pidnap 0.0.0-dev.4 → 0.0.0-dev.5
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.mjs +157 -167
- package/dist/cli.mjs.map +1 -1
- package/dist/client.d.mts +0 -1
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +2 -2
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +263 -71
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{logger-BIJzGqk3.mjs → logger-BU2RmetS.mjs} +228 -283
- package/dist/logger-BU2RmetS.mjs.map +1 -0
- package/package.json +4 -2
- package/src/api/client.ts +2 -2
- package/src/api/contract.ts +8 -14
- package/src/cli.ts +36 -36
- package/src/cron-process.ts +9 -18
- package/src/env-manager.ts +170 -163
- package/src/index.ts +0 -1
- package/src/lazy-process.ts +54 -91
- package/src/logger.ts +28 -36
- package/src/manager.ts +155 -212
- package/src/restarting-process.ts +16 -29
- package/src/task-list.ts +3 -5
- package/src/utils.ts +10 -0
- package/dist/logger-BIJzGqk3.mjs.map +0 -1
- package/dist/task-list-CIdbB3wM.d.mts +0 -230
- package/dist/task-list-CIdbB3wM.d.mts.map +0 -1
- package/src/port-utils.ts +0 -39
- package/src/tree-kill.ts +0 -131
package/src/env-manager.ts
CHANGED
|
@@ -1,85 +1,94 @@
|
|
|
1
1
|
import { parse } from "dotenv";
|
|
2
|
-
import { existsSync, globSync,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, globSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve, basename, isAbsolute } from "node:path";
|
|
4
|
+
import { watch } from "chokidar";
|
|
5
5
|
|
|
6
|
-
export type
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
export type EnvChangeEvent =
|
|
7
|
+
| {
|
|
8
|
+
type: "global";
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
type: "process";
|
|
12
|
+
key: string;
|
|
13
|
+
};
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
* Explicit env file paths to load
|
|
17
|
-
* Key is the identifier (e.g., "global", "app1")
|
|
18
|
-
* Value is the file path relative to cwd or absolute
|
|
19
|
-
*/
|
|
20
|
-
files?: Record<string, string>;
|
|
15
|
+
export type EnvChangeCallback = (event: EnvChangeEvent) => void;
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
watch?: boolean;
|
|
17
|
+
export interface EnvManagerConfig {
|
|
18
|
+
cwd: string;
|
|
19
|
+
globalEnvFile?: string;
|
|
20
|
+
customEnvFiles?: Record<string, string>;
|
|
27
21
|
}
|
|
28
22
|
|
|
29
23
|
export class EnvManager {
|
|
24
|
+
private globalEnv: Record<string, string> = {};
|
|
25
|
+
private globalEnvPath: string;
|
|
30
26
|
private env: Map<string, Record<string, string>> = new Map();
|
|
31
|
-
private cwd: string;
|
|
32
|
-
private watchEnabled: boolean;
|
|
33
27
|
private watchers: Map<string, ReturnType<typeof watch>> = new Map();
|
|
34
|
-
private
|
|
28
|
+
private fileToKey: Map<string, string> = new Map();
|
|
35
29
|
private changeCallbacks: Set<EnvChangeCallback> = new Set();
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
private cwdWatcher: ReturnType<typeof watch> | null = null;
|
|
31
|
+
private customKeys: Set<string> = new Set();
|
|
32
|
+
|
|
33
|
+
constructor(private config: EnvManagerConfig) {
|
|
34
|
+
this.globalEnvPath = config.globalEnvFile
|
|
35
|
+
? isAbsolute(config.globalEnvFile)
|
|
36
|
+
? config.globalEnvFile
|
|
37
|
+
: resolve(config.cwd, config.globalEnvFile)
|
|
38
|
+
: resolve(config.cwd, ".env");
|
|
39
|
+
|
|
40
|
+
// Load custom env files first (before auto-discovery)
|
|
41
|
+
this.loadCustomEnvFiles();
|
|
43
42
|
this.loadEnvFilesFromCwd();
|
|
44
|
-
|
|
45
|
-
// Load explicitly specified files
|
|
46
|
-
if (config.files) {
|
|
47
|
-
for (const [key, filePath] of Object.entries(config.files)) {
|
|
48
|
-
this.loadEnvFile(key, filePath);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
43
|
+
this.watchCwdForNewFiles();
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Register a custom env file for a key.
|
|
48
|
+
* Once registered, auto-discovered .env.{key} files will be ignored for this key.
|
|
49
|
+
*/
|
|
50
|
+
public registerFile(key: string, filePath: string): void {
|
|
51
|
+
const absolutePath = isAbsolute(filePath) ? filePath : resolve(this.config.cwd, filePath);
|
|
52
|
+
this.customKeys.add(key);
|
|
53
|
+
this.loadEnvFile(key, absolutePath);
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Check if a key has a custom env file registered
|
|
58
|
+
*/
|
|
59
|
+
public hasCustomFile(key: string): boolean {
|
|
60
|
+
return this.customKeys.has(key);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
|
-
* Load
|
|
64
|
+
* Load custom env files from config
|
|
63
65
|
*/
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this.
|
|
66
|
+
private loadCustomEnvFiles(): void {
|
|
67
|
+
if (!this.config.customEnvFiles) return;
|
|
68
|
+
|
|
69
|
+
for (const [key, filePath] of Object.entries(this.config.customEnvFiles)) {
|
|
70
|
+
this.registerFile(key, filePath);
|
|
69
71
|
}
|
|
72
|
+
}
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
public getEnvVars(key: string): Record<string, string> {
|
|
75
|
+
const specificEnv = this.env.get(key);
|
|
76
|
+
return {
|
|
77
|
+
...this.globalEnv,
|
|
78
|
+
...specificEnv,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
75
81
|
|
|
82
|
+
private loadEnvFilesFromCwd(): void {
|
|
83
|
+
if (existsSync(this.globalEnvPath)) this.loadGlobalEnv(this.globalEnvPath);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const envFiles = globSync(".env.*", { cwd: this.config.cwd });
|
|
76
87
|
for (const filePath of envFiles) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const suffix = match[1];
|
|
82
|
-
this.loadEnvFile(suffix, filePath);
|
|
88
|
+
const key = this.getEnvKeySuffix(basename(filePath));
|
|
89
|
+
// Skip if key has a custom file registered
|
|
90
|
+
if (key && !this.customKeys.has(key)) {
|
|
91
|
+
this.loadEnvFile(key, resolve(this.config.cwd, filePath));
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
} catch (err) {
|
|
@@ -87,46 +96,49 @@ export class EnvManager {
|
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
|
|
91
|
-
* Load a single env file and store it in the map
|
|
92
|
-
*/
|
|
93
|
-
private loadEnvFile(key: string, filePath: string): void {
|
|
94
|
-
const absolutePath = resolve(this.cwd, filePath);
|
|
95
|
-
|
|
96
|
-
if (!existsSync(absolutePath)) {
|
|
97
|
-
return; // Silently skip non-existent files
|
|
98
|
-
}
|
|
99
|
-
|
|
99
|
+
private parseEnvFile(absolutePath: string): Record<string, string> | null {
|
|
100
100
|
try {
|
|
101
101
|
const content = readFileSync(absolutePath, "utf-8");
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
return parse(content) ?? {};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.warn(`Failed to parse env file: ${absolutePath}`, err);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.fileToKeys.get(absolutePath)!.add(key);
|
|
109
|
+
private getEnvKeySuffix(fileName: string): string | null {
|
|
110
|
+
const match = fileName.match(/^\.env\.(.+)$/);
|
|
111
|
+
return match ? match[1] : null;
|
|
112
|
+
}
|
|
110
113
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
private loadGlobalEnv(absolutePath: string) {
|
|
115
|
+
if (!existsSync(absolutePath)) return;
|
|
116
|
+
const parsed = this.parseEnvFile(absolutePath);
|
|
117
|
+
if (parsed) {
|
|
118
|
+
this.globalEnv = parsed;
|
|
119
|
+
this.watchFile(absolutePath);
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
private loadEnvFile(key: string, absolutePath: string) {
|
|
124
|
+
const parsed = this.parseEnvFile(absolutePath);
|
|
125
|
+
if (!parsed) return;
|
|
126
|
+
|
|
127
|
+
this.env.set(key, parsed);
|
|
128
|
+
this.fileToKey.set(absolutePath, key);
|
|
129
|
+
this.watchFile(absolutePath);
|
|
130
|
+
}
|
|
131
|
+
|
|
123
132
|
private watchFile(absolutePath: string): void {
|
|
133
|
+
if (this.watchers.has(absolutePath)) return;
|
|
124
134
|
try {
|
|
125
|
-
const watcher = watch(absolutePath,
|
|
126
|
-
|
|
135
|
+
const watcher = watch(absolutePath, { ignoreInitial: true })
|
|
136
|
+
.on("change", () => {
|
|
127
137
|
this.handleFileChange(absolutePath);
|
|
128
|
-
}
|
|
129
|
-
|
|
138
|
+
})
|
|
139
|
+
.on("unlink", () => {
|
|
140
|
+
this.handleFileDelete(absolutePath);
|
|
141
|
+
});
|
|
130
142
|
|
|
131
143
|
this.watchers.set(absolutePath, watcher);
|
|
132
144
|
} catch (err) {
|
|
@@ -135,56 +147,80 @@ export class EnvManager {
|
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
/**
|
|
138
|
-
*
|
|
150
|
+
* Watch cwd for new .env.* files
|
|
139
151
|
*/
|
|
152
|
+
private watchCwdForNewFiles(): void {
|
|
153
|
+
try {
|
|
154
|
+
this.cwdWatcher = watch(this.config.cwd, {
|
|
155
|
+
ignoreInitial: true,
|
|
156
|
+
depth: 0,
|
|
157
|
+
}).on("add", (filePath) => {
|
|
158
|
+
this.handleNewFile(filePath);
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.warn(`Failed to watch cwd for new env files:`, err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
140
165
|
private handleFileChange(absolutePath: string): void {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
if (absolutePath === this.globalEnvPath) {
|
|
167
|
+
this.loadGlobalEnv(absolutePath);
|
|
168
|
+
this.notifyCallbacks({ type: "global" });
|
|
169
|
+
return;
|
|
145
170
|
}
|
|
146
171
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.reloadFile(absolutePath);
|
|
150
|
-
this.reloadDebounceTimers.delete(absolutePath);
|
|
151
|
-
}, 100);
|
|
172
|
+
const key = this.fileToKey.get(absolutePath);
|
|
173
|
+
if (!key) return;
|
|
152
174
|
|
|
153
|
-
this.
|
|
175
|
+
const parsed = this.parseEnvFile(absolutePath);
|
|
176
|
+
if (!parsed) return;
|
|
177
|
+
|
|
178
|
+
this.env.set(key, parsed);
|
|
179
|
+
this.notifyCallbacks({ type: "process", key });
|
|
154
180
|
}
|
|
155
181
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
readFile(absolutePath, "utf-8")
|
|
164
|
-
.then((content) => parse(content))
|
|
165
|
-
.then((parsed) => {
|
|
166
|
-
const changedKeys: string[] = [];
|
|
167
|
-
for (const key of keys) {
|
|
168
|
-
this.env.set(key, parsed);
|
|
169
|
-
changedKeys.push(key);
|
|
170
|
-
}
|
|
182
|
+
private handleFileDelete(absolutePath: string): void {
|
|
183
|
+
const watcher = this.watchers.get(absolutePath);
|
|
184
|
+
if (watcher) {
|
|
185
|
+
watcher.close();
|
|
186
|
+
this.watchers.delete(absolutePath);
|
|
187
|
+
}
|
|
171
188
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
if (absolutePath === this.globalEnvPath) {
|
|
190
|
+
this.globalEnv = {};
|
|
191
|
+
this.notifyCallbacks({ type: "global" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const key = this.fileToKey.get(absolutePath);
|
|
196
|
+
if (key) {
|
|
197
|
+
this.env.delete(key);
|
|
198
|
+
this.fileToKey.delete(absolutePath);
|
|
199
|
+
this.notifyCallbacks({ type: "process", key });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private handleNewFile(filePath: string): void {
|
|
204
|
+
const absolutePath = isAbsolute(filePath) ? filePath : resolve(this.config.cwd, filePath);
|
|
205
|
+
|
|
206
|
+
if (absolutePath === this.globalEnvPath) {
|
|
207
|
+
this.loadGlobalEnv(absolutePath);
|
|
208
|
+
this.notifyCallbacks({ type: "global" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const key = this.getEnvKeySuffix(basename(filePath));
|
|
213
|
+
// Skip if key has a custom file registered or already loaded
|
|
214
|
+
if (key && !this.customKeys.has(key) && !this.env.has(key)) {
|
|
215
|
+
this.loadEnvFile(key, absolutePath);
|
|
216
|
+
this.notifyCallbacks({ type: "process", key });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private notifyCallbacks(event: EnvChangeEvent): void {
|
|
221
|
+
for (const callback of this.changeCallbacks) callback(event);
|
|
182
222
|
}
|
|
183
223
|
|
|
184
|
-
/**
|
|
185
|
-
* Register a callback to be called when env files change
|
|
186
|
-
* Returns a function to unregister the callback
|
|
187
|
-
*/
|
|
188
224
|
onChange(callback: EnvChangeCallback): () => void {
|
|
189
225
|
this.changeCallbacks.add(callback);
|
|
190
226
|
return () => {
|
|
@@ -192,46 +228,17 @@ export class EnvManager {
|
|
|
192
228
|
};
|
|
193
229
|
}
|
|
194
230
|
|
|
195
|
-
|
|
196
|
-
* Stop watching all files and cleanup
|
|
197
|
-
*/
|
|
198
|
-
dispose(): void {
|
|
199
|
-
// Clear all timers
|
|
200
|
-
for (const timer of this.reloadDebounceTimers.values()) {
|
|
201
|
-
clearTimeout(timer);
|
|
202
|
-
}
|
|
203
|
-
this.reloadDebounceTimers.clear();
|
|
204
|
-
|
|
205
|
-
// Close all watchers
|
|
231
|
+
close(): void {
|
|
206
232
|
for (const watcher of this.watchers.values()) {
|
|
207
233
|
watcher.close();
|
|
208
234
|
}
|
|
209
235
|
this.watchers.clear();
|
|
210
236
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Get environment variables for a specific process
|
|
217
|
-
* Merges global env with process-specific env
|
|
218
|
-
* Process-specific env variables override global ones
|
|
219
|
-
*/
|
|
220
|
-
getEnvVars(processKey?: string): Record<string, string> {
|
|
221
|
-
const globalEnv = this.env.get("global") ?? {};
|
|
222
|
-
|
|
223
|
-
if (!processKey) {
|
|
224
|
-
return { ...globalEnv };
|
|
237
|
+
if (this.cwdWatcher) {
|
|
238
|
+
this.cwdWatcher.close();
|
|
239
|
+
this.cwdWatcher = null;
|
|
225
240
|
}
|
|
226
241
|
|
|
227
|
-
|
|
228
|
-
return { ...globalEnv, ...processEnv };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Get all loaded env maps (for debugging/inspection)
|
|
233
|
-
*/
|
|
234
|
-
getAllEnv(): ReadonlyMap<string, Record<string, string>> {
|
|
235
|
-
return this.env;
|
|
242
|
+
this.changeCallbacks.clear();
|
|
236
243
|
}
|
|
237
244
|
}
|
package/src/index.ts
CHANGED
package/src/lazy-process.ts
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import readline from "node:readline";
|
|
3
|
-
import { PassThrough } from "node:stream";
|
|
4
1
|
import * as v from "valibot";
|
|
5
2
|
import type { Logger } from "./logger.ts";
|
|
6
|
-
import {
|
|
3
|
+
import { setTimeout } from "node:timers/promises";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
7
6
|
|
|
8
|
-
export const
|
|
7
|
+
export const ProcessDefinition = v.object({
|
|
9
8
|
command: v.string(),
|
|
10
9
|
args: v.optional(v.array(v.string())),
|
|
11
10
|
cwd: v.optional(v.string()),
|
|
12
11
|
env: v.optional(v.record(v.string(), v.string())),
|
|
13
12
|
});
|
|
13
|
+
export type ProcessDefinition = v.InferOutput<typeof ProcessDefinition>;
|
|
14
14
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
export const ProcessStateSchema = v.picklist([
|
|
15
|
+
export const ProcessState = v.picklist([
|
|
18
16
|
"idle",
|
|
19
17
|
"starting",
|
|
20
18
|
"running",
|
|
@@ -22,35 +20,31 @@ export const ProcessStateSchema = v.picklist([
|
|
|
22
20
|
"stopped",
|
|
23
21
|
"error",
|
|
24
22
|
]);
|
|
25
|
-
|
|
26
|
-
export type ProcessState = v.InferOutput<typeof ProcessStateSchema>;
|
|
23
|
+
export type ProcessState = v.InferOutput<typeof ProcessState>;
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
|
-
* Kill a process and all its descendants
|
|
30
|
-
*
|
|
26
|
+
* Kill a process group (the process and all its descendants).
|
|
27
|
+
* Uses negative PID to target the entire process group.
|
|
31
28
|
*/
|
|
32
|
-
|
|
33
|
-
const pid = child.pid;
|
|
34
|
-
if (pid === undefined) {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
29
|
+
function killProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
|
|
38
30
|
try {
|
|
39
|
-
|
|
31
|
+
// Negative PID kills the entire process group
|
|
32
|
+
process.kill(-pid, signal);
|
|
40
33
|
return true;
|
|
41
|
-
} catch {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
36
|
+
// ESRCH = No such process (already dead)
|
|
37
|
+
// EPERM = Permission denied (might happen if some children already exited)
|
|
38
|
+
if (code === "ESRCH" || code === "EPERM") {
|
|
39
|
+
return true;
|
|
47
40
|
}
|
|
41
|
+
return false;
|
|
48
42
|
}
|
|
49
43
|
}
|
|
50
44
|
|
|
51
45
|
export class LazyProcess {
|
|
52
46
|
readonly name: string;
|
|
53
|
-
|
|
47
|
+
definition: ProcessDefinition;
|
|
54
48
|
private logger: Logger;
|
|
55
49
|
private childProcess: ChildProcess | null = null;
|
|
56
50
|
private _state: ProcessState = "idle";
|
|
@@ -60,14 +54,14 @@ export class LazyProcess {
|
|
|
60
54
|
constructor(name: string, definition: ProcessDefinition, logger: Logger) {
|
|
61
55
|
this.name = name;
|
|
62
56
|
this.definition = definition;
|
|
63
|
-
this.logger = logger;
|
|
57
|
+
this.logger = logger.withPrefix("SYS");
|
|
64
58
|
}
|
|
65
59
|
|
|
66
60
|
get state(): ProcessState {
|
|
67
61
|
return this._state;
|
|
68
62
|
}
|
|
69
63
|
|
|
70
|
-
start()
|
|
64
|
+
async start() {
|
|
71
65
|
if (this._state === "running" || this._state === "starting") {
|
|
72
66
|
throw new Error(`Process "${this.name}" is already ${this._state}`);
|
|
73
67
|
}
|
|
@@ -78,7 +72,7 @@ export class LazyProcess {
|
|
|
78
72
|
|
|
79
73
|
this._state = "starting";
|
|
80
74
|
this.processExit = Promise.withResolvers<void>();
|
|
81
|
-
this.logger.
|
|
75
|
+
this.logger.debug(`Starting process: ${this.definition.command}`);
|
|
82
76
|
|
|
83
77
|
try {
|
|
84
78
|
const env = this.definition.env ? { ...process.env, ...this.definition.env } : process.env;
|
|
@@ -87,37 +81,23 @@ export class LazyProcess {
|
|
|
87
81
|
cwd: this.definition.cwd,
|
|
88
82
|
env,
|
|
89
83
|
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
+
detached: true,
|
|
90
85
|
});
|
|
91
86
|
|
|
92
87
|
this._state = "running";
|
|
93
88
|
|
|
94
|
-
// Combine stdout and stderr into a single stream for unified logging
|
|
95
|
-
const combined = new PassThrough();
|
|
96
|
-
let streamCount = 0;
|
|
97
|
-
|
|
98
89
|
if (this.childProcess.stdout) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
this.
|
|
102
|
-
if (--streamCount === 0) combined.end();
|
|
103
|
-
});
|
|
90
|
+
const rl = createInterface({ input: this.childProcess.stdout });
|
|
91
|
+
rl.on("line", (line) => this.logger.withPrefix("OUT").info(line));
|
|
92
|
+
this.processExit.promise.then(() => rl.close());
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
if (this.childProcess.stderr) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
if (--streamCount === 0) combined.end();
|
|
111
|
-
});
|
|
96
|
+
const rl = createInterface({ input: this.childProcess.stderr });
|
|
97
|
+
rl.on("line", (line) => this.logger.withPrefix("ERR").info(line));
|
|
98
|
+
this.processExit.promise.then(() => rl.close());
|
|
112
99
|
}
|
|
113
100
|
|
|
114
|
-
// Use readline to handle line-by-line output properly
|
|
115
|
-
const rl = readline.createInterface({ input: combined });
|
|
116
|
-
rl.on("line", (line) => {
|
|
117
|
-
this.logger.info(line);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Handle process exit
|
|
121
101
|
this.childProcess.on("exit", (code, signal) => {
|
|
122
102
|
this.exitCode = code;
|
|
123
103
|
|
|
@@ -137,7 +117,6 @@ export class LazyProcess {
|
|
|
137
117
|
this.processExit.resolve();
|
|
138
118
|
});
|
|
139
119
|
|
|
140
|
-
// Handle spawn errors
|
|
141
120
|
this.childProcess.on("error", (err) => {
|
|
142
121
|
if (this._state !== "stopping" && this._state !== "stopped") {
|
|
143
122
|
this._state = "error";
|
|
@@ -169,44 +148,34 @@ export class LazyProcess {
|
|
|
169
148
|
}
|
|
170
149
|
|
|
171
150
|
this._state = "stopping";
|
|
172
|
-
this.
|
|
173
|
-
|
|
174
|
-
// Send SIGTERM for graceful shutdown (to entire process tree)
|
|
175
|
-
await killProcessTree(this.childProcess, "SIGTERM");
|
|
151
|
+
const pid = this.childProcess.pid;
|
|
176
152
|
|
|
177
|
-
|
|
153
|
+
if (pid === undefined) {
|
|
154
|
+
this._state = "stopped";
|
|
155
|
+
this.cleanup();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
178
158
|
|
|
179
|
-
|
|
180
|
-
const timeoutPromise = new Promise<"timeout">((resolve) =>
|
|
181
|
-
setTimeout(() => resolve("timeout"), timeoutMs),
|
|
182
|
-
);
|
|
159
|
+
this.logger.debug(`Stopping process group (pid: ${pid}) with SIGTERM`);
|
|
183
160
|
|
|
184
|
-
|
|
185
|
-
const
|
|
161
|
+
const timeoutMs = timeout ?? 5000;
|
|
162
|
+
const resultRace = Promise.race([
|
|
186
163
|
this.processExit.promise.then(() => "exited" as const),
|
|
187
|
-
|
|
164
|
+
setTimeout(timeoutMs, "timeout"),
|
|
188
165
|
]);
|
|
189
|
-
this.logger.info(`Stop race result: ${result}`);
|
|
190
166
|
|
|
191
|
-
|
|
192
|
-
|
|
167
|
+
killProcessGroup(pid, "SIGTERM");
|
|
168
|
+
|
|
169
|
+
this.logger.debug(`Waiting for process exit (timeout: ${timeoutMs}ms)`);
|
|
170
|
+
const result = await resultRace;
|
|
171
|
+
this.logger.debug(`process exit result: ${result}`);
|
|
172
|
+
|
|
173
|
+
if (result === "timeout") {
|
|
193
174
|
this.logger.warn(`Process did not exit within ${timeoutMs}ms, sending SIGKILL (pid: ${pid})`);
|
|
194
|
-
const killed =
|
|
195
|
-
this.logger.info(`SIGKILL sent
|
|
196
|
-
|
|
197
|
-
// If tree-kill failed, try direct kill as last resort
|
|
198
|
-
if (!killed && this.childProcess) {
|
|
199
|
-
this.logger.warn(`Tree-kill failed, attempting direct SIGKILL`);
|
|
200
|
-
try {
|
|
201
|
-
this.childProcess.kill("SIGKILL");
|
|
202
|
-
this.logger.info(`Direct SIGKILL sent`);
|
|
203
|
-
} catch (err) {
|
|
204
|
-
this.logger.error(`Direct SIGKILL failed:`, err);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
175
|
+
const killed = killProcessGroup(pid, "SIGKILL");
|
|
176
|
+
this.logger.info(`SIGKILL sent to process group: ${killed ? "success" : "failed"}`);
|
|
207
177
|
|
|
208
|
-
|
|
209
|
-
const killTimeout = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
|
178
|
+
const killTimeout = setTimeout(1000, "timeout");
|
|
210
179
|
await Promise.race([this.processExit.promise, killTimeout]);
|
|
211
180
|
|
|
212
181
|
if (this.childProcess && !this.childProcess.killed) {
|
|
@@ -220,9 +189,9 @@ export class LazyProcess {
|
|
|
220
189
|
}
|
|
221
190
|
|
|
222
191
|
async reset(): Promise<void> {
|
|
223
|
-
if (this.childProcess) {
|
|
224
|
-
// Kill the entire process
|
|
225
|
-
|
|
192
|
+
if (this.childProcess?.pid !== undefined) {
|
|
193
|
+
// Kill the entire process group
|
|
194
|
+
killProcessGroup(this.childProcess.pid, "SIGKILL");
|
|
226
195
|
await this.processExit.promise;
|
|
227
196
|
this.cleanup();
|
|
228
197
|
}
|
|
@@ -238,19 +207,13 @@ export class LazyProcess {
|
|
|
238
207
|
}
|
|
239
208
|
|
|
240
209
|
async waitForExit(): Promise<ProcessState> {
|
|
241
|
-
if (!this.childProcess)
|
|
242
|
-
return this._state;
|
|
243
|
-
}
|
|
244
|
-
|
|
210
|
+
if (!this.childProcess) return this._state;
|
|
245
211
|
await this.processExit.promise;
|
|
246
212
|
return this._state;
|
|
247
213
|
}
|
|
248
214
|
|
|
249
215
|
private cleanup(): void {
|
|
250
216
|
if (this.childProcess) {
|
|
251
|
-
// Remove all listeners to prevent memory leaks
|
|
252
|
-
this.childProcess.stdout?.removeAllListeners();
|
|
253
|
-
this.childProcess.stderr?.removeAllListeners();
|
|
254
217
|
this.childProcess.removeAllListeners();
|
|
255
218
|
this.childProcess = null;
|
|
256
219
|
}
|