bgrun 3.12.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/platform.ts CHANGED
@@ -11,6 +11,28 @@ import { measure, createMeasure } from "measure-fn";
11
11
 
12
12
  const plat = createMeasure('platform');
13
13
 
14
+ /**
15
+ * Execute a PowerShell command with -NoProfile synchronously.
16
+ * Returns stdout as string, or empty string on error.
17
+ *
18
+ * Uses Bun.spawnSync to avoid async stream hanging issues on Windows
19
+ * where Bun's pipe reader hangs indefinitely when reading killed processes.
20
+ */
21
+ export function psExec(command: string, _timeoutMs: number = 3000): string {
22
+ const tmpFile = join(os.tmpdir(), `bgr-ps-${Date.now()}.ps1`);
23
+ try {
24
+ fs.writeFileSync(tmpFile, command);
25
+ const result = Bun.spawnSync(
26
+ ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpFile]
27
+ );
28
+ try { fs.unlinkSync(tmpFile); } catch { }
29
+ return result.stdout?.toString() || '';
30
+ } catch {
31
+ try { fs.unlinkSync(tmpFile); } catch { }
32
+ return '';
33
+ }
34
+ }
35
+
14
36
  /** Detect if running on Windows - use function to prevent bundler tree-shaking */
15
37
  export function isWindows(): boolean {
16
38
  return process.platform === "win32";
@@ -39,8 +61,9 @@ export async function isProcessRunning(pid: number, command?: string): Promise<b
39
61
  }
40
62
 
