pidnap 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/src/manager.ts ADDED
@@ -0,0 +1,859 @@
1
+ import * as v from "valibot";
2
+ import { join } from "node:path";
3
+ import { cwd as getCwd } from "node:process";
4
+ import { mkdirSync } from "node:fs";
5
+ import { ProcessDefinitionSchema, type ProcessDefinition } from "./lazy-process.ts";
6
+ import type { Logger } from "./logger.ts";
7
+ import { TaskList, type NamedProcessDefinition } from "./task-list.ts";
8
+ import { CronProcess, CronProcessOptionsSchema } from "./cron-process.ts";
9
+ import {
10
+ RestartingProcess,
11
+ RestartingProcessOptionsSchema,
12
+ type RestartingProcessOptions,
13
+ } from "./restarting-process.ts";
14
+ import { EnvManager } from "./env-manager.ts";
15
+
16
+ // Valibot schemas
17
+
18
+ // HTTP server configuration schema
19
+ export const HttpServerConfigSchema = v.object({
20
+ host: v.optional(v.string()),
21
+ port: v.optional(v.number()),
22
+ authToken: v.optional(v.string()),
23
+ });
24
+
25
+ export type HttpServerConfig = v.InferOutput<typeof HttpServerConfigSchema>;
26
+
27
+ // Cron process entry schema
28
+ export const CronProcessEntrySchema = v.object({
29
+ name: v.string(),
30
+ definition: ProcessDefinitionSchema,
31
+ options: CronProcessOptionsSchema,
32
+ envFile: v.optional(v.string()),
33
+ });
34
+
35
+ export type CronProcessEntry = v.InferOutput<typeof CronProcessEntrySchema>;
36
+
37
+ // Env reload delay schema - can be number (ms), "immediately"/true, or false for disabled
38
+ export const EnvReloadDelaySchema = v.union([v.number(), v.boolean(), v.literal("immediately")]);
39
+
40
+ export type EnvReloadDelay = v.InferOutput<typeof EnvReloadDelaySchema>;
41
+
42
+ // Restarting process entry schema
43
+ export const RestartingProcessEntrySchema = v.object({
44
+ name: v.string(),
45
+ definition: ProcessDefinitionSchema,
46
+ options: v.optional(RestartingProcessOptionsSchema),
47
+ envFile: v.optional(v.string()),
48
+ envReloadDelay: v.optional(EnvReloadDelaySchema), // Default 5000ms
49
+ });
50
+
51
+ export type RestartingProcessEntry = v.InferOutput<typeof RestartingProcessEntrySchema>;
52
+
53
+ // Task entry schema with envFile
54
+ export const TaskEntrySchema = v.object({
55
+ name: v.string(),
56
+ definition: ProcessDefinitionSchema,
57
+ envFile: v.optional(v.string()),
58
+ });
59
+
60
+ export type TaskEntry = v.InferOutput<typeof TaskEntrySchema>;
61
+
62
+ // Main manager configuration schema
63
+ export const ManagerConfigSchema = v.object({
64
+ http: v.optional(HttpServerConfigSchema),
65
+ cwd: v.optional(v.string()),
66
+ logDir: v.optional(v.string()),
67
+ env: v.optional(v.record(v.string(), v.string())),
68
+ envFiles: v.optional(v.record(v.string(), v.string())),
69
+ tasks: v.optional(v.array(TaskEntrySchema)),
70
+ crons: v.optional(v.array(CronProcessEntrySchema)),
71
+ processes: v.optional(v.array(RestartingProcessEntrySchema)),
72
+ });
73
+
74
+ export type ManagerConfig = v.InferOutput<typeof ManagerConfigSchema>;
75
+
76
+ // Default restart policy when not specified
77
+ const DEFAULT_RESTART_OPTIONS = {
78
+ restartPolicy: "always" as const,
79
+ };
80
+
81
+ // Hardcoded shutdown configuration
82
+ const SHUTDOWN_TIMEOUT_MS = 15000;
83
+ const SHUTDOWN_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
84
+ const SHUTDOWN_EXIT_CODE = 0;
85
+
86
+ // Manager state
87
+ export type ManagerState =
88
+ | "idle" // Not started
89
+ | "initializing" // Running task list
90
+ | "running" // All processes running
91
+ | "stopping" // Stopping all processes
92
+ | "stopped"; // Fully stopped
93
+
94
+ export class Manager {
95
+ private config: ManagerConfig;
96
+ private logger: Logger;
97
+ private envManager: EnvManager;
98
+ private envFileKeyByProcess: Map<string, string> = new Map();
99
+
100
+ private _state: ManagerState = "idle";
101
+ private taskList: TaskList | null = null;
102
+ private cronProcesses: Map<string, CronProcess> = new Map();
103
+ private restartingProcesses: Map<string, RestartingProcess> = new Map();
104
+ private logDir: string;
105
+
106
+ // Env reload tracking
107
+ private processEnvReloadConfig: Map<string, EnvReloadDelay> = new Map();
108
+ private envReloadTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
109
+ private envChangeUnsubscribe: (() => void) | null = null;
110
+
111
+ // Shutdown handling
112
+ private signalHandlers: Map<NodeJS.Signals, () => void> = new Map();
113
+ private shutdownPromise: Promise<void> | null = null;
114
+ private isShuttingDown: boolean = false;
115
+
116
+ constructor(config: ManagerConfig, logger: Logger) {
117
+ this.config = config;
118
+ this.logger = logger;
119
+ this.logDir = config.logDir ?? join(getCwd(), "logs");
120
+ this.ensureLogDirs();
121
+ // Initialize EnvManager with watching enabled
122
+ this.envManager = new EnvManager({
123
+ cwd: config.cwd,
124
+ files: config.envFiles,
125
+ watch: true,
126
+ });
127
+
128
+ // Register env change handler
129
+ this.envChangeUnsubscribe = this.envManager.onChange((changedKeys) => {
130
+ this.handleEnvChange(changedKeys);
131
+ });
132
+
133
+ // Automatically register shutdown handlers
134
+ this.registerShutdownHandlers();
135
+ }
136
+
137
+ /**
138
+ * Merge global env with process-specific env and apply cwd inheritance
139
+ * Merge order: .env (global), config.env (global), .env.<processName>, processEnvFile, definition.env
140
+ */
141
+ private applyDefaults(
142
+ processName: string,
143
+ definition: ProcessDefinition,
144
+ envFile?: string,
145
+ ): ProcessDefinition {
146
+ // Start with env from .env file (global)
147
+ const envFromFiles = this.envManager.getEnvVars(processName);
148
+
149
+ // Build the env file map for this specific process if envFile is provided
150
+ let envFromCustomFile: Record<string, string> = {};
151
+ if (envFile) {
152
+ let key = this.envFileKeyByProcess.get(processName);
153
+ if (!key) {
154
+ key = `custom:${processName}`;
155
+ this.envFileKeyByProcess.set(processName, key);
156
+ this.envManager.registerFile(key, envFile);
157
+ }
158
+ envFromCustomFile = this.envManager.getEnvForKey(key);
159
+ }
160
+
161
+ return {
162
+ ...definition,
163
+ cwd: definition.cwd ?? this.config.cwd,
164
+ env: {
165
+ ...envFromFiles, // .env (global) + .env.<processName>
166
+ ...this.config.env, // Global env from config
167
+ ...envFromCustomFile, // Custom env file if specified
168
+ ...definition.env, // Process-specific env overrides
169
+ },
170
+ };
171
+ }
172
+
173
+ private processLogFile(name: string): string {
174
+ return join(this.logDir, "process", `${name}.log`);
175
+ }
176
+
177
+ private taskLogFile(name: string): string {
178
+ return join(this.logDir, "tasks", `${name}.log`);
179
+ }
180
+
181
+ private cronLogFile(name: string): string {
182
+ return join(this.logDir, "cron", `${name}.log`);
183
+ }
184
+
185
+ private ensureLogDirs(): void {
186
+ mkdirSync(this.logDir, { recursive: true });
187
+ mkdirSync(join(this.logDir, "process"), { recursive: true });
188
+ mkdirSync(join(this.logDir, "tasks"), { recursive: true });
189
+ mkdirSync(join(this.logDir, "cron"), { recursive: true });
190
+ }
191
+
192
+ /**
193
+ * Handle env file changes
194
+ */
195
+ private handleEnvChange(changedKeys: string[]): void {
196
+ if (this._state !== "running") {
197
+ return; // Only handle changes when manager is running
198
+ }
199
+
200
+ this.logger.info(`Env files changed for keys: ${changedKeys.join(", ")}`);
201
+
202
+ // Check which processes are affected
203
+ const affectedProcesses = new Set<string>();
204
+
205
+ for (const key of changedKeys) {
206
+ // Global key affects all processes
207
+ if (key === "global") {
208
+ for (const processName of this.restartingProcesses.keys()) {
209
+ affectedProcesses.add(processName);
210
+ }
211
+ } else {
212
+ // Specific key affects matching process
213
+ if (this.restartingProcesses.has(key)) {
214
+ affectedProcesses.add(key);
215
+ }
216
+
217
+ for (const [processName, customKey] of this.envFileKeyByProcess.entries()) {
218
+ if (customKey === key && this.restartingProcesses.has(processName)) {
219
+ affectedProcesses.add(processName);
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ // Schedule restarts for affected processes
226
+ for (const processName of affectedProcesses) {
227
+ const reloadDelay = this.processEnvReloadConfig.get(processName);
228
+
229
+ // Skip if reload is disabled for this process
230
+ if (reloadDelay === false) {
231
+ continue;
232
+ }
233
+
234
+ this.scheduleProcessReload(processName, reloadDelay);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Schedule a process reload with debouncing
240
+ */
241
+ private scheduleProcessReload(processName: string, reloadDelay?: EnvReloadDelay): void {
242
+ // Clear existing timer if any
243
+ const existingTimer = this.envReloadTimers.get(processName);
244
+ if (existingTimer) {
245
+ clearTimeout(existingTimer);
246
+ }
247
+
248
+ // Determine delay in ms
249
+ let delayMs: number;
250
+ if (reloadDelay === false) {
251
+ return; // Should not happen, but guard anyway
252
+ } else if (reloadDelay === true || reloadDelay === "immediately") {
253
+ delayMs = 0;
254
+ } else if (typeof reloadDelay === "number") {
255
+ delayMs = reloadDelay;
256
+ } else {
257
+ delayMs = 5000; // Default 5 seconds
258
+ }
259
+
260
+ this.logger.info(`Scheduling reload for process "${processName}" in ${delayMs}ms`);
261
+
262
+ const timer = setTimeout(async () => {
263
+ await this.reloadProcessEnv(processName);
264
+ this.envReloadTimers.delete(processName);
265
+ }, delayMs);
266
+
267
+ this.envReloadTimers.set(processName, timer);
268
+ }
269
+
270
+ /**
271
+ * Reload a process with updated env vars
272
+ */
273
+ private async reloadProcessEnv(processName: string): Promise<void> {
274
+ const proc = this.restartingProcesses.get(processName);
275
+ if (!proc) {
276
+ this.logger.warn(`Process "${processName}" not found for env reload`);
277
+ return;
278
+ }
279
+
280
+ this.logger.info(`Reloading process "${processName}" due to env change`);
281
+
282
+ // Get the original config for this process
283
+ const processConfig = this.config.processes?.find((p) => p.name === processName);
284
+ if (!processConfig) {
285
+ this.logger.warn(`Process config for "${processName}" not found`);
286
+ return;
287
+ }
288
+
289
+ // Rebuild definition with updated env
290
+ const updatedDefinition = this.applyDefaults(
291
+ processName,
292
+ processConfig.definition,
293
+ processConfig.envFile,
294
+ );
295
+
296
+ // Reload the process (graceful restart)
297
+ await proc.reload(updatedDefinition, true);
298
+ }
299
+
300
+ get state(): ManagerState {
301
+ return this._state;
302
+ }
303
+
304
+ /**
305
+ * Get all cron processes (read-only access)
306
+ */
307
+ getCronProcesses(): ReadonlyMap<string, CronProcess> {
308
+ return this.cronProcesses;
309
+ }
310
+
311
+ /**
312
+ * Get a specific cron process by name
313
+ */
314
+ getCronProcess(name: string): CronProcess | undefined {
315
+ return this.cronProcesses.get(name);
316
+ }
317
+
318
+ /**
319
+ * Get all restarting processes (read-only access)
320
+ */
321
+ getRestartingProcesses(): ReadonlyMap<string, RestartingProcess> {
322
+ return this.restartingProcesses;
323
+ }
324
+
325
+ /**
326
+ * Get a specific restarting process by name
327
+ */
328
+ getRestartingProcess(name: string): RestartingProcess | undefined {
329
+ return this.restartingProcesses.get(name);
330
+ }
331
+
332
+ /**
333
+ * Get the task list (read-only access)
334
+ */
335
+ getTaskList(): TaskList | null {
336
+ return this.taskList;
337
+ }
338
+
339
+ /**
340
+ * Get a restarting process by name or index
341
+ */
342
+ getProcessByTarget(target: string | number): RestartingProcess | undefined {
343
+ if (typeof target === "string") {
344
+ return this.restartingProcesses.get(target);
345
+ }
346
+ const entries = Array.from(this.restartingProcesses.values());
347
+ return entries[target];
348
+ }
349
+
350
+ /**
351
+ * Get a cron process by name or index
352
+ */
353
+ getCronByTarget(target: string | number): CronProcess | undefined {
354
+ if (typeof target === "string") {
355
+ return this.cronProcesses.get(target);
356
+ }
357
+ const entries = Array.from(this.cronProcesses.values());
358
+ return entries[target];
359
+ }
360
+
361
+ /**
362
+ * Get a task by id or index
363
+ */
364
+ getTaskByTarget(
365
+ target: string | number,
366
+ ): { id: string; state: string; processNames: string[] } | undefined {
367
+ if (!this.taskList) return undefined;
368
+ const tasks = this.taskList.tasks;
369
+
370
+ if (typeof target === "string") {
371
+ const task = tasks.find((t) => t.id === target);
372
+ if (!task) return undefined;
373
+ return {
374
+ id: task.id,
375
+ state: task.state,
376
+ processNames: task.processes.map((p) => p.name),
377
+ };
378
+ }
379
+
380
+ const task = tasks[target];
381
+ if (!task) return undefined;
382
+ return {
383
+ id: task.id,
384
+ state: task.state,
385
+ processNames: task.processes.map((p) => p.name),
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Start a restarting process by target
391
+ */
392
+ startProcessByTarget(target: string | number): RestartingProcess {
393
+ const proc = this.getProcessByTarget(target);
394
+ if (!proc) {
395
+ throw new Error(`Process not found: ${target}`);
396
+ }
397
+ proc.start();
398
+ return proc;
399
+ }
400
+
401
+ /**
402
+ * Stop a restarting process by target
403
+ */
404
+ async stopProcessByTarget(target: string | number, timeout?: number): Promise<RestartingProcess> {
405
+ const proc = this.getProcessByTarget(target);
406
+ if (!proc) {
407
+ throw new Error(`Process not found: ${target}`);
408
+ }
409
+ await proc.stop(timeout);
410
+ return proc;
411
+ }
412
+
413
+ /**
414
+ * Restart a restarting process by target
415
+ */
416
+ async restartProcessByTarget(
417
+ target: string | number,
418
+ force: boolean = false,
419
+ ): Promise<RestartingProcess> {
420
+ const proc = this.getProcessByTarget(target);
421
+ if (!proc) {
422
+ throw new Error(`Process not found: ${target}`);
423
+ }
424
+ await proc.restart(force);
425
+ return proc;
426
+ }
427
+
428
+ /**
429
+ * Reload a restarting process with new definition
430
+ */
431
+ async reloadProcessByTarget(
432
+ target: string | number,
433
+ newDefinition: ProcessDefinition,
434
+ options?: {
435
+ restartImmediately?: boolean;
436
+ updateOptions?: Partial<RestartingProcessOptions>;
437
+ },
438
+ ): Promise<RestartingProcess> {
439
+ const proc = this.getProcessByTarget(target);
440
+ if (!proc) {
441
+ throw new Error(`Process not found: ${target}`);
442
+ }
443
+
444
+ // Apply global defaults to new definition
445
+ const definitionWithDefaults = this.applyDefaults(proc.name, newDefinition);
446
+
447
+ // Update options if provided
448
+ if (options?.updateOptions) {
449
+ proc.updateOptions(options.updateOptions);
450
+ }
451
+
452
+ // Reload with new definition
453
+ await proc.reload(definitionWithDefaults, options?.restartImmediately ?? true);
454
+ this.logger.info(`Reloaded process: ${proc.name}`);
455
+ return proc;
456
+ }
457
+
458
+ /**
459
+ * Remove a restarting process by target
460
+ */
461
+ async removeProcessByTarget(target: string | number, timeout?: number): Promise<void> {
462
+ const proc = this.getProcessByTarget(target);
463
+ if (!proc) {
464
+ throw new Error(`Process not found: ${target}`);
465
+ }
466
+
467
+ // Stop the process first
468
+ await proc.stop(timeout);
469
+
470
+ // Remove from the map
471
+ this.restartingProcesses.delete(proc.name);
472
+ this.logger.info(`Removed process: ${proc.name}`);
473
+ }
474
+
475
+ /**
476
+ * Add a task to the task list
477
+ * Creates the task list if it doesn't exist and starts it
478
+ */
479
+ addTask(
480
+ name: string,
481
+ definition: ProcessDefinition,
482
+ ): { id: string; state: string; processNames: string[] } {
483
+ if (!this.taskList) {
484
+ const taskListLogger = this.logger.child("tasks", {
485
+ logFile: this.taskLogFile("tasks"),
486
+ });
487
+ this.taskList = new TaskList("runtime", taskListLogger, undefined, (processName) => {
488
+ return this.taskLogFile(processName);
489
+ });
490
+ }
491
+
492
+ const namedProcess: NamedProcessDefinition = {
493
+ name,
494
+ process: this.applyDefaults(name, definition),
495
+ };
496
+ const id = this.taskList.addTask(namedProcess);
497
+
498
+ // Start the task list if it's idle so the task runs immediately
499
+ if (this.taskList.state === "idle") {
500
+ this.taskList.start();
501
+ }
502
+
503
+ return {
504
+ id,
505
+ state: "pending",
506
+ processNames: [name],
507
+ };
508
+ }
509
+
510
+ removeTaskByTarget(target: string | number): {
511
+ id: string;
512
+ state: string;
513
+ processNames: string[];
514
+ } {
515
+ if (!this.taskList) {
516
+ throw new Error(`Task list not initialized`);
517
+ }
518
+
519
+ const removed = this.taskList.removeTaskByTarget(target);
520
+ return {
521
+ id: removed.id,
522
+ state: removed.state,
523
+ processNames: removed.processes.map((p) => p.name),
524
+ };
525
+ }
526
+
527
+ /**
528
+ * Add a restarting process at runtime
529
+ */
530
+ addProcess(
531
+ name: string,
532
+ definition: ProcessDefinition,
533
+ options?: RestartingProcessOptions,
534
+ envReloadDelay?: EnvReloadDelay,
535
+ ): RestartingProcess {
536
+ if (this.restartingProcesses.has(name)) {
537
+ throw new Error(`Process "${name}" already exists`);
538
+ }
539
+
540
+ const processLogger = this.logger.child(name, { logFile: this.processLogFile(name) });
541
+ const restartingProcess = new RestartingProcess(
542
+ name,
543
+ this.applyDefaults(name, definition),
544
+ options ?? DEFAULT_RESTART_OPTIONS,
545
+ processLogger,
546
+ );
547
+ this.restartingProcesses.set(name, restartingProcess);
548
+ restartingProcess.start();
549
+
550
+ // Track env reload config for this process
551
+ this.processEnvReloadConfig.set(name, envReloadDelay ?? 5000);
552
+
553
+ this.logger.info(`Added and started restarting process: ${name}`);
554
+ return restartingProcess;
555
+ }
556
+
557
+ /**
558
+ * Trigger a cron process by target
559
+ */
560
+ async triggerCronByTarget(target: string | number): Promise<CronProcess> {
561
+ const cron = this.getCronByTarget(target);
562
+ if (!cron) {
563
+ throw new Error(`Cron not found: ${target}`);
564
+ }
565
+ await cron.trigger();
566
+ return cron;
567
+ }
568
+
569
+ /**
570
+ * Start a cron process by target
571
+ */
572
+ startCronByTarget(target: string | number): CronProcess {
573
+ const cron = this.getCronByTarget(target);
574
+ if (!cron) {
575
+ throw new Error(`Cron not found: ${target}`);
576
+ }
577
+ cron.start();
578
+ return cron;
579
+ }
580
+
581
+ /**
582
+ * Stop a cron process by target
583
+ */
584
+ async stopCronByTarget(target: string | number, timeout?: number): Promise<CronProcess> {
585
+ const cron = this.getCronByTarget(target);
586
+ if (!cron) {
587
+ throw new Error(`Cron not found: ${target}`);
588
+ }
589
+ await cron.stop(timeout);
590
+ return cron;
591
+ }
592
+
593
+ /**
594
+ * Start the manager:
595
+ * 1. Run task list (if configured) and wait for completion
596
+ * 2. Create and start all cron/restarting processes
597
+ */
598
+ async start(): Promise<void> {
599
+ if (this._state !== "idle" && this._state !== "stopped") {
600
+ throw new Error(`Manager is already ${this._state}`);
601
+ }
602
+
603
+ this.logger.info(`Starting manager`);
604
+
605
+ // Phase 1: Run initialization tasks
606
+ if (this.config.tasks && this.config.tasks.length > 0) {
607
+ this._state = "initializing";
608
+ this.logger.info(`Running initialization tasks`);
609
+
610
+ const taskListLogger = this.logger.child("tasks");
611
+ // Apply defaults to all tasks
612
+ const tasksWithDefaults = this.config.tasks.map((task) => ({
613
+ name: task.name,
614
+ process: this.applyDefaults(task.name, task.definition, task.envFile),
615
+ }));
616
+ this.taskList = new TaskList("init", taskListLogger, tasksWithDefaults, (processName) => {
617
+ return this.taskLogFile(processName);
618
+ });
619
+
620
+ this.taskList.start();
621
+ await this.taskList.waitUntilIdle();
622
+
623
+ // Check if any tasks failed
624
+ const failedTasks = this.taskList.tasks.filter((t) => t.state === "failed");
625
+ if (failedTasks.length > 0) {
626
+ this._state = "stopped";
627
+ const failedNames = failedTasks.map((t) => t.id).join(", ");
628
+ throw new Error(`Initialization failed: tasks [${failedNames}] failed`);
629
+ }
630
+
631
+ this.logger.info(`Initialization tasks completed`);
632
+ }
633
+
634
+ // Phase 2: Create and start cron processes
635
+ if (this.config.crons) {
636
+ for (const entry of this.config.crons) {
637
+ const processLogger = this.logger.child(entry.name, {
638
+ logFile: this.cronLogFile(entry.name),
639
+ });
640
+ const cronProcess = new CronProcess(
641
+ entry.name,
642
+ this.applyDefaults(entry.name, entry.definition, entry.envFile),
643
+ entry.options,
644
+ processLogger,
645
+ );
646
+ this.cronProcesses.set(entry.name, cronProcess);
647
+ cronProcess.start();
648
+ this.logger.info(`Started cron process: ${entry.name}`);
649
+ }
650
+ }
651
+
652
+ // Phase 3: Create and start restarting processes
653
+ if (this.config.processes) {
654
+ for (const entry of this.config.processes) {
655
+ const processLogger = this.logger.child(entry.name, {
656
+ logFile: this.processLogFile(entry.name),
657
+ });
658
+ const restartingProcess = new RestartingProcess(
659
+ entry.name,
660
+ this.applyDefaults(entry.name, entry.definition, entry.envFile),
661
+ entry.options ?? DEFAULT_RESTART_OPTIONS,
662
+ processLogger,
663
+ );
664
+ this.restartingProcesses.set(entry.name, restartingProcess);
665
+ restartingProcess.start();
666
+
667
+ // Track env reload config for this process
668
+ this.processEnvReloadConfig.set(
669
+ entry.name,
670
+ entry.envReloadDelay ?? 5000, // Default to 5000ms
671
+ );
672
+
673
+ this.logger.info(`Started restarting process: ${entry.name}`);
674
+ }
675
+ }
676
+
677
+ this._state = "running";
678
+ this.logger.info(
679
+ `Manager started with ${this.cronProcesses.size} cron process(es) and ${this.restartingProcesses.size} restarting process(es)`,
680
+ );
681
+ }
682
+
683
+ /**
684
+ * Stop all managed processes
685
+ */
686
+ async stop(timeout?: number): Promise<void> {
687
+ if (this._state === "idle" || this._state === "stopped") {
688
+ this._state = "stopped";
689
+ this.unregisterShutdownHandlers();
690
+ return;
691
+ }
692
+
693
+ this._state = "stopping";
694
+ this.logger.info(`Stopping manager`);
695
+
696
+ // Clear all env reload timers
697
+ for (const timer of this.envReloadTimers.values()) {
698
+ clearTimeout(timer);
699
+ }
700
+ this.envReloadTimers.clear();
701
+
702
+ // Unsubscribe from env changes
703
+ if (this.envChangeUnsubscribe) {
704
+ this.envChangeUnsubscribe();
705
+ this.envChangeUnsubscribe = null;
706
+ }
707
+
708
+ // Dispose env manager
709
+ this.envManager.dispose();
710
+
711
+ // Stop task list if still running
712
+ if (this.taskList) {
713
+ await this.taskList.stop(timeout);
714
+ }
715
+
716
+ // Stop all cron processes in parallel
717
+ const cronStopPromises = Array.from(this.cronProcesses.values()).map((p) => p.stop(timeout));
718
+
719
+ // Stop all restarting processes in parallel
720
+ const restartingStopPromises = Array.from(this.restartingProcesses.values()).map((p) =>
721
+ p.stop(timeout),
722
+ );
723
+
724
+ await Promise.all([...cronStopPromises, ...restartingStopPromises]);
725
+
726
+ this._state = "stopped";
727
+ this.logger.info(`Manager stopped`);
728
+
729
+ this.unregisterShutdownHandlers();
730
+ }
731
+
732
+ /**
733
+ * Register signal handlers for graceful shutdown
734
+ * Called automatically by constructor
735
+ */
736
+ private registerShutdownHandlers(): void {
737
+ if (this.signalHandlers.size > 0) {
738
+ this.logger.warn(`Shutdown handlers already registered`);
739
+ return;
740
+ }
741
+
742
+ for (const signal of SHUTDOWN_SIGNALS) {
743
+ const handler = () => this.handleSignal(signal);
744
+ this.signalHandlers.set(signal, handler);
745
+ process.on(signal, handler);
746
+ this.logger.debug(`Registered handler for ${signal}`);
747
+ }
748
+
749
+ this.logger.info(`Shutdown handlers registered for signals: ${SHUTDOWN_SIGNALS.join(", ")}`);
750
+ }
751
+
752
+ /**
753
+ * Unregister signal handlers for graceful shutdown
754
+ */
755
+ private unregisterShutdownHandlers(): void {
756
+ if (this.signalHandlers.size === 0) {
757
+ return;
758
+ }
759
+
760
+ for (const [signal, handler] of this.signalHandlers.entries()) {
761
+ process.off(signal, handler);
762
+ this.logger.debug(`Unregistered handler for ${signal}`);
763
+ }
764
+
765
+ this.signalHandlers.clear();
766
+ }
767
+
768
+ /**
769
+ * Wait for shutdown to complete (useful for keeping process alive)
770
+ * Resolves when the manager has fully stopped
771
+ */
772
+ async waitForShutdown(): Promise<void> {
773
+ // If already stopped, return immediately
774
+ if (this._state === "stopped") {
775
+ return;
776
+ }
777
+
778
+ // If shutdown is in progress, wait for it
779
+ if (this.shutdownPromise) {
780
+ await this.shutdownPromise;
781
+ return;
782
+ }
783
+
784
+ // Wait for state to become stopped
785
+ return new Promise((resolve) => {
786
+ const checkInterval = setInterval(() => {
787
+ if (this._state === "stopped") {
788
+ clearInterval(checkInterval);
789
+ resolve();
790
+ }
791
+ }, 100);
792
+ });
793
+ }
794
+
795
+ /**
796
+ * Trigger graceful shutdown programmatically
797
+ */
798
+ async shutdown(): Promise<void> {
799
+ if (this.isShuttingDown) {
800
+ this.logger.warn(`Shutdown already in progress`);
801
+ if (this.shutdownPromise) {
802
+ await this.shutdownPromise;
803
+ }
804
+ return;
805
+ }
806
+
807
+ this.isShuttingDown = true;
808
+ this.logger.info(`Initiating graceful shutdown (timeout: ${SHUTDOWN_TIMEOUT_MS}ms)`);
809
+
810
+ this.shutdownPromise = this.performShutdown();
811
+ await this.shutdownPromise;
812
+ }
813
+
814
+ private handleSignal(signal: NodeJS.Signals): void {
815
+ this.logger.info(`Received ${signal}, initiating graceful shutdown...`);
816
+
817
+ // Prevent handling multiple signals
818
+ if (this.isShuttingDown) {
819
+ this.logger.warn(`Shutdown already in progress, ignoring ${signal}`);
820
+ return;
821
+ }
822
+
823
+ this.isShuttingDown = true;
824
+ this.shutdownPromise = this.performShutdown();
825
+
826
+ this.shutdownPromise
827
+ .then(() => {
828
+ this.logger.info(`Exiting with code ${SHUTDOWN_EXIT_CODE}`);
829
+ process.exit(SHUTDOWN_EXIT_CODE);
830
+ })
831
+ .catch((err) => {
832
+ this.logger.error(`Shutdown error:`, err);
833
+ process.exit(1);
834
+ });
835
+ }
836
+
837
+ private async performShutdown(): Promise<void> {
838
+ // Create a timeout promise
839
+ const timeoutPromise = new Promise<never>((_, reject) => {
840
+ setTimeout(() => {
841
+ reject(new Error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms`));
842
+ }, SHUTDOWN_TIMEOUT_MS);
843
+ });
844
+
845
+ try {
846
+ // Race between graceful stop and timeout
847
+ await Promise.race([this.stop(SHUTDOWN_TIMEOUT_MS), timeoutPromise]);
848
+ this.logger.info(`Graceful shutdown completed`);
849
+ } catch (err) {
850
+ this.logger.error(`Shutdown error:`, err);
851
+ // Force stop on timeout
852
+ this._state = "stopped";
853
+ throw err;
854
+ } finally {
855
+ this.isShuttingDown = false;
856
+ this.shutdownPromise = null;
857
+ }
858
+ }
859
+ }