pidnap 0.0.0-dev.0 → 0.0.0-dev.2

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 CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type RestartingProcessOptions,
13
13
  } from "./restarting-process.ts";
14
14
  import { EnvManager } from "./env-manager.ts";
15
+ import { killProcessOnPort } from "./port-utils.ts";
15
16
 
16
17
  // Valibot schemas
17
18
 
@@ -46,6 +47,7 @@ export const RestartingProcessEntrySchema = v.object({
46
47
  options: v.optional(RestartingProcessOptionsSchema),
47
48
  envFile: v.optional(v.string()),
48
49
  envReloadDelay: v.optional(EnvReloadDelaySchema), // Default 5000ms
50
+ port: v.optional(v.number()), // Kill any process on this port before spawning
49
51
  });
50
52
 
51
53
  export type RestartingProcessEntry = v.InferOutput<typeof RestartingProcessEntrySchema>;
@@ -108,6 +110,9 @@ export class Manager {
108
110
  private envReloadTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
109
111
  private envChangeUnsubscribe: (() => void) | null = null;
110
112
 
113
+ // Port tracking for processes
114
+ private processPortConfig: Map<string, number> = new Map();
115
+
111
116
  // Shutdown handling
112
117
  private signalHandlers: Map<NodeJS.Signals, () => void> = new Map();
113
118
  private shutdownPromise: Promise<void> | null = null;
@@ -182,6 +187,19 @@ export class Manager {
182
187
  return join(this.logDir, "cron", `${name}.log`);
183
188
  }
184
189
 
190
+ /**
191
+ * Kill any process running on the configured port for a process
192
+ */
193
+ private async killPortForProcess(processName: string): Promise<void> {
194
+ const port = this.processPortConfig.get(processName);
195
+ if (port === undefined) return;
196
+
197
+ const killed = await killProcessOnPort(port);
198
+ if (killed) {
199
+ this.logger.info(`Killed process on port ${port} before starting "${processName}"`);
200
+ }
201
+ }
202
+
185
203
  private ensureLogDirs(): void {
186
204
  mkdirSync(this.logDir, { recursive: true });
187
205
  mkdirSync(join(this.logDir, "process"), { recursive: true });
@@ -389,11 +407,13 @@ export class Manager {
389
407
  /**
390
408
  * Start a restarting process by target
391
409
  */
392
- startProcessByTarget(target: string | number): RestartingProcess {
410
+ async startProcessByTarget(target: string | number): Promise<RestartingProcess> {
393
411
  const proc = this.getProcessByTarget(target);
394
412
  if (!proc) {
395
413
  throw new Error(`Process not found: ${target}`);
396
414
  }
415
+ // Kill any process on the configured port before starting
416
+ await this.killPortForProcess(proc.name);
397
417
  proc.start();
398
418
  return proc;
399
419
  }
@@ -421,6 +441,8 @@ export class Manager {
421
441
  if (!proc) {
422
442
  throw new Error(`Process not found: ${target}`);
423
443
  }
444
+ // Kill any process on the configured port before restarting
445
+ await this.killPortForProcess(proc.name);
424
446
  await proc.restart(force);
425
447
  return proc;
426
448
  }
@@ -527,12 +549,13 @@ export class Manager {
527
549
  /**
528
550
  * Add a restarting process at runtime
529
551
  */
530
- addProcess(
552
+ async addProcess(
531
553
  name: string,
532
554
  definition: ProcessDefinition,
533
555
  options?: RestartingProcessOptions,
534
556
  envReloadDelay?: EnvReloadDelay,
535
- ): RestartingProcess {
557
+ port?: number,
558
+ ): Promise<RestartingProcess> {
536
559
  if (this.restartingProcesses.has(name)) {
537
560
  throw new Error(`Process "${name}" already exists`);
538
561
  }
@@ -545,6 +568,13 @@ export class Manager {
545
568
  processLogger,
546
569
  );
547
570
  this.restartingProcesses.set(name, restartingProcess);
571
+
572
+ // Track port config and kill any process on that port before starting
573
+ if (port !== undefined) {
574
+ this.processPortConfig.set(name, port);
575
+ await this.killPortForProcess(name);
576
+ }
577
+
548
578
  restartingProcess.start();
549
579
 
550
580
  // Track env reload config for this process
@@ -662,6 +692,13 @@ export class Manager {
662
692
  processLogger,
663
693
  );
664
694
  this.restartingProcesses.set(entry.name, restartingProcess);
695
+
696
+ // Track port config and kill any process on that port before starting
697
+ if (entry.port !== undefined) {
698
+ this.processPortConfig.set(entry.name, entry.port);
699
+ await this.killPortForProcess(entry.name);
700
+ }
701
+
665
702
  restartingProcess.start();
666
703
 
667
704
  // Track env reload config for this process
@@ -0,0 +1,39 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { treeKill } from "./tree-kill.ts";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ /**
8
+ * Kill any process listening on the specified port, including all descendants.
9
+ */
10
+ export async function killProcessOnPort(port: number): Promise<boolean> {
11
+ try {
12
+ // Use lsof to find the process ID listening on the port
13
+ const { stdout } = await execAsync(`lsof -ti tcp:${port}`);
14
+ const pids = stdout
15
+ .trim()
16
+ .split("\n")
17
+ .filter(Boolean)
18
+ .map((p) => parseInt(p, 10))
19
+ .filter((p) => !isNaN(p));
20
+
21
+ if (pids.length === 0) {
22
+ return false; // No process found on port
23
+ }
24
+
25
+ // Tree kill all processes found on the port (kills children/grandchildren too)
26
+ for (const pid of pids) {
27
+ try {
28
+ await treeKill(pid, "SIGKILL");
29
+ } catch {
30
+ // Process may have already exited
31
+ }
32
+ }
33
+
34
+ return true;
35
+ } catch {
36
+ // Command failed (e.g., no process on port)
37
+ return false;
38
+ }
39
+ }
@@ -0,0 +1,131 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ type ProcessTree = Record<number, number[]>;
4
+
5
+ /**
6
+ * Kill a process and all its descendants (children, grandchildren, etc.)
7
+ * @param pid - The process ID to kill
8
+ * @param signal - The signal to send (default: SIGTERM)
9
+ * @returns Promise that resolves when all processes have been signaled
10
+ */
11
+ export async function treeKill(pid: number, signal: NodeJS.Signals = "SIGTERM"): Promise<void> {
12
+ if (Number.isNaN(pid)) {
13
+ throw new Error("pid must be a number");
14
+ }
15
+
16
+ const tree: ProcessTree = { [pid]: [] };
17
+ const pidsToProcess = new Set<number>([pid]);
18
+
19
+ // Build the process tree
20
+ await buildProcessTree(pid, tree, pidsToProcess);
21
+
22
+ // Kill all processes in the tree (children first, then parents)
23
+ killAll(tree, signal);
24
+ }
25
+
26
+ /**
27
+ * Build a tree of all child processes recursively
28
+ */
29
+ async function buildProcessTree(
30
+ parentPid: number,
31
+ tree: ProcessTree,
32
+ pidsToProcess: Set<number>,
33
+ ): Promise<void> {
34
+ return new Promise((resolve) => {
35
+ const ps = spawn("ps", ["-o", "pid", "--no-headers", "--ppid", String(parentPid)]);
36
+
37
+ let allData = "";
38
+
39
+ ps.stdout.on("data", (data: Buffer) => {
40
+ allData += data.toString("ascii");
41
+ });
42
+
43
+ ps.on("close", async (code) => {
44
+ pidsToProcess.delete(parentPid);
45
+
46
+ if (code !== 0) {
47
+ // No child processes found for this parent
48
+ if (pidsToProcess.size === 0) {
49
+ resolve();
50
+ }
51
+ return;
52
+ }
53
+
54
+ const childPids = allData.match(/\d+/g);
55
+ if (!childPids) {
56
+ if (pidsToProcess.size === 0) {
57
+ resolve();
58
+ }
59
+ return;
60
+ }
61
+
62
+ // Process all child PIDs concurrently
63
+ const promises: Promise<void>[] = [];
64
+
65
+ for (const pidStr of childPids) {
66
+ const childPid = parseInt(pidStr, 10);
67
+ tree[parentPid].push(childPid);
68
+ tree[childPid] = [];
69
+ pidsToProcess.add(childPid);
70
+ promises.push(buildProcessTree(childPid, tree, pidsToProcess));
71
+ }
72
+
73
+ await Promise.all(promises);
74
+
75
+ if (pidsToProcess.size === 0) {
76
+ resolve();
77
+ }
78
+ });
79
+
80
+ ps.on("error", () => {
81
+ pidsToProcess.delete(parentPid);
82
+ if (pidsToProcess.size === 0) {
83
+ resolve();
84
+ }
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Kill all processes in the tree
91
+ * Kills children before parents to ensure clean shutdown
92
+ */
93
+ function killAll(tree: ProcessTree, signal: NodeJS.Signals): void {
94
+ const killed = new Set<number>();
95
+
96
+ // Get all PIDs and sort by depth (deepest first)
97
+ const allPids = Object.keys(tree).map(Number);
98
+
99
+ // Kill children first, then parents
100
+ for (const pid of allPids) {
101
+ // Kill all children of this pid
102
+ for (const childPid of tree[pid]) {
103
+ if (!killed.has(childPid)) {
104
+ killPid(childPid, signal);
105
+ killed.add(childPid);
106
+ }
107
+ }
108
+ }
109
+
110
+ // Kill all parent pids
111
+ for (const pid of allPids) {
112
+ if (!killed.has(pid)) {
113
+ killPid(pid, signal);
114
+ killed.add(pid);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Kill a single process, ignoring ESRCH errors (process already dead)
121
+ */
122
+ function killPid(pid: number, signal: NodeJS.Signals): void {
123
+ try {
124
+ process.kill(pid, signal);
125
+ } catch (err) {
126
+ // ESRCH = No such process (already dead) - ignore this
127
+ if ((err as NodeJS.ErrnoException).code !== "ESRCH") {
128
+ throw err;
129
+ }
130
+ }
131
+ }