bm2 1.0.23 → 1.0.25
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/package.json +3 -1
- package/src/index.ts +2 -0
- package/src/log-manager.ts +62 -33
- package/src/process-container.ts +77 -40
- package/src/process-manager.ts +15 -4
- package/src/process-table.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bm2",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.25",
|
|
4
4
|
"description": "A blazing-fast, full-featured process manager built entirely on Bun native APIs. The modern PM2 replacement — zero Node.js dependencies, pure Bun performance.",
|
|
5
5
|
"main": "src/api.ts",
|
|
6
6
|
"module": "src/api.ts",
|
|
@@ -65,10 +65,12 @@
|
|
|
65
65
|
],
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"cli-table3": "^0.6.5",
|
|
68
|
+
"pidusage": "^4.0.1",
|
|
68
69
|
"ws": "^8.19.0"
|
|
69
70
|
},
|
|
70
71
|
"devDependencies": {
|
|
71
72
|
"@types/bun": "^1.3.9",
|
|
73
|
+
"@types/pidusage": "^2.0.5",
|
|
72
74
|
"@types/ws": "^8.18.1",
|
|
73
75
|
"bun-types": "latest",
|
|
74
76
|
"typescript": "^5.9.3"
|
package/src/index.ts
CHANGED
|
@@ -141,7 +141,9 @@ async function sendToDaemon(msg: DaemonMessage): Promise<DaemonResponse> {
|
|
|
141
141
|
// ---------------------------------------------------------------------------
|
|
142
142
|
|
|
143
143
|
async function loadEcosystemConfig(filePath: string): Promise<EcosystemConfig> {
|
|
144
|
+
|
|
144
145
|
const abs = resolve(filePath);
|
|
146
|
+
|
|
145
147
|
if (!existsSync(abs)) {
|
|
146
148
|
throw new Error(`Ecosystem file not found: ${abs}`);
|
|
147
149
|
}
|
package/src/log-manager.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* License: GPL-3.0-only
|
|
14
14
|
* Author: Zak <zak@maxxpainn.com>
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
import { join, dirname } from "path";
|
|
18
|
-
import {
|
|
18
|
+
import { openSync, readSync, closeSync } from "fs";
|
|
19
|
+
import { appendFile, stat, rename, unlink, readdir, access } from "fs/promises";
|
|
19
20
|
import { LOG_DIR, DEFAULT_LOG_MAX_SIZE, DEFAULT_LOG_RETAIN } from "./constants";
|
|
20
21
|
import type { LogRotateOptions } from "./types";
|
|
21
22
|
|
|
@@ -56,11 +57,12 @@ export class LogManager {
|
|
|
56
57
|
this.flushTimers.delete(filePath);
|
|
57
58
|
|
|
58
59
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Use appendFile (O_APPEND) instead of read-entire-file-then-rewrite.
|
|
61
|
+
// The old Bun.write approach pulled the whole log into a JS string on
|
|
62
|
+
// every flush — O(file size) memory per flush, quadratic overall.
|
|
63
|
+
// appendFile seeks to EOF at the kernel level and writes only new bytes.
|
|
64
|
+
await appendFile(filePath, content, { encoding: "utf8" });
|
|
62
65
|
} catch (err) {
|
|
63
|
-
// If file too large, log the error
|
|
64
66
|
console.error(`[bm2] Failed to write log: ${filePath}`, err);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -120,14 +122,29 @@ export class LogManager {
|
|
|
120
122
|
try {
|
|
121
123
|
const f = Bun.file(filePath);
|
|
122
124
|
if (!(await f.exists())) return;
|
|
125
|
+
|
|
123
126
|
const currentSize = f.size;
|
|
124
|
-
if (currentSize
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
if (currentSize <= lastSize) return;
|
|
128
|
+
|
|
129
|
+
const byteLength = currentSize - lastSize;
|
|
130
|
+
|
|
131
|
+
// Read only the new bytes via fs.readSync to avoid:
|
|
132
|
+
// 1. Loading the entire file into memory on every poll.
|
|
133
|
+
// 2. Slicing by character offset (lastSize) on a UTF-8 string,
|
|
134
|
+
// which silently corrupts multi-byte sequences.
|
|
135
|
+
const buf = Buffer.allocUnsafe(byteLength);
|
|
136
|
+
const fd = openSync(filePath, "r");
|
|
137
|
+
try {
|
|
138
|
+
readSync(fd, buf, 0, byteLength, lastSize);
|
|
139
|
+
} finally {
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
lastSize = currentSize;
|
|
144
|
+
|
|
145
|
+
const newContent = new TextDecoder().decode(buf);
|
|
146
|
+
for (const line of newContent.split("\n").filter(Boolean)) {
|
|
147
|
+
callback(line);
|
|
131
148
|
}
|
|
132
149
|
} catch {}
|
|
133
150
|
}, 500);
|
|
@@ -138,43 +155,55 @@ export class LogManager {
|
|
|
138
155
|
const file = Bun.file(filePath);
|
|
139
156
|
if (!(await file.exists())) return;
|
|
140
157
|
|
|
141
|
-
|
|
142
|
-
|
|
158
|
+
// Async stat — no thread-blocking syscall on the main event loop
|
|
159
|
+
const fileStat = await stat(filePath);
|
|
160
|
+
if (fileStat.size < options.maxSize) return;
|
|
143
161
|
|
|
144
|
-
// Rotate files
|
|
162
|
+
// Rotate files: shift .N → .N+1, filePath → .1
|
|
145
163
|
for (let i = options.retain - 1; i >= 1; i--) {
|
|
146
164
|
const src = i === 1 ? filePath : `${filePath}.${i - 1}`;
|
|
147
165
|
const dst = `${filePath}.${i}`;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
|
|
167
|
+
const srcExists = await access(src).then(() => true).catch(() => false);
|
|
168
|
+
if (!srcExists) continue;
|
|
169
|
+
|
|
170
|
+
await rename(src, dst);
|
|
171
|
+
|
|
172
|
+
if (options.compress) {
|
|
173
|
+
// Spawn the system `gzip` binary as a background subprocess so
|
|
174
|
+
// compression never blocks the JS event loop. gzip -f replaces
|
|
175
|
+
// `dst` with `dst.gz` in-place, matching the old .gz naming.
|
|
176
|
+
try {
|
|
177
|
+
const proc = Bun.spawn(["gzip", "-f", dst], {
|
|
178
|
+
stdout: "ignore",
|
|
179
|
+
stderr: "pipe",
|
|
180
|
+
});
|
|
181
|
+
const exitCode = await proc.exited;
|
|
182
|
+
if (exitCode !== 0) {
|
|
183
|
+
const errText = await new Response(proc.stderr).text();
|
|
184
|
+
console.error(`[bm2] gzip failed for ${dst}: ${errText.trim()}`);
|
|
185
|
+
}
|
|
186
|
+
} catch (compressErr) {
|
|
187
|
+
console.error(`[bm2] Failed to compress rotated log ${dst}:`, compressErr);
|
|
159
188
|
}
|
|
160
189
|
}
|
|
161
190
|
}
|
|
162
191
|
|
|
163
|
-
// Clean excess rotated files
|
|
192
|
+
// Clean excess rotated files asynchronously
|
|
164
193
|
const dir = dirname(filePath);
|
|
165
194
|
const baseName = filePath.split("/").pop()!;
|
|
166
195
|
try {
|
|
167
|
-
const files =
|
|
196
|
+
const files = await readdir(dir);
|
|
168
197
|
const rotated = files
|
|
169
198
|
.filter((f) => f.startsWith(baseName + "."))
|
|
170
199
|
.sort()
|
|
171
200
|
.reverse();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
201
|
+
await Promise.all(
|
|
202
|
+
rotated.slice(options.retain).map((f) => unlink(join(dir, f)).catch(() => {}))
|
|
203
|
+
);
|
|
175
204
|
} catch {}
|
|
176
205
|
|
|
177
|
-
// Truncate original
|
|
206
|
+
// Truncate original to reclaim inode while keeping it open for writers
|
|
178
207
|
await Bun.write(filePath, "");
|
|
179
208
|
} catch (err) {
|
|
180
209
|
console.error(`[bm2] Log rotation failed for ${filePath}:`, err);
|
package/src/process-container.ts
CHANGED
|
@@ -32,6 +32,9 @@ import {
|
|
|
32
32
|
DEFAULT_LOG_MAX_SIZE,
|
|
33
33
|
DEFAULT_LOG_RETAIN,
|
|
34
34
|
} from "./constants";
|
|
35
|
+
import pidusage from "pidusage";
|
|
36
|
+
import { readdir } from "node:fs/promises";
|
|
37
|
+
|
|
35
38
|
|
|
36
39
|
export class ProcessContainer {
|
|
37
40
|
public id: number;
|
|
@@ -224,56 +227,90 @@ export class ProcessContainer {
|
|
|
224
227
|
|
|
225
228
|
private async pipeStream(stream: ReadableStream<Uint8Array>, filePath: string) {
|
|
226
229
|
const reader = stream.getReader();
|
|
230
|
+
const decoder = new TextDecoder();
|
|
231
|
+
|
|
232
|
+
// Holds the tail of the last chunk if it did not end on a newline.
|
|
233
|
+
// Without this, a chunk boundary mid-word (e.g. "hel" / "lo\n") would be
|
|
234
|
+
// written as two separate log lines, corrupting the output.
|
|
235
|
+
let remainder = "";
|
|
236
|
+
|
|
227
237
|
try {
|
|
228
238
|
while (true) {
|
|
229
239
|
const { done, value } = await reader.read();
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
|
|
241
|
+
if (done) {
|
|
242
|
+
// Flush any buffered content that was never terminated with \n
|
|
243
|
+
if (remainder.length > 0) {
|
|
244
|
+
const timestamp = new Date().toISOString();
|
|
245
|
+
await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`);
|
|
246
|
+
remainder = "";
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
236
249
|
}
|
|
250
|
+
|
|
251
|
+
// stream=true tells the decoder to hold multi-byte UTF-8 sequences
|
|
252
|
+
// that straddle chunk boundaries rather than emitting replacement chars.
|
|
253
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
254
|
+
|
|
255
|
+
// Prepend any leftover from the previous chunk before splitting.
|
|
256
|
+
// This is a single string allocation per chunk (not per line), so
|
|
257
|
+
// allocation pressure stays O(chunk size) rather than O(line count).
|
|
258
|
+
const text = remainder + chunk;
|
|
259
|
+
const lines = text.split("\n");
|
|
260
|
+
|
|
261
|
+
// The last element is either "" (chunk ended on \n) or an incomplete
|
|
262
|
+
// line. Either way, hold it back for the next iteration.
|
|
263
|
+
remainder = lines.pop()!;
|
|
264
|
+
|
|
265
|
+
if (lines.length === 0) continue;
|
|
266
|
+
|
|
267
|
+
const timestamp = new Date().toISOString();
|
|
268
|
+
// Build a single string for all complete lines in this chunk so
|
|
269
|
+
// appendLog (and the underlying O_APPEND write) is called once per
|
|
270
|
+
// chunk, not once per line.
|
|
271
|
+
const output = lines.map((line) => `[${timestamp}] ${line}\n`).join("");
|
|
272
|
+
await this.logManager.appendLog(filePath, output);
|
|
237
273
|
}
|
|
238
|
-
} catch {
|
|
274
|
+
} catch {
|
|
275
|
+
// Flush remainder on unexpected stream error
|
|
276
|
+
if (remainder.length > 0) {
|
|
277
|
+
const timestamp = new Date().toISOString();
|
|
278
|
+
await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`).catch(() => {});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
239
281
|
}
|
|
240
282
|
|
|
283
|
+
|
|
241
284
|
private startMonitoring() {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
285
|
+
this.monitorInterval = setInterval(async () => {
|
|
286
|
+
|
|
287
|
+
if (!this.pid || this.status !== "online") return;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
|
|
291
|
+
// 1. Fetch cross-platform CPU and Memory usage
|
|
292
|
+
const stats = await pidusage(this.pid);
|
|
293
|
+
|
|
294
|
+
// pidusage returns memory directly in bytes and cpu as a percentage
|
|
295
|
+
this.memory = stats.memory;
|
|
296
|
+
this.cpu = stats.cpu;
|
|
297
|
+
|
|
298
|
+
// 2. Track file descriptors (handles) on Linux
|
|
299
|
+
// (pidusage does not provide this metric, so we keep the original logic)
|
|
300
|
+
if (process.platform === "linux") {
|
|
301
|
+
try {
|
|
302
|
+
this.handles = (await readdir(`/proc/${this.pid}/fd`)).length;
|
|
303
|
+
} catch {}
|
|
252
304
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
this.
|
|
257
|
-
|
|
258
|
-
} else {
|
|
259
|
-
const ps = Bun.spawn(["ps", "-o", "rss=,pcpu=", "-p", String(this.pid)], {
|
|
260
|
-
stdout: "pipe", stderr: "pipe",
|
|
261
|
-
});
|
|
262
|
-
const output = await new Response(ps.stdout).text();
|
|
263
|
-
const parts = output.trim().split(/\s+/);
|
|
264
|
-
if (parts.length >= 2) {
|
|
265
|
-
this.memory = parseInt(parts[0]!) * 1024;
|
|
266
|
-
this.cpu = parseFloat(parts[1]!);
|
|
305
|
+
|
|
306
|
+
// 3. Max memory restart
|
|
307
|
+
if (this.config.maxMemoryRestart && this.memory > this.config.maxMemoryRestart) {
|
|
308
|
+
console.log(`[bm2] ${this.name} exceeded memory limit (${this.memory} > ${this.config.maxMemoryRestart}), restarting...`);
|
|
309
|
+
await this.restart();
|
|
267
310
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (this.config.maxMemoryRestart && this.memory > this.config.maxMemoryRestart) {
|
|
272
|
-
console.log(`[bm2] ${this.name} exceeded memory limit (${this.memory} > ${this.config.maxMemoryRestart}), restarting...`);
|
|
273
|
-
await this.restart();
|
|
274
|
-
}
|
|
275
|
-
} catch {}
|
|
276
|
-
}, MONITOR_INTERVAL);
|
|
311
|
+
|
|
312
|
+
} catch {}
|
|
313
|
+
}, MONITOR_INTERVAL);
|
|
277
314
|
}
|
|
278
315
|
|
|
279
316
|
private startLogRotation(logPaths: { outFile: string; errFile: string }) {
|
package/src/process-manager.ts
CHANGED
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
DEFAULT_RESTART_DELAY,
|
|
36
36
|
DEFAULT_LOG_MAX_SIZE,
|
|
37
37
|
DEFAULT_LOG_RETAIN,
|
|
38
|
-
|
|
38
|
+
} from "./constants";
|
|
39
|
+
import path from "path";
|
|
40
|
+
|
|
39
41
|
|
|
40
42
|
export class ProcessManager {
|
|
41
43
|
private processes: Map<number, ProcessContainer> = new Map();
|
|
@@ -73,8 +75,12 @@
|
|
|
73
75
|
const config = this.buildConfig(id, name, options, resolvedInstances, i);
|
|
74
76
|
|
|
75
77
|
const container = new ProcessContainer(
|
|
76
|
-
id,
|
|
77
|
-
|
|
78
|
+
id,
|
|
79
|
+
config,
|
|
80
|
+
this.logManager,
|
|
81
|
+
this.clusterManager,
|
|
82
|
+
this.healthChecker,
|
|
83
|
+
this.cronManager
|
|
78
84
|
);
|
|
79
85
|
|
|
80
86
|
this.processes.set(id, container);
|
|
@@ -109,10 +115,15 @@
|
|
|
109
115
|
instances: number,
|
|
110
116
|
workerIndex: number
|
|
111
117
|
): ProcessDescription {
|
|
118
|
+
|
|
119
|
+
const script = path.isAbsolute(options.script)
|
|
120
|
+
? options.script
|
|
121
|
+
: path.resolve(process.cwd(), options.script);
|
|
122
|
+
|
|
112
123
|
return {
|
|
113
124
|
id,
|
|
114
125
|
name,
|
|
115
|
-
script
|
|
126
|
+
script,
|
|
116
127
|
args: options.args || [],
|
|
117
128
|
cwd: options.cwd || process.cwd(),
|
|
118
129
|
env: {
|
package/src/process-table.ts
CHANGED
|
@@ -106,6 +106,8 @@ export function printProcessTable(processes: ProcessState[]) {
|
|
|
106
106
|
style: { border: ["dim"] },
|
|
107
107
|
chars: minimalBorders(),
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
//console.log("processes===>", processes)
|
|
109
111
|
|
|
110
112
|
for (const p of processes) {
|
|
111
113
|
const cpu = p.monit?.cpu ?? 0;
|
|
@@ -158,6 +160,7 @@ export function liveWatchProcess(processes: ProcessState[], interval = 5_000) {
|
|
|
158
160
|
// Render table
|
|
159
161
|
const render = () => {
|
|
160
162
|
clear();
|
|
163
|
+
|
|
161
164
|
printProcessTable(getSortedProcesses());
|
|
162
165
|
|
|
163
166
|
console.log(color("─".repeat(50), "dim"));
|