@iskra-bun/process-kit 0.1.0 → 0.2.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 +18 -0
- package/dist/index.d.ts +37 -2
- package/dist/index.js +120 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/spawner.ts +153 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @iskra-bun/process-kit
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9654df: New process-management features:
|
|
8
|
+
|
|
9
|
+
- `spawn(name, config)` and `kill(name)` to add or gracefully remove individual processes at runtime, instead of only at boot.
|
|
10
|
+
- Configurable exponential restart backoff (`restartBackoff: { initialMs, maxMs, factor }` on `ProcessConfig`) replacing the fixed 1s restart delay, so a crash-looping process backs off instead of hammering a broken dependency. Defaults to the previous 1s when unset.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- f9654df: `stop()` now shuts processes down gracefully — SIGTERM, wait for exit with a configurable timeout, then SIGKILL — and awaits exit before clearing state. `oneshot` mode is now implemented: the process runs to completion once and is never restarted, even when `restartOnCrash` is set.
|
|
15
|
+
- `terminate()` now detects orphaned processes: when a process survives both SIGTERM and SIGKILL, it logs an error (`survived SIGTERM and SIGKILL and is now an orphan`) via `app.logger` instead of failing silently. This is shared by `kill()` and `stop()`; a process that exits cleanly does not log.
|
|
16
|
+
- Updated dependencies [f9654df]
|
|
17
|
+
- Updated dependencies
|
|
18
|
+
- Updated dependencies [f9654df]
|
|
19
|
+
- @iskra-bun/core@0.1.1
|
|
20
|
+
|
|
3
21
|
## 0.1.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Driver, App } from '@iskra-bun/core';
|
|
1
|
+
import { Driver, App, ProcessConfig } from '@iskra-bun/core';
|
|
2
2
|
|
|
3
3
|
declare class ProcessManager implements Driver {
|
|
4
4
|
name: string;
|
|
@@ -7,13 +7,48 @@ declare class ProcessManager implements Driver {
|
|
|
7
7
|
private stopping;
|
|
8
8
|
init(app: App): void;
|
|
9
9
|
start(): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Spawn and register a new process at runtime. Reuses the existing internal
|
|
12
|
+
* spawn logic and respects the process mode (stdio/daemon/oneshot).
|
|
13
|
+
*
|
|
14
|
+
* @throws {Error} if a process with the given name is already registered.
|
|
15
|
+
*/
|
|
16
|
+
spawn(name: string, config: ProcessConfig): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Gracefully stop and remove a single named process. Sends SIGTERM, then
|
|
19
|
+
* escalates to SIGKILL after `gracefulTimeoutMs`. The worst-case wait before
|
|
20
|
+
* this method resolves is `gracefulTimeoutMs * 2`: `gracefulTimeoutMs` for the
|
|
21
|
+
* SIGKILL escalation plus another `gracefulTimeoutMs` grace window for the
|
|
22
|
+
* process to actually exit afterwards. If the process still has not exited by
|
|
23
|
+
* then, it is reported as an orphan via `app.logger.error`.
|
|
24
|
+
*
|
|
25
|
+
* @throws {Error} if no process with the given name exists.
|
|
26
|
+
*/
|
|
27
|
+
kill(name: string, gracefulTimeoutMs?: number): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Send SIGTERM, escalate to SIGKILL after `gracefulTimeoutMs`, and wait up to
|
|
30
|
+
* `gracefulTimeoutMs * 2` for the process to exit. After the race settles, if
|
|
31
|
+
* the process is still alive it is surfaced as an orphan via `app.logger.error`
|
|
32
|
+
* so a hung shutdown is observable instead of silently succeeding.
|
|
33
|
+
*/
|
|
34
|
+
private terminate;
|
|
10
35
|
private readStdOut;
|
|
11
36
|
private processLine;
|
|
37
|
+
/**
|
|
38
|
+
* Compute the next backoff delay for a process restart.
|
|
39
|
+
*
|
|
40
|
+
* - If no `restartBackoff` config is provided, returns the flat 1000 ms default.
|
|
41
|
+
* - The FIRST restart (`restarts === 0`) waits exactly `currentBackoffMs`
|
|
42
|
+
* (seeded to `initialMs` on spawn); exponential growth begins on the SECOND
|
|
43
|
+
* restart with `min(currentMs * factor, maxMs)`.
|
|
44
|
+
* - If the process was stable (uptime > restartCooldown), the backoff resets to initialMs.
|
|
45
|
+
*/
|
|
46
|
+
private computeBackoffMs;
|
|
12
47
|
private handleExit;
|
|
13
48
|
private spawnProcess;
|
|
14
49
|
private readStdErr;
|
|
15
50
|
send(name: string, data: any): Promise<void>;
|
|
16
|
-
stop(): Promise<void>;
|
|
51
|
+
stop(gracefulTimeoutMs?: number): Promise<void>;
|
|
17
52
|
}
|
|
18
53
|
|
|
19
54
|
export { ProcessManager };
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,68 @@ var ProcessManager = class {
|
|
|
19
19
|
this.spawnProcess(name, config);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Spawn and register a new process at runtime. Reuses the existing internal
|
|
24
|
+
* spawn logic and respects the process mode (stdio/daemon/oneshot).
|
|
25
|
+
*
|
|
26
|
+
* @throws {Error} if a process with the given name is already registered.
|
|
27
|
+
*/
|
|
28
|
+
async spawn(name, config) {
|
|
29
|
+
if (this.processes.has(name)) {
|
|
30
|
+
throw new Error(`Process '${name}' is already registered. Kill it first or use a different name.`);
|
|
31
|
+
}
|
|
32
|
+
this.spawnProcess(name, config);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Gracefully stop and remove a single named process. Sends SIGTERM, then
|
|
36
|
+
* escalates to SIGKILL after `gracefulTimeoutMs`. The worst-case wait before
|
|
37
|
+
* this method resolves is `gracefulTimeoutMs * 2`: `gracefulTimeoutMs` for the
|
|
38
|
+
* SIGKILL escalation plus another `gracefulTimeoutMs` grace window for the
|
|
39
|
+
* process to actually exit afterwards. If the process still has not exited by
|
|
40
|
+
* then, it is reported as an orphan via `app.logger.error`.
|
|
41
|
+
*
|
|
42
|
+
* @throws {Error} if no process with the given name exists.
|
|
43
|
+
*/
|
|
44
|
+
async kill(name, gracefulTimeoutMs = 5e3) {
|
|
45
|
+
const procInfo = this.processes.get(name);
|
|
46
|
+
if (!procInfo) {
|
|
47
|
+
throw new Error(`Process '${name}' not found. It may have already exited or never been spawned.`);
|
|
48
|
+
}
|
|
49
|
+
this.processes.delete(name);
|
|
50
|
+
const proc = procInfo.process;
|
|
51
|
+
if (proc.killed) return;
|
|
52
|
+
await this.terminate(name, proc, gracefulTimeoutMs);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Send SIGTERM, escalate to SIGKILL after `gracefulTimeoutMs`, and wait up to
|
|
56
|
+
* `gracefulTimeoutMs * 2` for the process to exit. After the race settles, if
|
|
57
|
+
* the process is still alive it is surfaced as an orphan via `app.logger.error`
|
|
58
|
+
* so a hung shutdown is observable instead of silently succeeding.
|
|
59
|
+
*/
|
|
60
|
+
async terminate(name, proc, gracefulTimeoutMs) {
|
|
61
|
+
proc.kill("SIGTERM");
|
|
62
|
+
const deadline = gracefulTimeoutMs * 2;
|
|
63
|
+
const forceKillTimer = setTimeout(() => {
|
|
64
|
+
if (!proc.killed) {
|
|
65
|
+
this.app?.logger.warn(`Process ${name} did not exit within ${gracefulTimeoutMs}ms; sending SIGKILL`);
|
|
66
|
+
proc.kill();
|
|
67
|
+
}
|
|
68
|
+
}, gracefulTimeoutMs);
|
|
69
|
+
try {
|
|
70
|
+
await Promise.race([
|
|
71
|
+
proc.exited,
|
|
72
|
+
new Promise((r) => setTimeout(r, deadline))
|
|
73
|
+
]);
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(forceKillTimer);
|
|
76
|
+
}
|
|
77
|
+
if (!proc.killed) {
|
|
78
|
+
this.app?.logger.error(
|
|
79
|
+
{ name },
|
|
80
|
+
`Process ${name} survived SIGTERM and SIGKILL and is now an orphan; manual cleanup may be required`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
22
84
|
async readStdOut(name, stream) {
|
|
23
85
|
const reader = stream.getReader();
|
|
24
86
|
const decoder = new TextDecoder();
|
|
@@ -36,6 +98,7 @@ var ProcessManager = class {
|
|
|
36
98
|
}
|
|
37
99
|
}
|
|
38
100
|
} catch (err) {
|
|
101
|
+
this.app?.logger.debug({ err, name }, `stdout reader for process ${name} failed`);
|
|
39
102
|
}
|
|
40
103
|
}
|
|
41
104
|
processLine(name, line) {
|
|
@@ -46,12 +109,44 @@ var ProcessManager = class {
|
|
|
46
109
|
this.app?.emit("process:log", { name, text: line });
|
|
47
110
|
}
|
|
48
111
|
}
|
|
49
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Compute the next backoff delay for a process restart.
|
|
114
|
+
*
|
|
115
|
+
* - If no `restartBackoff` config is provided, returns the flat 1000 ms default.
|
|
116
|
+
* - The FIRST restart (`restarts === 0`) waits exactly `currentBackoffMs`
|
|
117
|
+
* (seeded to `initialMs` on spawn); exponential growth begins on the SECOND
|
|
118
|
+
* restart with `min(currentMs * factor, maxMs)`.
|
|
119
|
+
* - If the process was stable (uptime > restartCooldown), the backoff resets to initialMs.
|
|
120
|
+
*/
|
|
121
|
+
computeBackoffMs(procInfo) {
|
|
122
|
+
const backoffCfg = procInfo.config.restartBackoff;
|
|
123
|
+
if (!backoffCfg) {
|
|
124
|
+
return 1e3;
|
|
125
|
+
}
|
|
126
|
+
const cooldown = procInfo.config.restartCooldown ?? 6e4;
|
|
127
|
+
const uptime = Date.now() - procInfo.startedAt;
|
|
128
|
+
if (uptime > cooldown) {
|
|
129
|
+
return backoffCfg.initialMs ?? 1e3;
|
|
130
|
+
}
|
|
131
|
+
const maxMs = backoffCfg.maxMs ?? 3e4;
|
|
132
|
+
const initialMs = backoffCfg.initialMs ?? 1e3;
|
|
133
|
+
if (procInfo.restarts === 0 && procInfo.currentBackoffMs === initialMs) {
|
|
134
|
+
return Math.min(procInfo.currentBackoffMs, maxMs);
|
|
135
|
+
}
|
|
136
|
+
const factor = backoffCfg.factor ?? 2;
|
|
137
|
+
const next = Math.min(procInfo.currentBackoffMs * factor, maxMs);
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
handleExit(name, exitCode) {
|
|
50
141
|
if (this.stopping) return;
|
|
51
142
|
const procInfo = this.processes.get(name);
|
|
52
143
|
if (!procInfo) return;
|
|
53
144
|
this.app?.logger.warn(`Process ${name} exited with code ${exitCode}`);
|
|
54
145
|
this.processes.delete(name);
|
|
146
|
+
if (procInfo.config.mode === "oneshot") {
|
|
147
|
+
this.app?.emit("process:exit", { name, exitCode });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
55
150
|
if (procInfo.config.restartOnCrash) {
|
|
56
151
|
const maxRestarts = procInfo.config.maxRestarts ?? 10;
|
|
57
152
|
const cooldown = procInfo.config.restartCooldown ?? 6e4;
|
|
@@ -62,15 +157,17 @@ var ProcessManager = class {
|
|
|
62
157
|
this.app?.emit("process:max-restarts", { name, restarts: procInfo.restarts, maxRestarts });
|
|
63
158
|
return;
|
|
64
159
|
}
|
|
65
|
-
|
|
160
|
+
const delayMs = this.computeBackoffMs(procInfo);
|
|
161
|
+
this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts}) in ${delayMs}ms`);
|
|
66
162
|
setTimeout(() => {
|
|
67
|
-
this.spawnProcess(name, procInfo.config, restarts);
|
|
68
|
-
},
|
|
163
|
+
this.spawnProcess(name, procInfo.config, restarts, delayMs);
|
|
164
|
+
}, delayMs);
|
|
69
165
|
}
|
|
70
166
|
}
|
|
71
|
-
// Updated spawn signature to track restarts
|
|
72
|
-
spawnProcess(name, config, restarts = 0) {
|
|
167
|
+
// Updated spawn signature to track restarts and backoff state
|
|
168
|
+
spawnProcess(name, config, restarts = 0, currentBackoffMs) {
|
|
73
169
|
if (this.stopping || !this.app) return;
|
|
170
|
+
const initialBackoffMs = config.restartBackoff?.initialMs ?? 1e3;
|
|
74
171
|
this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(" ") || ""})`);
|
|
75
172
|
try {
|
|
76
173
|
const proc = Bun.spawn(
|
|
@@ -80,8 +177,8 @@ var ProcessManager = class {
|
|
|
80
177
|
stdout: config.mode === "stdio" ? "pipe" : "inherit",
|
|
81
178
|
stderr: config.mode === "stdio" ? "pipe" : "inherit",
|
|
82
179
|
stdin: config.mode === "stdio" ? "pipe" : "ignore",
|
|
83
|
-
onExit: (
|
|
84
|
-
this.handleExit(name, exitCode || 0
|
|
180
|
+
onExit: (_proc, exitCode, _signalCode, _error) => {
|
|
181
|
+
this.handleExit(name, exitCode || 0);
|
|
85
182
|
}
|
|
86
183
|
}
|
|
87
184
|
);
|
|
@@ -90,7 +187,8 @@ var ProcessManager = class {
|
|
|
90
187
|
config,
|
|
91
188
|
name,
|
|
92
189
|
restarts,
|
|
93
|
-
startedAt: Date.now()
|
|
190
|
+
startedAt: Date.now(),
|
|
191
|
+
currentBackoffMs: currentBackoffMs ?? initialBackoffMs
|
|
94
192
|
});
|
|
95
193
|
if (config.mode === "stdio") {
|
|
96
194
|
if (proc.stdout) this.readStdOut(name, proc.stdout);
|
|
@@ -113,6 +211,7 @@ var ProcessManager = class {
|
|
|
113
211
|
}
|
|
114
212
|
}
|
|
115
213
|
} catch (err) {
|
|
214
|
+
this.app?.logger.debug({ err, name }, `stderr reader for process ${name} failed`);
|
|
116
215
|
}
|
|
117
216
|
}
|
|
118
217
|
// Send data to process stdin
|
|
@@ -130,20 +229,25 @@ var ProcessManager = class {
|
|
|
130
229
|
try {
|
|
131
230
|
const message = typeof data === "string" ? data : JSON.stringify(data);
|
|
132
231
|
stdin.write(message + "\n");
|
|
133
|
-
stdin.flush
|
|
232
|
+
if (typeof stdin.flush === "function") {
|
|
233
|
+
stdin.flush();
|
|
234
|
+
}
|
|
134
235
|
} catch (err) {
|
|
135
236
|
this.app?.logger.error({ err }, `Failed to write to process ${name}`);
|
|
136
237
|
}
|
|
137
238
|
}
|
|
138
|
-
async stop() {
|
|
239
|
+
async stop(gracefulTimeoutMs = 5e3) {
|
|
139
240
|
this.stopping = true;
|
|
140
241
|
this.app?.logger.info("Stopping all processes...");
|
|
141
|
-
|
|
142
|
-
if (!info.process.killed) {
|
|
143
|
-
info.process.kill();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
242
|
+
const entries = [...this.processes.entries()];
|
|
146
243
|
this.processes.clear();
|
|
244
|
+
await Promise.all(
|
|
245
|
+
entries.map(async ([name, info]) => {
|
|
246
|
+
const proc = info.process;
|
|
247
|
+
if (proc.killed) return;
|
|
248
|
+
await this.terminate(name, proc, gracefulTimeoutMs);
|
|
249
|
+
})
|
|
250
|
+
);
|
|
147
251
|
}
|
|
148
252
|
};
|
|
149
253
|
export {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/spawner.ts"],"sourcesContent":["import type { App, Driver, ProcessConfig } from '@iskra-bun/core';\nimport { Subprocess } from 'bun';\n\ninterface RunningProcess {\n process: Subprocess;\n config: ProcessConfig;\n name: string;\n restarts: number;\n startedAt: number;\n}\n\nexport class ProcessManager implements Driver {\n name = 'ProcessManager';\n private app: App | null = null;\n private processes: Map<string, RunningProcess> = new Map();\n private stopping = false;\n\n init(app: App) {\n this.app = app;\n }\n\n async start() {\n if (!this.app) return;\n const processConfigs = this.app.config.processes;\n\n if (!processConfigs) {\n this.app.logger.debug('No processes configured.');\n return;\n }\n\n this.app.logger.info(`Starting ${Object.keys(processConfigs).length} processes...`);\n\n for (const [name, config] of Object.entries(processConfigs)) {\n this.spawnProcess(name, config);\n }\n }\n\n private async readStdOut(name: string, stream: ReadableStream) {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n\n const lines = buffer.split('\\n');\n // Keep the last chunk if it's not a complete line (doesn't end with \\n)\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n if (!line.trim()) continue;\n this.processLine(name, line);\n }\n }\n } catch (err) {\n // Stream closed or error\n }\n }\n\n private processLine(name: string, line: string) {\n try {\n // Try to parse as JSON Event\n const json = JSON.parse(line);\n this.app?.emit('process:message', { name, message: json });\n } catch {\n // Fallback to raw log\n this.app?.emit('process:log', { name, text: line });\n }\n }\n\n private handleExit(name: string, exitCode: number, signalCode: number) {\n if (this.stopping) return;\n\n const procInfo = this.processes.get(name);\n if (!procInfo) return;\n\n this.app?.logger.warn(`Process ${name} exited with code ${exitCode}`);\n\n // Remove from map so we don't try to kill it again on stop()\n this.processes.delete(name);\n\n if (procInfo.config.restartOnCrash) {\n const maxRestarts = procInfo.config.maxRestarts ?? 10;\n const cooldown = procInfo.config.restartCooldown ?? 60000;\n const uptime = Date.now() - procInfo.startedAt;\n\n // If the process ran longer than the cooldown, consider it stable and reset the counter\n const restarts = uptime > cooldown ? 1 : procInfo.restarts + 1;\n\n if (restarts > maxRestarts) {\n this.app?.logger.error(`Process ${name} exceeded max restarts (${maxRestarts}). Not restarting.`);\n this.app?.emit('process:max-restarts', { name, restarts: procInfo.restarts, maxRestarts });\n return;\n }\n\n this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts})`);\n\n setTimeout(() => {\n this.spawnProcess(name, procInfo.config, restarts);\n }, 1000);\n }\n }\n\n // Updated spawn signature to track restarts\n private spawnProcess(name: string, config: ProcessConfig, restarts = 0) {\n if (this.stopping || !this.app) return;\n\n this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(' ') || ''})`);\n\n try {\n const proc = Bun.spawn(\n [config.command, ...(config.args || [])],\n {\n env: { ...process.env, ...config.env },\n stdout: config.mode === 'stdio' ? 'pipe' : 'inherit',\n stderr: config.mode === 'stdio' ? 'pipe' : 'inherit',\n stdin: config.mode === 'stdio' ? 'pipe' : 'ignore',\n onExit: (proc, exitCode, signalCode, error) => {\n this.handleExit(name, exitCode || 0, signalCode || 0);\n }\n }\n );\n\n this.processes.set(name, {\n process: proc,\n config,\n name,\n restarts,\n startedAt: Date.now(),\n });\n\n if (config.mode === 'stdio') {\n if (proc.stdout) this.readStdOut(name, proc.stdout);\n if (proc.stderr) this.readStdErr(name, proc.stderr);\n }\n\n } catch (err) {\n this.app.logger.error({ err }, `Failed to spawn process: ${name}`);\n }\n }\n\n private async readStdErr(name: string, stream: ReadableStream) {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const text = decoder.decode(value, { stream: true });\n if (text.trim()) {\n this.app?.emit('process:error', { name, text });\n }\n }\n } catch (err) {\n // Stream closed\n }\n }\n\n // Send data to process stdin\n async send(name: string, data: any) {\n const procInfo = this.processes.get(name);\n if (!procInfo) {\n this.app?.logger.warn(`Cannot send message to non-existent process: ${name}`);\n return;\n }\n\n if (procInfo.config.mode !== 'stdio' || !procInfo.process.stdin) {\n this.app?.logger.warn(`Cannot send message to process ${name} (mode is not stdio or stdin is closed)`);\n return;\n }\n\n // Bun's Subprocess.stdin is a FileSink\n const stdin = procInfo.process.stdin as any;\n\n try {\n // If data is object, stringify it and add newline\n const message = typeof data === 'string' ? data : JSON.stringify(data);\n stdin.write(message + '\\n');\n stdin.flush();\n } catch (err) {\n this.app?.logger.error({ err }, `Failed to write to process ${name}`);\n }\n }\n\n async stop() {\n this.stopping = true;\n this.app?.logger.info('Stopping all processes...');\n\n for (const [name, info] of this.processes) {\n if (!info.process.killed) {\n info.process.kill();\n }\n }\n this.processes.clear();\n }\n}\n"],"mappings":";AAWO,IAAM,iBAAN,MAAuC;AAAA,EAC1C,OAAO;AAAA,EACC,MAAkB;AAAA,EAClB,YAAyC,oBAAI,IAAI;AAAA,EACjD,WAAW;AAAA,EAEnB,KAAK,KAAU;AACX,SAAK,MAAM;AAAA,EACf;AAAA,EAEA,MAAM,QAAQ;AACV,QAAI,CAAC,KAAK,IAAK;AACf,UAAM,iBAAiB,KAAK,IAAI,OAAO;AAEvC,QAAI,CAAC,gBAAgB;AACjB,WAAK,IAAI,OAAO,MAAM,0BAA0B;AAChD;AAAA,IACJ;AAEA,SAAK,IAAI,OAAO,KAAK,YAAY,OAAO,KAAK,cAAc,EAAE,MAAM,eAAe;AAElF,eAAW,CAAC,MAAM,MAAM,KAAK,OAAO,QAAQ,cAAc,GAAG;AACzD,WAAK,aAAa,MAAM,MAAM;AAAA,IAClC;AAAA,EACJ;AAAA,EAEA,MAAc,WAAW,MAAc,QAAwB;AAC3D,UAAM,SAAS,OAAO,UAAU;AAChC,UAAM,UAAU,IAAI,YAAY;AAChC,QAAI,SAAS;AAEb,QAAI;AACA,aAAO,MAAM;AACT,cAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,YAAI,KAAM;AAEV,kBAAU,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAEhD,cAAM,QAAQ,OAAO,MAAM,IAAI;AAE/B,iBAAS,MAAM,IAAI,KAAK;AAExB,mBAAW,QAAQ,OAAO;AACtB,cAAI,CAAC,KAAK,KAAK,EAAG;AAClB,eAAK,YAAY,MAAM,IAAI;AAAA,QAC/B;AAAA,MACJ;AAAA,IACJ,SAAS,KAAK;AAAA,IAEd;AAAA,EACJ;AAAA,EAEQ,YAAY,MAAc,MAAc;AAC5C,QAAI;AAEA,YAAM,OAAO,KAAK,MAAM,IAAI;AAC5B,WAAK,KAAK,KAAK,mBAAmB,EAAE,MAAM,SAAS,KAAK,CAAC;AAAA,IAC7D,QAAQ;AAEJ,WAAK,KAAK,KAAK,eAAe,EAAE,MAAM,MAAM,KAAK,CAAC;AAAA,IACtD;AAAA,EACJ;AAAA,EAEQ,WAAW,MAAc,UAAkB,YAAoB;AACnE,QAAI,KAAK,SAAU;AAEnB,UAAM,WAAW,KAAK,UAAU,IAAI,IAAI;AACxC,QAAI,CAAC,SAAU;AAEf,SAAK,KAAK,OAAO,KAAK,WAAW,IAAI,qBAAqB,QAAQ,EAAE;AAGpE,SAAK,UAAU,OAAO,IAAI;AAE1B,QAAI,SAAS,OAAO,gBAAgB;AAChC,YAAM,cAAc,SAAS,OAAO,eAAe;AACnD,YAAM,WAAW,SAAS,OAAO,mBAAmB;AACpD,YAAM,SAAS,KAAK,IAAI,IAAI,SAAS;AAGrC,YAAM,WAAW,SAAS,WAAW,IAAI,SAAS,WAAW;AAE7D,UAAI,WAAW,aAAa;AACxB,aAAK,KAAK,OAAO,MAAM,WAAW,IAAI,2BAA2B,WAAW,oBAAoB;AAChG,aAAK,KAAK,KAAK,wBAAwB,EAAE,MAAM,UAAU,SAAS,UAAU,YAAY,CAAC;AACzF;AAAA,MACJ;AAEA,WAAK,KAAK,OAAO,KAAK,uBAAuB,IAAI,aAAa,QAAQ,IAAI,WAAW,GAAG;AAExF,iBAAW,MAAM;AACb,aAAK,aAAa,MAAM,SAAS,QAAQ,QAAQ;AAAA,MACrD,GAAG,GAAI;AAAA,IACX;AAAA,EACJ;AAAA;AAAA,EAGQ,aAAa,MAAc,QAAuB,WAAW,GAAG;AACpE,QAAI,KAAK,YAAY,CAAC,KAAK,IAAK;AAEhC,SAAK,IAAI,OAAO,KAAK,qBAAqB,IAAI,KAAK,OAAO,OAAO,IAAI,OAAO,MAAM,KAAK,GAAG,KAAK,EAAE,GAAG;AAEpG,QAAI;AACA,YAAM,OAAO,IAAI;AAAA,QACb,CAAC,OAAO,SAAS,GAAI,OAAO,QAAQ,CAAC,CAAE;AAAA,QACvC;AAAA,UACI,KAAK,EAAE,GAAG,QAAQ,KAAK,GAAG,OAAO,IAAI;AAAA,UACrC,QAAQ,OAAO,SAAS,UAAU,SAAS;AAAA,UAC3C,QAAQ,OAAO,SAAS,UAAU,SAAS;AAAA,UAC3C,OAAO,OAAO,SAAS,UAAU,SAAS;AAAA,UAC1C,QAAQ,CAACA,OAAM,UAAU,YAAY,UAAU;AAC3C,iBAAK,WAAW,MAAM,YAAY,GAAG,cAAc,CAAC;AAAA,UACxD;AAAA,QACJ;AAAA,MACJ;AAEA,WAAK,UAAU,IAAI,MAAM;AAAA,QACrB,SAAS;AAAA,QACT;AAAA,QACA;AAAA,QACA;AAAA,QACA,WAAW,KAAK,IAAI;AAAA,MACxB,CAAC;AAED,UAAI,OAAO,SAAS,SAAS;AACzB,YAAI,KAAK,OAAQ,MAAK,WAAW,MAAM,KAAK,MAAM;AAClD,YAAI,KAAK,OAAQ,MAAK,WAAW,MAAM,KAAK,MAAM;AAAA,MACtD;AAAA,IAEJ,SAAS,KAAK;AACV,WAAK,IAAI,OAAO,MAAM,EAAE,IAAI,GAAG,4BAA4B,IAAI,EAAE;AAAA,IACrE;AAAA,EACJ;AAAA,EAEA,MAAc,WAAW,MAAc,QAAwB;AAC3D,UAAM,SAAS,OAAO,UAAU;AAChC,UAAM,UAAU,IAAI,YAAY;AAEhC,QAAI;AACA,aAAO,MAAM;AACT,cAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,YAAI,KAAM;AACV,cAAM,OAAO,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AACnD,YAAI,KAAK,KAAK,GAAG;AACb,eAAK,KAAK,KAAK,iBAAiB,EAAE,MAAM,KAAK,CAAC;AAAA,QAClD;AAAA,MACJ;AAAA,IACJ,SAAS,KAAK;AAAA,IAEd;AAAA,EACJ;AAAA;AAAA,EAGA,MAAM,KAAK,MAAc,MAAW;AAChC,UAAM,WAAW,KAAK,UAAU,IAAI,IAAI;AACxC,QAAI,CAAC,UAAU;AACX,WAAK,KAAK,OAAO,KAAK,gDAAgD,IAAI,EAAE;AAC5E;AAAA,IACJ;AAEA,QAAI,SAAS,OAAO,SAAS,WAAW,CAAC,SAAS,QAAQ,OAAO;AAC7D,WAAK,KAAK,OAAO,KAAK,kCAAkC,IAAI,yCAAyC;AACrG;AAAA,IACJ;AAGA,UAAM,QAAQ,SAAS,QAAQ;AAE/B,QAAI;AAEA,YAAM,UAAU,OAAO,SAAS,WAAW,OAAO,KAAK,UAAU,IAAI;AACrE,YAAM,MAAM,UAAU,IAAI;AAC1B,YAAM,MAAM;AAAA,IAChB,SAAS,KAAK;AACV,WAAK,KAAK,OAAO,MAAM,EAAE,IAAI,GAAG,8BAA8B,IAAI,EAAE;AAAA,IACxE;AAAA,EACJ;AAAA,EAEA,MAAM,OAAO;AACT,SAAK,WAAW;AAChB,SAAK,KAAK,OAAO,KAAK,2BAA2B;AAEjD,eAAW,CAAC,MAAM,IAAI,KAAK,KAAK,WAAW;AACvC,UAAI,CAAC,KAAK,QAAQ,QAAQ;AACtB,aAAK,QAAQ,KAAK;AAAA,MACtB;AAAA,IACJ;AACA,SAAK,UAAU,MAAM;AAAA,EACzB;AACJ;","names":["proc"]}
|
|
1
|
+
{"version":3,"sources":["../src/spawner.ts"],"sourcesContent":["import type { App, Driver, ProcessConfig } from '@iskra-bun/core';\nimport type { FileSink } from 'bun';\nimport { Subprocess } from 'bun';\n\ninterface RunningProcess {\n process: Subprocess;\n config: ProcessConfig;\n name: string;\n restarts: number;\n startedAt: number;\n /** Current computed backoff delay in ms (grows with each crash) */\n currentBackoffMs: number;\n}\n\nexport class ProcessManager implements Driver {\n name = 'ProcessManager';\n private app: App | null = null;\n private processes: Map<string, RunningProcess> = new Map();\n private stopping = false;\n\n init(app: App) {\n this.app = app;\n }\n\n async start() {\n if (!this.app) return;\n const processConfigs = this.app.config.processes;\n\n if (!processConfigs) {\n this.app.logger.debug('No processes configured.');\n return;\n }\n\n this.app.logger.info(`Starting ${Object.keys(processConfigs).length} processes...`);\n\n for (const [name, config] of Object.entries(processConfigs)) {\n this.spawnProcess(name, config);\n }\n }\n\n /**\n * Spawn and register a new process at runtime. Reuses the existing internal\n * spawn logic and respects the process mode (stdio/daemon/oneshot).\n *\n * @throws {Error} if a process with the given name is already registered.\n */\n async spawn(name: string, config: ProcessConfig): Promise<void> {\n if (this.processes.has(name)) {\n throw new Error(`Process '${name}' is already registered. Kill it first or use a different name.`);\n }\n this.spawnProcess(name, config);\n }\n\n /**\n * Gracefully stop and remove a single named process. Sends SIGTERM, then\n * escalates to SIGKILL after `gracefulTimeoutMs`. The worst-case wait before\n * this method resolves is `gracefulTimeoutMs * 2`: `gracefulTimeoutMs` for the\n * SIGKILL escalation plus another `gracefulTimeoutMs` grace window for the\n * process to actually exit afterwards. If the process still has not exited by\n * then, it is reported as an orphan via `app.logger.error`.\n *\n * @throws {Error} if no process with the given name exists.\n */\n async kill(name: string, gracefulTimeoutMs = 5000): Promise<void> {\n const procInfo = this.processes.get(name);\n if (!procInfo) {\n throw new Error(`Process '${name}' not found. It may have already exited or never been spawned.`);\n }\n\n // Remove from the map before terminating so handleExit won't try to restart it\n this.processes.delete(name);\n\n const proc = procInfo.process;\n if (proc.killed) return;\n\n await this.terminate(name, proc, gracefulTimeoutMs);\n }\n\n /**\n * Send SIGTERM, escalate to SIGKILL after `gracefulTimeoutMs`, and wait up to\n * `gracefulTimeoutMs * 2` for the process to exit. After the race settles, if\n * the process is still alive it is surfaced as an orphan via `app.logger.error`\n * so a hung shutdown is observable instead of silently succeeding.\n */\n private async terminate(name: string, proc: Subprocess, gracefulTimeoutMs: number): Promise<void> {\n proc.kill('SIGTERM');\n\n const deadline = gracefulTimeoutMs * 2;\n const forceKillTimer = setTimeout(() => {\n if (!proc.killed) {\n this.app?.logger.warn(`Process ${name} did not exit within ${gracefulTimeoutMs}ms; sending SIGKILL`);\n proc.kill();\n }\n }, gracefulTimeoutMs);\n\n try {\n await Promise.race([\n proc.exited,\n new Promise<void>(r => setTimeout(r, deadline)),\n ]);\n } finally {\n clearTimeout(forceKillTimer);\n }\n\n if (!proc.killed) {\n this.app?.logger.error(\n { name },\n `Process ${name} survived SIGTERM and SIGKILL and is now an orphan; manual cleanup may be required`,\n );\n }\n }\n\n private async readStdOut(name: string, stream: ReadableStream) {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n\n const lines = buffer.split('\\n');\n // Keep the last chunk if it's not a complete line (doesn't end with \\n)\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n if (!line.trim()) continue;\n this.processLine(name, line);\n }\n }\n } catch (err) {\n // A thrown error here is a broken pipe mid-read, distinct from the\n // normal end-of-stream (`done: true`) that exits the loop above.\n this.app?.logger.debug({ err, name }, `stdout reader for process ${name} failed`);\n }\n }\n\n private processLine(name: string, line: string) {\n try {\n // Try to parse as JSON Event\n const json = JSON.parse(line);\n this.app?.emit('process:message', { name, message: json });\n } catch {\n // Fallback to raw log\n this.app?.emit('process:log', { name, text: line });\n }\n }\n\n /**\n * Compute the next backoff delay for a process restart.\n *\n * - If no `restartBackoff` config is provided, returns the flat 1000 ms default.\n * - The FIRST restart (`restarts === 0`) waits exactly `currentBackoffMs`\n * (seeded to `initialMs` on spawn); exponential growth begins on the SECOND\n * restart with `min(currentMs * factor, maxMs)`.\n * - If the process was stable (uptime > restartCooldown), the backoff resets to initialMs.\n */\n private computeBackoffMs(procInfo: RunningProcess): number {\n const backoffCfg = procInfo.config.restartBackoff;\n if (!backoffCfg) {\n return 1000;\n }\n\n const cooldown = procInfo.config.restartCooldown ?? 60000;\n const uptime = Date.now() - procInfo.startedAt;\n if (uptime > cooldown) {\n // Process was stable — reset to initial delay\n return backoffCfg.initialMs ?? 1000;\n }\n\n const maxMs = backoffCfg.maxMs ?? 30000;\n const initialMs = backoffCfg.initialMs ?? 1000;\n\n // The very first restart (no prior restarts and the backoff has not yet\n // grown past its initial value) waits exactly initialMs — exponential\n // growth begins on the SECOND restart.\n if (procInfo.restarts === 0 && procInfo.currentBackoffMs === initialMs) {\n return Math.min(procInfo.currentBackoffMs, maxMs);\n }\n\n const factor = backoffCfg.factor ?? 2;\n const next = Math.min(procInfo.currentBackoffMs * factor, maxMs);\n return next;\n }\n\n private handleExit(name: string, exitCode: number) {\n if (this.stopping) return;\n\n const procInfo = this.processes.get(name);\n if (!procInfo) return;\n\n this.app?.logger.warn(`Process ${name} exited with code ${exitCode}`);\n\n // Remove from map so we don't try to kill it again on stop()\n this.processes.delete(name);\n\n // oneshot processes run to completion once and are never restarted\n if (procInfo.config.mode === 'oneshot') {\n this.app?.emit('process:exit', { name, exitCode });\n return;\n }\n\n if (procInfo.config.restartOnCrash) {\n const maxRestarts = procInfo.config.maxRestarts ?? 10;\n const cooldown = procInfo.config.restartCooldown ?? 60000;\n const uptime = Date.now() - procInfo.startedAt;\n\n // If the process ran longer than the cooldown, consider it stable and reset the counter\n const restarts = uptime > cooldown ? 1 : procInfo.restarts + 1;\n\n if (restarts > maxRestarts) {\n this.app?.logger.error(`Process ${name} exceeded max restarts (${maxRestarts}). Not restarting.`);\n this.app?.emit('process:max-restarts', { name, restarts: procInfo.restarts, maxRestarts });\n return;\n }\n\n const delayMs = this.computeBackoffMs(procInfo);\n\n this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts}) in ${delayMs}ms`);\n\n setTimeout(() => {\n this.spawnProcess(name, procInfo.config, restarts, delayMs);\n }, delayMs);\n }\n }\n\n // Updated spawn signature to track restarts and backoff state\n private spawnProcess(name: string, config: ProcessConfig, restarts = 0, currentBackoffMs?: number) {\n if (this.stopping || !this.app) return;\n\n const initialBackoffMs = config.restartBackoff?.initialMs ?? 1000;\n\n this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(' ') || ''})`);\n\n try {\n const proc = Bun.spawn(\n [config.command, ...(config.args || [])],\n {\n env: { ...process.env, ...config.env },\n stdout: config.mode === 'stdio' ? 'pipe' : 'inherit',\n stderr: config.mode === 'stdio' ? 'pipe' : 'inherit',\n stdin: config.mode === 'stdio' ? 'pipe' : 'ignore',\n onExit: (_proc, exitCode, _signalCode, _error) => {\n this.handleExit(name, exitCode || 0);\n }\n }\n );\n\n this.processes.set(name, {\n process: proc,\n config,\n name,\n restarts,\n startedAt: Date.now(),\n currentBackoffMs: currentBackoffMs ?? initialBackoffMs,\n });\n\n if (config.mode === 'stdio') {\n if (proc.stdout) this.readStdOut(name, proc.stdout);\n if (proc.stderr) this.readStdErr(name, proc.stderr);\n }\n\n } catch (err) {\n this.app.logger.error({ err }, `Failed to spawn process: ${name}`);\n }\n }\n\n private async readStdErr(name: string, stream: ReadableStream) {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const text = decoder.decode(value, { stream: true });\n if (text.trim()) {\n this.app?.emit('process:error', { name, text });\n }\n }\n } catch (err) {\n // A thrown error here is a broken pipe mid-read, distinct from the\n // normal end-of-stream (`done: true`) that exits the loop above.\n this.app?.logger.debug({ err, name }, `stderr reader for process ${name} failed`);\n }\n }\n\n // Send data to process stdin\n async send(name: string, data: any) {\n const procInfo = this.processes.get(name);\n if (!procInfo) {\n this.app?.logger.warn(`Cannot send message to non-existent process: ${name}`);\n return;\n }\n\n if (procInfo.config.mode !== 'stdio' || !procInfo.process.stdin) {\n this.app?.logger.warn(`Cannot send message to process ${name} (mode is not stdio or stdin is closed)`);\n return;\n }\n\n // Bun's Subprocess.stdin is a FileSink when stdin is piped.\n const stdin = procInfo.process.stdin as FileSink;\n\n try {\n // If data is object, stringify it and add newline\n const message = typeof data === 'string' ? data : JSON.stringify(data);\n stdin.write(message + '\\n');\n // flush is optional on the FileSink surface — call it only if present.\n if (typeof stdin.flush === 'function') {\n stdin.flush();\n }\n } catch (err) {\n this.app?.logger.error({ err }, `Failed to write to process ${name}`);\n }\n }\n\n async stop(gracefulTimeoutMs = 5000) {\n this.stopping = true;\n this.app?.logger.info('Stopping all processes...');\n\n const entries = [...this.processes.entries()];\n this.processes.clear();\n\n await Promise.all(\n entries.map(async ([name, info]) => {\n const proc = info.process;\n if (proc.killed) return;\n await this.terminate(name, proc, gracefulTimeoutMs);\n })\n );\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iskra-bun/process-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Gestion de procesos externos (Python, binarios) para Iskra.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iskra",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"build": "tsup --config ../../tsup.config.ts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@iskra-bun/core": "0.1.
|
|
49
|
+
"@iskra-bun/core": "0.1.1",
|
|
50
50
|
"zod": "^3.24.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
package/src/spawner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { App, Driver, ProcessConfig } from '@iskra-bun/core';
|
|
2
|
+
import type { FileSink } from 'bun';
|
|
2
3
|
import { Subprocess } from 'bun';
|
|
3
4
|
|
|
4
5
|
interface RunningProcess {
|
|
@@ -7,6 +8,8 @@ interface RunningProcess {
|
|
|
7
8
|
name: string;
|
|
8
9
|
restarts: number;
|
|
9
10
|
startedAt: number;
|
|
11
|
+
/** Current computed backoff delay in ms (grows with each crash) */
|
|
12
|
+
currentBackoffMs: number;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
export class ProcessManager implements Driver {
|
|
@@ -35,6 +38,78 @@ export class ProcessManager implements Driver {
|
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Spawn and register a new process at runtime. Reuses the existing internal
|
|
43
|
+
* spawn logic and respects the process mode (stdio/daemon/oneshot).
|
|
44
|
+
*
|
|
45
|
+
* @throws {Error} if a process with the given name is already registered.
|
|
46
|
+
*/
|
|
47
|
+
async spawn(name: string, config: ProcessConfig): Promise<void> {
|
|
48
|
+
if (this.processes.has(name)) {
|
|
49
|
+
throw new Error(`Process '${name}' is already registered. Kill it first or use a different name.`);
|
|
50
|
+
}
|
|
51
|
+
this.spawnProcess(name, config);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Gracefully stop and remove a single named process. Sends SIGTERM, then
|
|
56
|
+
* escalates to SIGKILL after `gracefulTimeoutMs`. The worst-case wait before
|
|
57
|
+
* this method resolves is `gracefulTimeoutMs * 2`: `gracefulTimeoutMs` for the
|
|
58
|
+
* SIGKILL escalation plus another `gracefulTimeoutMs` grace window for the
|
|
59
|
+
* process to actually exit afterwards. If the process still has not exited by
|
|
60
|
+
* then, it is reported as an orphan via `app.logger.error`.
|
|
61
|
+
*
|
|
62
|
+
* @throws {Error} if no process with the given name exists.
|
|
63
|
+
*/
|
|
64
|
+
async kill(name: string, gracefulTimeoutMs = 5000): Promise<void> {
|
|
65
|
+
const procInfo = this.processes.get(name);
|
|
66
|
+
if (!procInfo) {
|
|
67
|
+
throw new Error(`Process '${name}' not found. It may have already exited or never been spawned.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Remove from the map before terminating so handleExit won't try to restart it
|
|
71
|
+
this.processes.delete(name);
|
|
72
|
+
|
|
73
|
+
const proc = procInfo.process;
|
|
74
|
+
if (proc.killed) return;
|
|
75
|
+
|
|
76
|
+
await this.terminate(name, proc, gracefulTimeoutMs);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send SIGTERM, escalate to SIGKILL after `gracefulTimeoutMs`, and wait up to
|
|
81
|
+
* `gracefulTimeoutMs * 2` for the process to exit. After the race settles, if
|
|
82
|
+
* the process is still alive it is surfaced as an orphan via `app.logger.error`
|
|
83
|
+
* so a hung shutdown is observable instead of silently succeeding.
|
|
84
|
+
*/
|
|
85
|
+
private async terminate(name: string, proc: Subprocess, gracefulTimeoutMs: number): Promise<void> {
|
|
86
|
+
proc.kill('SIGTERM');
|
|
87
|
+
|
|
88
|
+
const deadline = gracefulTimeoutMs * 2;
|
|
89
|
+
const forceKillTimer = setTimeout(() => {
|
|
90
|
+
if (!proc.killed) {
|
|
91
|
+
this.app?.logger.warn(`Process ${name} did not exit within ${gracefulTimeoutMs}ms; sending SIGKILL`);
|
|
92
|
+
proc.kill();
|
|
93
|
+
}
|
|
94
|
+
}, gracefulTimeoutMs);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await Promise.race([
|
|
98
|
+
proc.exited,
|
|
99
|
+
new Promise<void>(r => setTimeout(r, deadline)),
|
|
100
|
+
]);
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(forceKillTimer);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!proc.killed) {
|
|
106
|
+
this.app?.logger.error(
|
|
107
|
+
{ name },
|
|
108
|
+
`Process ${name} survived SIGTERM and SIGKILL and is now an orphan; manual cleanup may be required`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
38
113
|
private async readStdOut(name: string, stream: ReadableStream) {
|
|
39
114
|
const reader = stream.getReader();
|
|
40
115
|
const decoder = new TextDecoder();
|
|
@@ -57,7 +132,9 @@ export class ProcessManager implements Driver {
|
|
|
57
132
|
}
|
|
58
133
|
}
|
|
59
134
|
} catch (err) {
|
|
60
|
-
//
|
|
135
|
+
// A thrown error here is a broken pipe mid-read, distinct from the
|
|
136
|
+
// normal end-of-stream (`done: true`) that exits the loop above.
|
|
137
|
+
this.app?.logger.debug({ err, name }, `stdout reader for process ${name} failed`);
|
|
61
138
|
}
|
|
62
139
|
}
|
|
63
140
|
|
|
@@ -72,7 +149,44 @@ export class ProcessManager implements Driver {
|
|
|
72
149
|
}
|
|
73
150
|
}
|
|
74
151
|
|
|
75
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Compute the next backoff delay for a process restart.
|
|
154
|
+
*
|
|
155
|
+
* - If no `restartBackoff` config is provided, returns the flat 1000 ms default.
|
|
156
|
+
* - The FIRST restart (`restarts === 0`) waits exactly `currentBackoffMs`
|
|
157
|
+
* (seeded to `initialMs` on spawn); exponential growth begins on the SECOND
|
|
158
|
+
* restart with `min(currentMs * factor, maxMs)`.
|
|
159
|
+
* - If the process was stable (uptime > restartCooldown), the backoff resets to initialMs.
|
|
160
|
+
*/
|
|
161
|
+
private computeBackoffMs(procInfo: RunningProcess): number {
|
|
162
|
+
const backoffCfg = procInfo.config.restartBackoff;
|
|
163
|
+
if (!backoffCfg) {
|
|
164
|
+
return 1000;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const cooldown = procInfo.config.restartCooldown ?? 60000;
|
|
168
|
+
const uptime = Date.now() - procInfo.startedAt;
|
|
169
|
+
if (uptime > cooldown) {
|
|
170
|
+
// Process was stable — reset to initial delay
|
|
171
|
+
return backoffCfg.initialMs ?? 1000;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const maxMs = backoffCfg.maxMs ?? 30000;
|
|
175
|
+
const initialMs = backoffCfg.initialMs ?? 1000;
|
|
176
|
+
|
|
177
|
+
// The very first restart (no prior restarts and the backoff has not yet
|
|
178
|
+
// grown past its initial value) waits exactly initialMs — exponential
|
|
179
|
+
// growth begins on the SECOND restart.
|
|
180
|
+
if (procInfo.restarts === 0 && procInfo.currentBackoffMs === initialMs) {
|
|
181
|
+
return Math.min(procInfo.currentBackoffMs, maxMs);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const factor = backoffCfg.factor ?? 2;
|
|
185
|
+
const next = Math.min(procInfo.currentBackoffMs * factor, maxMs);
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private handleExit(name: string, exitCode: number) {
|
|
76
190
|
if (this.stopping) return;
|
|
77
191
|
|
|
78
192
|
const procInfo = this.processes.get(name);
|
|
@@ -83,6 +197,12 @@ export class ProcessManager implements Driver {
|
|
|
83
197
|
// Remove from map so we don't try to kill it again on stop()
|
|
84
198
|
this.processes.delete(name);
|
|
85
199
|
|
|
200
|
+
// oneshot processes run to completion once and are never restarted
|
|
201
|
+
if (procInfo.config.mode === 'oneshot') {
|
|
202
|
+
this.app?.emit('process:exit', { name, exitCode });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
86
206
|
if (procInfo.config.restartOnCrash) {
|
|
87
207
|
const maxRestarts = procInfo.config.maxRestarts ?? 10;
|
|
88
208
|
const cooldown = procInfo.config.restartCooldown ?? 60000;
|
|
@@ -97,18 +217,22 @@ export class ProcessManager implements Driver {
|
|
|
97
217
|
return;
|
|
98
218
|
}
|
|
99
219
|
|
|
100
|
-
|
|
220
|
+
const delayMs = this.computeBackoffMs(procInfo);
|
|
221
|
+
|
|
222
|
+
this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts}) in ${delayMs}ms`);
|
|
101
223
|
|
|
102
224
|
setTimeout(() => {
|
|
103
|
-
this.spawnProcess(name, procInfo.config, restarts);
|
|
104
|
-
},
|
|
225
|
+
this.spawnProcess(name, procInfo.config, restarts, delayMs);
|
|
226
|
+
}, delayMs);
|
|
105
227
|
}
|
|
106
228
|
}
|
|
107
229
|
|
|
108
|
-
// Updated spawn signature to track restarts
|
|
109
|
-
private spawnProcess(name: string, config: ProcessConfig, restarts = 0) {
|
|
230
|
+
// Updated spawn signature to track restarts and backoff state
|
|
231
|
+
private spawnProcess(name: string, config: ProcessConfig, restarts = 0, currentBackoffMs?: number) {
|
|
110
232
|
if (this.stopping || !this.app) return;
|
|
111
233
|
|
|
234
|
+
const initialBackoffMs = config.restartBackoff?.initialMs ?? 1000;
|
|
235
|
+
|
|
112
236
|
this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(' ') || ''})`);
|
|
113
237
|
|
|
114
238
|
try {
|
|
@@ -119,8 +243,8 @@ export class ProcessManager implements Driver {
|
|
|
119
243
|
stdout: config.mode === 'stdio' ? 'pipe' : 'inherit',
|
|
120
244
|
stderr: config.mode === 'stdio' ? 'pipe' : 'inherit',
|
|
121
245
|
stdin: config.mode === 'stdio' ? 'pipe' : 'ignore',
|
|
122
|
-
onExit: (
|
|
123
|
-
this.handleExit(name, exitCode || 0
|
|
246
|
+
onExit: (_proc, exitCode, _signalCode, _error) => {
|
|
247
|
+
this.handleExit(name, exitCode || 0);
|
|
124
248
|
}
|
|
125
249
|
}
|
|
126
250
|
);
|
|
@@ -131,6 +255,7 @@ export class ProcessManager implements Driver {
|
|
|
131
255
|
name,
|
|
132
256
|
restarts,
|
|
133
257
|
startedAt: Date.now(),
|
|
258
|
+
currentBackoffMs: currentBackoffMs ?? initialBackoffMs,
|
|
134
259
|
});
|
|
135
260
|
|
|
136
261
|
if (config.mode === 'stdio') {
|
|
@@ -157,7 +282,9 @@ export class ProcessManager implements Driver {
|
|
|
157
282
|
}
|
|
158
283
|
}
|
|
159
284
|
} catch (err) {
|
|
160
|
-
//
|
|
285
|
+
// A thrown error here is a broken pipe mid-read, distinct from the
|
|
286
|
+
// normal end-of-stream (`done: true`) that exits the loop above.
|
|
287
|
+
this.app?.logger.debug({ err, name }, `stderr reader for process ${name} failed`);
|
|
161
288
|
}
|
|
162
289
|
}
|
|
163
290
|
|
|
@@ -174,28 +301,35 @@ export class ProcessManager implements Driver {
|
|
|
174
301
|
return;
|
|
175
302
|
}
|
|
176
303
|
|
|
177
|
-
// Bun's Subprocess.stdin is a FileSink
|
|
178
|
-
const stdin = procInfo.process.stdin as
|
|
304
|
+
// Bun's Subprocess.stdin is a FileSink when stdin is piped.
|
|
305
|
+
const stdin = procInfo.process.stdin as FileSink;
|
|
179
306
|
|
|
180
307
|
try {
|
|
181
308
|
// If data is object, stringify it and add newline
|
|
182
309
|
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
|
183
310
|
stdin.write(message + '\n');
|
|
184
|
-
|
|
311
|
+
// flush is optional on the FileSink surface — call it only if present.
|
|
312
|
+
if (typeof stdin.flush === 'function') {
|
|
313
|
+
stdin.flush();
|
|
314
|
+
}
|
|
185
315
|
} catch (err) {
|
|
186
316
|
this.app?.logger.error({ err }, `Failed to write to process ${name}`);
|
|
187
317
|
}
|
|
188
318
|
}
|
|
189
319
|
|
|
190
|
-
async stop() {
|
|
320
|
+
async stop(gracefulTimeoutMs = 5000) {
|
|
191
321
|
this.stopping = true;
|
|
192
322
|
this.app?.logger.info('Stopping all processes...');
|
|
193
323
|
|
|
194
|
-
|
|
195
|
-
if (!info.process.killed) {
|
|
196
|
-
info.process.kill();
|
|
197
|
-
}
|
|
198
|
-
}
|
|
324
|
+
const entries = [...this.processes.entries()];
|
|
199
325
|
this.processes.clear();
|
|
326
|
+
|
|
327
|
+
await Promise.all(
|
|
328
|
+
entries.map(async ([name, info]) => {
|
|
329
|
+
const proc = info.process;
|
|
330
|
+
if (proc.killed) return;
|
|
331
|
+
await this.terminate(name, proc, gracefulTimeoutMs);
|
|
332
|
+
})
|
|
333
|
+
);
|
|
200
334
|
}
|
|
201
335
|
}
|