@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 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
- handleExit(name, exitCode, signalCode) {
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
- this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts})`);
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
- }, 1e3);
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: (proc2, exitCode, signalCode, error) => {
84
- this.handleExit(name, exitCode || 0, signalCode || 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
- for (const [name, info] of this.processes) {
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":"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","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.1.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.0",
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
- // Stream closed or error
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
- private handleExit(name: string, exitCode: number, signalCode: number) {
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
- this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts})`);
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
- }, 1000);
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: (proc, exitCode, signalCode, error) => {
123
- this.handleExit(name, exitCode || 0, signalCode || 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
- // Stream closed
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 any;
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
- stdin.flush();
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
- for (const [name, info] of this.processes) {
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
  }