41
63
  if (isWindows()) {
42
- const result = await $`tasklist /FI "PID eq ${pid}" /NH`.nothrow().text();
43
- return result.includes(`${pid}`);
64
+ // process.kill(pid, 0) signal 0 checks existence without killing
65
+ // 100x faster than tasklist which hangs on some Windows systems
66
+ try { process.kill(pid, 0); return true; } catch { return false; }
44
67
  } else {
45
68
  const result = await $`ps -p ${pid}`.nothrow().text();
46
69
  return result.includes(`${pid}`);
@@ -86,8 +109,10 @@ async function isDockerContainerRunning(command: string): Promise<boolean> {
86
109
  async function getChildPids(pid: number): Promise<number[]> {
87
110
  try {
88
111
  if (isWindows()) {
89
- // On Windows, use PowerShell to get child processes
90
- const result = await $`powershell -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}' | Select-Object -ExpandProperty ProcessId"`.nothrow().text();
112
+ const result = await psExec(
113
+ `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}' | Select-Object -ExpandProperty ProcessId`,
114
+ 3000
115
+ );
91
116
  return result
92
117
  .split('\n')
93
118
  .map(line => parseInt(line.trim()))
@@ -176,6 +201,39 @@ export async function isPortFree(port: number): Promise<boolean> {
176
201
  }
177
202
  }
178
203
 
204
+ /**
205
+ * Get info about what's using a port.
206
+ * Returns { inUse: boolean, pid?: number, processName?: string }
207
+ */
208
+ export async function getPortInfo(port: number): Promise<{ inUse: boolean; pid?: number; processName?: string }> {
209
+ try {
210
+ if (isWindows()) {
211
+ const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
212
+ for (const line of result.split('\n')) {
213
+ const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
214
+ if (match) {
215
+ const pid = parseInt(match[2]);
216
+ if (pid > 0 && await isProcessRunning(pid)) {
217
+ // Get process name
218
+ const nameResult = await $`powershell -NoProfile -Command "(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).ProcessName"`.nothrow().quiet().text();
219
+ return { inUse: true, pid, processName: nameResult.trim() || 'unknown' };
220
+ }
221
+ }
222
+ }
223
+ return { inUse: false };
224
+ } else {
225
+ const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
226
+ const lines = result.trim().split('\n').filter((l: string) => l.trim());
227
+ if (lines.length > 1) {
228
+ return { inUse: true };
229
+ }
230
+ return { inUse: false };
231
+ }
232
+ } catch {
233
+ return { inUse: false };
234
+ }
235
+ }
236
+
179
237
  /**
180
238
  * Wait for a port to become free, polling with timeout.
181
239
  * Returns true if port is free, false if timeout reached.
@@ -265,23 +323,26 @@ export function getShellCommand(command: string): string[] {
265
323
  return ["sh", "-c", command];
266
324
  }
267
325
  }
268
-
269
326
  /**
270
327
  * Find the actual child process PID spawned by a shell wrapper.
271
- * Traverses the process tree recursively to find the deepest (leaf) child.
272
- * On Windows, bgr spawn creates: cmd.exe → bgr.exe bun.exe
273
- * We need the bun.exe PID, not the intermediate bgr.exe.
328
+ * Traverses the process tree to find the deepest (leaf) child.
329
+ * On Windows, bgr spawn creates: cmd.exe → bun.exe (typically 1-2 levels)
330
+ *
331
+ * Uses PowerShell with -NoProfile and a hard timeout to prevent hangs.
274
332
  */
275
333
  export async function findChildPid(parentPid: number): Promise<number> {
276
334
  let currentPid = parentPid;
277
- const maxDepth = 5; // Safety limit to avoid infinite loops
335
+ const maxDepth = 2; // cmd.exe bun.exe is the typical chain
278
336
 
279
337
  for (let depth = 0; depth < maxDepth; depth++) {
280
338
  try {
281
339
  let childPids: number[] = [];
282
340
 
283
341
  if (isWindows()) {
284
- const result = await $`powershell -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${currentPid}' | Select-Object -ExpandProperty ProcessId"`.nothrow().text();
342
+ const result = await psExec(
343
+ `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${currentPid}' | Select-Object -ExpandProperty ProcessId`,
344
+ 3000
345
+ );
285
346
  childPids = result
286
347
  .split('\n')
287
348
  .map((line: string) => parseInt(line.trim()))
@@ -295,12 +356,7 @@ export async function findChildPid(parentPid: number): Promise<number> {
295
356
  .filter(n => !isNaN(n) && n > 0);
296
357
  }
297
358
 
298
- if (childPids.length === 0) {
299
- // No children — currentPid is the leaf process
300
- break;
301
- }
302
-
303
- // Follow the first child deeper
359
+ if (childPids.length === 0) break;
304
360
  currentPid = childPids[0];
305
361
  } catch {
306
362
  break;
@@ -335,14 +391,10 @@ export async function reconcileProcessPids(
335
391
  let runningProcs: Array<{ pid: number; cmdLine: string }> = [];
336
392
 
337
393
  if (isWindows()) {
338
- // Write a temp PS1 script to avoid quoting issues with $() in Bun's shell
339
- const tmpScript = join(os.tmpdir(), 'bgr-reconcile.ps1');
340
- const psCode = `Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'bun.exe' } | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.CommandLine)" }`;
341
- await Bun.write(tmpScript, psCode);
342
-
343
- const ps = Bun.spawnSync(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpScript]);
344
- const output = ps.stdout.toString();
345
-
394
+ const output = await psExec(
395
+ `Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.CommandLine)" }`,
396
+ 5000
397
+ );
346
398
  for (const line of output.split('\n')) {
347
399
  const sepIdx = line.indexOf('|');
348
400
  if (sepIdx === -1) continue;
@@ -508,32 +560,17 @@ export async function getProcessBatchResources(pids: number[]): Promise<Map<numb
508
560
 
509
561
  try {
510
562
  if (isWindows()) {
511
- const result = await $`powershell -Command "Get-Process | Select-Object Id, CPU, WorkingSet"`.nothrow().quiet().text();
512
- const lines = result.trim().split('\n');
513
-
514
- for (const line of lines) {
515
- const trimmed = line.trim();
516
- if (!trimmed || trimmed.startsWith('Id') || trimmed.startsWith('--')) continue;
517
-
518
- // Replace multiple spaces with a single space to parse correctly
519
- const parts = trimmed.split(/\s+/);
520
- if (parts.length >= 3) {
521
- const pid = parseInt(parts[0]);
522
- // CPU can sometimes be blank if process is just starting, handle that
523
- let cpuStr = parts[1];
524
- let memStr = parts[2];
525
- if (parts.length === 2) {
526
- // If CPU is missing, powershell might omit it and give just ID and WorkingSet
527
- cpuStr = "0";
528
- memStr = parts[1];
529
- }
530
-
531
- const cpu = parseFloat(cpuStr) || 0;
532
- const memory = parseInt(memStr) || 0;
533
-
534
- if (!isNaN(pid) && !isNaN(memory)) {
535
- if (pidSet.has(pid)) resourceMap.set(pid, { memory, cpu });
536
- }
563
+ // psExec(Get-Process) is fast (~2ms) vs tasklist which hangs
564
+ const output = psExec(
565
+ `Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | Select-Object Id, WorkingSet64 | ForEach-Object { Write-Output "$($_.Id)|$($_.WorkingSet64)" }`
566
+ );
567
+ for (const line of output.split('\n')) {
568
+ const sepIdx = line.indexOf('|');
569
+ if (sepIdx === -1) continue;
570
+ const pid = parseInt(line.substring(0, sepIdx).trim());
571
+ const memory = parseInt(line.substring(sepIdx + 1).trim()) || 0;
572
+ if (!isNaN(pid) && pidSet.has(pid)) {
573
+ resourceMap.set(pid, { memory, cpu: 0 });
537
574
  }
538
575
  }
539
576
  } else {
package/src/server.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * - Otherwise → defaults to 3000, falls back to next available if busy
15
15
  */
16
16
  import path from 'path';
17
- import { getAllProcesses, getProcess } from './db';
17
+ import { getAllProcesses, getProcess, addHistoryEntry } from './db';
18
18
  import { isProcessRunning } from './platform';
19
19
  import { handleRun } from './commands/run';
20
20
  import { parseEnvString } from './utils';
@@ -26,8 +26,44 @@ const GUARD_SKIP_NAMES = new Set(['bgr-dashboard', 'bgr-guard']); // Don't try t
26
26
  const _g = globalThis as any;
27
27
  if (!_g.__bgrGuardRestartCounts) _g.__bgrGuardRestartCounts = new Map<string, number>();
28
28
  if (!_g.__bgrGuardNextRestartTime) _g.__bgrGuardNextRestartTime = new Map<string, number>();
29
+ if (!_g.__bgrGuardEvents) _g.__bgrGuardEvents = [] as { time: number; name: string; action: string; success: boolean }[];
29
30
  export const guardRestartCounts: Map<string, number> = _g.__bgrGuardRestartCounts;
30
31
  const guardNextRestartTime: Map<string, number> = _g.__bgrGuardNextRestartTime;
32
+ export const guardEvents: { time: number; name: string; action: string; success: boolean }[] = _g.__bgrGuardEvents;
33
+
34
+ /** Try to free a port from zombie processes (dead PIDs holding sockets) */
35
+ async function cleanupPort(port: number): Promise<number> {
36
+ if (process.platform !== 'win32') return port;
37
+ try {
38
+ const proc = Bun.spawn(['powershell', '-NoProfile', '-Command',
39
+ `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`
40
+ ], { stdout: 'pipe', stderr: 'pipe' });
41
+ const text = await new Response(proc.stdout).text();
42
+ const pid = parseInt(text.trim(), 10);
43
+ if (!pid || pid === process.pid) return port;
44
+
45
+ // Check if the owning process is actually dead
46
+ const checkProc = Bun.spawn(['powershell', '-NoProfile', '-Command',
47
+ `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id`
48
+ ], { stdout: 'pipe', stderr: 'pipe' });
49
+ const checkText = await new Response(checkProc.stdout).text();
50
+ if (checkText.trim()) {
51
+ // Process is alive — kill it to reclaim the port
52
+ console.log(`[server] Killing PID ${pid} holding port ${port}`);
53
+ Bun.spawn(['taskkill', '/F', '/PID', String(pid)], { stdout: 'pipe', stderr: 'pipe' });
54
+ await Bun.sleep(1000);
55
+ return port;
56
+ } else {
57
+ // Process is dead but socket is zombie — use fallback port
58
+ const fallback = port + 1;
59
+ console.log(`[server] ⚠ Port ${port} held by zombie PID ${pid} — falling back to port ${fallback}`);
60
+ return fallback;
61
+ }
62
+ } catch { return port; /* best-effort cleanup */ }
63
+ }
64
+
65
+ let _originalPort = 3000;
66
+ let _currentPort = 3000;
31
67
 
32
68
  export async function startServer() {
33
69
  // Dynamic import to avoid melina's side-effect console.log at bundle load time
@@ -36,12 +72,20 @@ export async function startServer() {
36
72
 
37
73
  // Only pass port when BUN_PORT is explicitly set.
38
74
  // When omitted, Melina defaults to 3000 with auto-fallback to next available port.
39
- const explicitPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : undefined;
75
+ const requestedPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : 3000;
76
+ _originalPort = requestedPort;
77
+
78
+ // Clean up zombie port bindings before starting — may return a different fallback port
79
+ const resolvedPort = await cleanupPort(requestedPort);
80
+ _currentPort = resolvedPort;
81
+
82
+ // Pass port explicitly if user requested one OR if we had to fallback
83
+ const needsExplicitPort = process.env.BUN_PORT || resolvedPort !== requestedPort;
40
84
  await start({
41
85
  appDir,
42
86
  defaultTitle: 'bgrun Dashboard - Process Manager',
43
87
  globalCss: path.join(appDir, 'globals.css'),
44
- ...(explicitPort !== undefined && { port: explicitPort }),
88
+ ...(needsExplicitPort && { port: resolvedPort }),
45
89
  });
46
90
 
47
91
  // Start the built-in process guard
@@ -50,6 +94,33 @@ export async function startServer() {
50
94
  // Start log rotation (prevents unbounded log file growth)
51
95
  const { startLogRotation } = await import('./log-rotation');
52
96
  startLogRotation(() => getAllProcesses());
97
+
98
+ // Start sticky port checker - periodically try original port if we're on a fallback
99
+ if (resolvedPort !== requestedPort) {
100
+ startStickyPortChecker();
101
+ }
102
+ }
103
+
104
+ function startStickyPortChecker() {
105
+ const CHECK_INTERVAL_MS = 60_000; // Check every 60 seconds
106
+ console.log(`[server] Starting sticky port checker (original: ${_originalPort}, current: ${_currentPort})`);
107
+
108
+ setInterval(async () => {
109
+ if (_currentPort === _originalPort) return; // Already on original port
110
+
111
+ try {
112
+ const proc = Bun.spawn(['powershell', '-NoProfile', '-Command',
113
+ `Get-NetTCPConnection -LocalPort ${_originalPort} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`
114
+ ], { stdout: 'pipe', stderr: 'pipe' });
115
+ const text = await new Response(proc.stdout).text();
116
+ const pid = parseInt(text.trim(), 10);
117
+
118
+ if (!pid) {
119
+ console.log(`[server] ✓ Original port ${_originalPort} is now available! Consider restarting to reclaim it.`);
120
+ _currentPort = _originalPort;
121
+ }
122
+ } catch { /* best-effort */ }
123
+ }, CHECK_INTERVAL_MS);
53
124
  }
54
125
 
55
126
  /**
@@ -89,6 +160,7 @@ function startGuard() {
89
160
  if (now < nextRestart) continue; // Still in backoff period
90
161
 
91
162
  console.log(`[guard] ⚠ Guarded process "${proc.name}" (PID ${proc.pid}) is dead, restarting...`);
163
+ let success = false;
92
164
  try {
93
165
  await handleRun({
94
166
  action: 'run',
@@ -96,12 +168,22 @@ function startGuard() {
96
168
  force: true,
97
169
  remoteName: '',
98
170
  });
171
+ success = true;
99
172
 
100
173
  // Track restart count
101
174
  const prevCount = guardRestartCounts.get(proc.name) || 0;
102
175
  const newCount = prevCount + 1;
103
176
  guardRestartCounts.set(proc.name, newCount);
104
177
 
178
+ // Record in history database
179
+ try {
180
+ addHistoryEntry(proc.name, 'restart', undefined, { by: 'guard', count: newCount });
181
+ } catch { /* ignore history errors */ }
182
+
183
+ // Record event for dashboard
184
+ guardEvents.unshift({ time: now, name: proc.name, action: 'restart', success: true });
185
+ if (guardEvents.length > 100) guardEvents.pop();
186
+
105
187
  // Exponential backoff if it crashes repeatedly (more than 5 times)
106
188
  if (newCount > 5) {
107
189
  const backoffSeconds = Math.min(30 * Math.pow(2, newCount - 6), 300); // 30s, 60s, 120s, up to 5 mins
@@ -112,6 +194,8 @@ function startGuard() {
112
194
  }
113
195
  } catch (err: any) {
114
196
  console.error(`[guard] ✗ Failed to restart "${proc.name}": ${err.message}`);
197
+ guardEvents.unshift({ time: now, name: proc.name, action: 'restart', success: false });
198
+ if (guardEvents.length > 100) guardEvents.pop();
115
199
  }
116
200
  } else {
117
201
  // Reset counter if process has been stable (alive at least once during check)
package/src/utils.ts CHANGED
@@ -37,8 +37,8 @@ export async function getVersion(): Promise<string> {
37
37
 
38
38
  export function validateDirectory(directory: string) {
39
39
  if (!directory || !fs.existsSync(directory)) {
40
- console.log(chalk.red("❌ Error: 'directory' must be a valid path."));
41
- process.exit(1);
40
+ // Throw instead of process.exit() lets dashboard API handlers catch gracefully
41
+ throw new Error(`Directory not found or invalid: '${directory}'`);
42
42
  }
43
43
  }
44
44