bgrun 3.10.0 → 3.10.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.
@@ -898,6 +898,19 @@ tr.animate-in:nth-child(10) {
898
898
  border: 1px solid var(--info-border);
899
899
  }
900
900
 
901
+ a.port-link {
902
+ text-decoration: none;
903
+ transition: all var(--transition-fast);
904
+ }
905
+
906
+ a.port-link:hover {
907
+ text-decoration: underline;
908
+ background: rgba(56, 189, 248, 0.15);
909
+ /* Slightly brighter info-bg on hover */
910
+ color: #7dd3fc;
911
+ /* Brighter info color */
912
+ }
913
+
901
914
  .command {
902
915
  font-family: var(--font-mono);
903
916
  font-size: 0.75rem;
@@ -164,7 +164,7 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
164
164
  <td className="pid">{String(p.pid)}</td>
165
165
  <td>
166
166
  {p.port
167
- ? <span className="port-num">:{p.port}</span>
167
+ ? <a className="port-num port-link" href={`http://localhost:${p.port}`} target="_blank" rel="noopener" title={`Open localhost:${p.port}`} onClick={(e: Event) => e.stopPropagation()}>:{p.port}</a>
168
168
  : <span style={{ color: 'var(--text-muted)' }}>–</span>
169
169
  }
170
170
  </td>
@@ -228,7 +228,7 @@ function ProcessCard({ p }: { p: ProcessData }) {
228
228
  </div>
229
229
  <div className="card-details">
230
230
  <div className="card-detail"><span className="card-label">PID</span><span>{p.pid}</span></div>
231
- <div className="card-detail"><span className="card-label">Port</span><span>{p.port ? `:${p.port}` : '–'}</span></div>
231
+ <div className="card-detail"><span className="card-label">Port</span>{p.port ? <a className="port-link" href={`http://localhost:${p.port}`} target="_blank" rel="noopener" onClick={(e: Event) => e.stopPropagation()}>:{p.port}</a> : <span>–</span>}</div>
232
232
  <div className="card-detail"><span className="card-label">Memory</span><span>{p.memory > 0 ? formatMemory(p.memory) : '–'}</span></div>
233
233
  <div className="card-detail"><span className="card-label">Runtime</span><span>{formatRuntime(p.runtime)}</span></div>
234
234
  </div>
@@ -845,7 +845,7 @@ export default function mount(): () => void {
845
845
  const metaItems = [
846
846
  { label: 'Status', value: proc.running ? '● Running' : '○ Stopped' },
847
847
  { label: 'PID', value: String(proc.pid) },
848
- { label: 'Port', value: proc.port ? `:${proc.port}` : '–' },
848
+ { label: 'Port', value: proc.port ? `:${proc.port}` : '–', href: proc.port ? `http://localhost:${proc.port}` : undefined },
849
849
  { label: 'Runtime', value: formatRuntime(proc.runtime) },
850
850
  { label: 'Command', value: proc.command },
851
851
  { label: 'Directory', value: proc.directory || '–' },
@@ -853,10 +853,13 @@ export default function mount(): () => void {
853
853
  { label: 'Group', value: proc.group || '–' },
854
854
  ];
855
855
 
856
- const items = metaItems.map(m => (
856
+ const items = metaItems.map((m: any) => (
857
857
  <div className="meta-item">
858
858
  <span className="meta-label">{m.label}</span>
859
- <span className="meta-value">{m.value}</span>
859
+ {m.href
860
+ ? <a className="meta-value port-link" href={m.href} target="_blank" rel="noopener">{m.value}</a>
861
+ : <span className="meta-value">{m.value}</span>
862
+ }
860
863
  </div>
861
864
  ) as unknown as Node);
862
865
  meta.replaceChildren(...items);
package/dist/index.js CHANGED
@@ -80,36 +80,24 @@ async function getChildPids(pid) {
80
80
  }
81
81
  async function terminateProcess(pid, force = false) {
82
82
  await plat.measure(`Terminate PID ${pid}`, async (m) => {
83
- const children = await m("Get children", () => getChildPids(pid)) ?? [];
84
- for (const childPid of children) {
85
- try {
86
- if (isWindows()) {
87
- if (force) {
88
- await $`taskkill /F /PID ${childPid}`.nothrow().quiet();
89
- } else {
90
- await $`taskkill /PID ${childPid}`.nothrow().quiet();
91
- }
92
- } else {
93
- const signal = force ? "KILL" : "TERM";
94
- await $`kill -${signal} ${childPid}`.nothrow();
83
+ try {
84
+ if (isWindows()) {
85
+ await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
86
+ } else {
87
+ const children = await m("Get children", () => getChildPids(pid)) ?? [];
88
+ const signal = force ? "KILL" : "TERM";
89
+ for (const childPid of children) {
90
+ try {
91
+ await $`kill -${signal} ${childPid}`.nothrow();
92
+ } catch {}
95
93
  }
96
- } catch {}
97
- }
98
- await Bun.sleep(500);
99
- if (await isProcessRunning(pid)) {
100
- try {
101
- if (isWindows()) {
102
- if (force) {
103
- await $`taskkill /F /PID ${pid}`.nothrow().quiet();
104
- } else {
105
- await $`taskkill /PID ${pid}`.nothrow().quiet();
106
- }
107
- } else {
108
- const signal = force ? "KILL" : "TERM";
94
+ await Bun.sleep(500);
95
+ if (await isProcessRunning(pid)) {
109
96
  await $`kill -${signal} ${pid}`.nothrow();
110
97
  }
111
- } catch {}
112
- }
98
+ }
99
+ } catch {}
100
+ await Bun.sleep(300);
113
101
  });
114
102
  }
115
103
  async function isPortFree(port) {
@@ -118,9 +106,13 @@ async function isPortFree(port) {
118
106
  const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
119
107
  for (const line of result.split(`
120
108
  `)) {
121
- const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING`));
122
- if (match)
123
- return false;
109
+ const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
110
+ if (match) {
111
+ const pid = parseInt(match[2]);
112
+ if (pid > 0 && await isProcessRunning(pid)) {
113
+ return false;
114
+ }
115
+ }
124
116
  }
125
117
  return true;
126
118
  } else {
@@ -159,8 +151,11 @@ async function killProcessOnPort(port) {
159
151
  }
160
152
  }
161
153
  for (const pid of pids) {
162
- await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
163
- console.log(`Killed process ${pid} using port ${port}`);
154
+ const alive = await isProcessRunning(pid);
155
+ if (alive) {
156
+ await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
157
+ console.log(`Killed process ${pid} using port ${port}`);
158
+ }
164
159
  }
165
160
  } else {
166
161
  const result = await $`lsof -ti :${port}`.nothrow().text();
@@ -326,6 +321,87 @@ var init_platform = __esm(() => {
326
321
  plat = createMeasure("platform");
327
322
  });
328
323
 
324
+ // src/utils.ts
325
+ import * as fs2 from "fs";
326
+ import chalk from "chalk";
327
+ function parseEnvString(envString) {
328
+ const env = {};
329
+ envString.split(",").forEach((pair) => {
330
+ const [key, value] = pair.split("=");
331
+ if (key && value)
332
+ env[key] = value;
333
+ });
334
+ return env;
335
+ }
336
+ function calculateRuntime(startTime) {
337
+ const start = new Date(startTime).getTime();
338
+ const now = new Date().getTime();
339
+ const diffInMinutes = Math.floor((now - start) / (1000 * 60));
340
+ return `${diffInMinutes} minutes`;
341
+ }
342
+ async function getVersion() {
343
+ try {
344
+ const { join } = await import("path");
345
+ const pkgPath = join(import.meta.dir, "../package.json");
346
+ const pkg = await Bun.file(pkgPath).json();
347
+ return pkg.version || "0.0.0";
348
+ } catch {
349
+ return "0.0.0";
350
+ }
351
+ }
352
+ function validateDirectory(directory) {
353
+ if (!directory || !fs2.existsSync(directory)) {
354
+ console.log(chalk.red("\u274C Error: 'directory' must be a valid path."));
355
+ process.exit(1);
356
+ }
357
+ }
358
+ function tailFile(path, prefix, colorFn, lines) {
359
+ let position = 0;
360
+ let lastPartial = "";
361
+ if (!fs2.existsSync(path)) {
362
+ return () => {};
363
+ }
364
+ const fd = fs2.openSync(path, "r");
365
+ const printNewContent = () => {
366
+ try {
367
+ const stats = fs2.statSync(path);
368
+ if (stats.size <= position)
369
+ return;
370
+ const buffer = Buffer.alloc(stats.size - position);
371
+ fs2.readSync(fd, buffer, 0, buffer.length, position);
372
+ let content = buffer.toString();
373
+ content = lastPartial + content;
374
+ lastPartial = "";
375
+ const lineArray = content.split(/\r?\n/);
376
+ if (!content.endsWith(`
377
+ `)) {
378
+ lastPartial = lineArray.pop() || "";
379
+ }
380
+ lineArray.forEach((line) => {
381
+ if (line) {
382
+ console.log(colorFn(prefix + line));
383
+ }
384
+ });
385
+ position = stats.size;
386
+ } catch (e) {}
387
+ };
388
+ const watcher = fs2.watch(path, { persistent: true }, (event) => {
389
+ if (event === "change") {
390
+ printNewContent();
391
+ }
392
+ });
393
+ printNewContent();
394
+ return () => {
395
+ watcher.close();
396
+ try {
397
+ fs2.closeSync(fd);
398
+ } catch {}
399
+ };
400
+ }
401
+ var init_utils = __esm(() => {
402
+ init_platform();
403
+ });
404
+
329
405
  // src/db.ts
330
406
  var exports_db = {};
331
407
  __export(exports_db, {
@@ -441,111 +517,6 @@ var init_db = __esm(() => {
441
517
  });
442
518
  });
443
519
 
444
- // src/server.ts
445
- var exports_server = {};
446
- __export(exports_server, {
447
- startServer: () => startServer
448
- });
449
- import path2 from "path";
450
- async function startServer() {
451
- const { start } = await import("melina");
452
- const appDir = path2.join(import.meta.dir, "../dashboard/app");
453
- const explicitPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : undefined;
454
- await start({
455
- appDir,
456
- defaultTitle: "bgrun Dashboard - Process Manager",
457
- globalCss: path2.join(appDir, "globals.css"),
458
- ...explicitPort !== undefined && { port: explicitPort }
459
- });
460
- }
461
- var init_server = () => {};
462
-
463
- // src/index.ts
464
- import { parseArgs } from "util";
465
-
466
- // src/utils.ts
467
- init_platform();
468
- import * as fs2 from "fs";
469
- import chalk from "chalk";
470
- function parseEnvString(envString) {
471
- const env = {};
472
- envString.split(",").forEach((pair) => {
473
- const [key, value] = pair.split("=");
474
- if (key && value)
475
- env[key] = value;
476
- });
477
- return env;
478
- }
479
- function calculateRuntime(startTime) {
480
- const start = new Date(startTime).getTime();
481
- const now = new Date().getTime();
482
- const diffInMinutes = Math.floor((now - start) / (1000 * 60));
483
- return `${diffInMinutes} minutes`;
484
- }
485
- async function getVersion() {
486
- try {
487
- const { join } = await import("path");
488
- const pkgPath = join(import.meta.dir, "../package.json");
489
- const pkg = await Bun.file(pkgPath).json();
490
- return pkg.version || "0.0.0";
491
- } catch {
492
- return "0.0.0";
493
- }
494
- }
495
- function validateDirectory(directory) {
496
- if (!directory || !fs2.existsSync(directory)) {
497
- console.log(chalk.red("\u274C Error: 'directory' must be a valid path."));
498
- process.exit(1);
499
- }
500
- }
501
- function tailFile(path, prefix, colorFn, lines) {
502
- let position = 0;
503
- let lastPartial = "";
504
- if (!fs2.existsSync(path)) {
505
- return () => {};
506
- }
507
- const fd = fs2.openSync(path, "r");
508
- const printNewContent = () => {
509
- try {
510
- const stats = fs2.statSync(path);
511
- if (stats.size <= position)
512
- return;
513
- const buffer = Buffer.alloc(stats.size - position);
514
- fs2.readSync(fd, buffer, 0, buffer.length, position);
515
- let content = buffer.toString();
516
- content = lastPartial + content;
517
- lastPartial = "";
518
- const lineArray = content.split(/\r?\n/);
519
- if (!content.endsWith(`
520
- `)) {
521
- lastPartial = lineArray.pop() || "";
522
- }
523
- lineArray.forEach((line) => {
524
- if (line) {
525
- console.log(colorFn(prefix + line));
526
- }
527
- });
528
- position = stats.size;
529
- } catch (e) {}
530
- };
531
- const watcher = fs2.watch(path, { persistent: true }, (event) => {
532
- if (event === "change") {
533
- printNewContent();
534
- }
535
- });
536
- printNewContent();
537
- return () => {
538
- watcher.close();
539
- try {
540
- fs2.closeSync(fd);
541
- } catch {}
542
- };
543
- }
544
-
545
- // src/commands/run.ts
546
- init_db();
547
- init_platform();
548
-
549
520
  // src/logger.ts
550
521
  import boxen from "boxen";
551
522
  import chalk2 from "chalk";
@@ -570,6 +541,7 @@ function error(message) {
570
541
  }));
571
542
  process.exit(1);
572
543
  }
544
+ var init_logger = () => {};
573
545
 
574
546
  // src/config.ts
575
547
  function formatEnvKey(key) {
@@ -607,8 +579,6 @@ var {$: $2 } = globalThis.Bun;
607
579
  var {sleep: sleep2 } = globalThis.Bun;
608
580
  import { join as join2 } from "path";
609
581
  import { createMeasure as createMeasure2 } from "measure-fn";
610
- var homePath2 = getHomeDir();
611
- var run = createMeasure2("run");
612
582
  async function handleRun(options) {
613
583
  const { command, directory, env, name, configPath, force, fetch, stdout, stderr } = options;
614
584
  const existingProcess = name ? getProcess(name) : null;
@@ -662,6 +632,22 @@ async function handleRun(options) {
662
632
  }
663
633
  });
664
634
  }
635
+ const cmdToMatch = existingProcess.command;
636
+ if (cmdToMatch) {
637
+ await run.measure("Zombie sweep", async () => {
638
+ try {
639
+ const result = await $2`powershell -Command "Get-Process -Name bun -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '${cmdToMatch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").split(" ")[1] || cmdToMatch}' } | Select-Object -ExpandProperty Id"`.nothrow().text();
640
+ const zombiePids = result.split(`
641
+ `).map((l) => parseInt(l.trim())).filter((n) => !isNaN(n) && n > 0);
642
+ for (const zPid of zombiePids) {
643
+ await $2`taskkill /F /T /PID ${zPid}`.nothrow().quiet();
644
+ }
645
+ if (zombiePids.length > 0) {
646
+ announce(`\uD83E\uDDF9 Swept ${zombiePids.length} zombie process(es)`, "Zombie Cleanup");
647
+ }
648
+ } catch {}
649
+ });
650
+ }
665
651
  await retryDatabaseOperation(() => removeProcessByName(name));
666
652
  } else {
667
653
  if (!directory || !name || !command) {
@@ -729,6 +715,77 @@ async function handleRun(options) {
729
715
  }));
730
716
  announce(`${existingProcess ? "\uD83D\uDD04 Restarted" : "\uD83D\uDE80 Launched"} process "${name}" with PID ${actualPid}`, "Process Started");
731
717
  }
718
+ var homePath2, run;
719
+ var init_run = __esm(() => {
720
+ init_db();
721
+ init_platform();
722
+ init_logger();
723
+ init_utils();
724
+ homePath2 = getHomeDir();
725
+ run = createMeasure2("run");
726
+ });
727
+
728
+ // src/server.ts
729
+ var exports_server = {};
730
+ __export(exports_server, {
731
+ startServer: () => startServer
732
+ });
733
+ import path2 from "path";
734
+ async function startServer() {
735
+ const { start } = await import("melina");
736
+ const appDir = path2.join(import.meta.dir, "../dashboard/app");
737
+ const explicitPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : undefined;
738
+ await start({
739
+ appDir,
740
+ defaultTitle: "bgrun Dashboard - Process Manager",
741
+ globalCss: path2.join(appDir, "globals.css"),
742
+ ...explicitPort !== undefined && { port: explicitPort }
743
+ });
744
+ startGuard();
745
+ }
746
+ function startGuard() {
747
+ console.log(`[guard] \u2713 Built-in process guard started (checking every ${GUARD_INTERVAL_MS / 1000}s)`);
748
+ setInterval(async () => {
749
+ try {
750
+ const processes = getAllProcesses();
751
+ if (processes.length === 0)
752
+ return;
753
+ for (const proc of processes) {
754
+ if (GUARD_SKIP_NAMES.has(proc.name))
755
+ continue;
756
+ const alive = await isProcessRunning(proc.pid, proc.command);
757
+ if (!alive) {
758
+ console.log(`[guard] \u26A0 Process "${proc.name}" (PID ${proc.pid}) is dead, restarting...`);
759
+ try {
760
+ await handleRun({
761
+ action: "run",
762
+ name: proc.name,
763
+ force: true,
764
+ remoteName: ""
765
+ });
766
+ console.log(`[guard] \u2713 Restarted "${proc.name}"`);
767
+ } catch (err) {
768
+ console.error(`[guard] \u2717 Failed to restart "${proc.name}": ${err.message}`);
769
+ }
770
+ }
771
+ }
772
+ } catch (err) {
773
+ console.error(`[guard] Error in guard loop: ${err.message}`);
774
+ }
775
+ }, GUARD_INTERVAL_MS);
776
+ }
777
+ var GUARD_INTERVAL_MS = 30000, GUARD_SKIP_NAMES;
778
+ var init_server = __esm(() => {
779
+ init_db();
780
+ init_platform();
781
+ init_run();
782
+ GUARD_SKIP_NAMES = new Set(["bgr-dashboard"]);
783
+ });
784
+
785
+ // src/index.ts
786
+ init_utils();
787
+ init_run();
788
+ import { parseArgs } from "util";
732
789
 
733
790
  // src/commands/list.ts
734
791
  import chalk4 from "chalk";
@@ -898,6 +955,8 @@ function renderProcessTable(processes, options) {
898
955
 
899
956
  // src/commands/list.ts
900
957
  init_db();
958
+ init_logger();
959
+ init_utils();
901
960
  init_platform();
902
961
  function formatMemory(bytes) {
903
962
  if (bytes === 0)
@@ -974,6 +1033,7 @@ async function showAll(opts) {
974
1033
  // src/commands/cleanup.ts
975
1034
  init_db();
976
1035
  init_platform();
1036
+ init_logger();
977
1037
  import * as fs3 from "fs";
978
1038
  async function handleDelete(name) {
979
1039
  const process2 = getProcess(name);
@@ -1093,6 +1153,9 @@ async function handleDeleteAll() {
1093
1153
  // src/commands/watch.ts
1094
1154
  init_db();
1095
1155
  init_platform();
1156
+ init_logger();
1157
+ init_utils();
1158
+ init_run();
1096
1159
  import * as fs4 from "fs";
1097
1160
  import path from "path";
1098
1161
  import chalk5 from "chalk";
@@ -1282,6 +1345,7 @@ SIGINT received...`));
1282
1345
 
1283
1346
  // src/commands/logs.ts
1284
1347
  init_db();
1348
+ init_logger();
1285
1349
  init_platform();
1286
1350
  import chalk6 from "chalk";
1287
1351
  import * as fs5 from "fs";
@@ -1326,7 +1390,9 @@ async function showLogs(name, logType = "both", lines) {
1326
1390
  }
1327
1391
 
1328
1392
  // src/commands/details.ts
1393
+ init_logger();
1329
1394
  init_db();
1395
+ init_utils();
1330
1396
  init_platform();
1331
1397
  import chalk7 from "chalk";
1332
1398
  async function showDetails(name) {
@@ -1363,6 +1429,7 @@ ${Object.entries(envVars).map(([key, value]) => `${chalk7.cyan.bold(key)} = ${ch
1363
1429
  }
1364
1430
 
1365
1431
  // src/index.ts
1432
+ init_logger();
1366
1433
  init_platform();
1367
1434
  init_db();
1368
1435
  import dedent from "dedent";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.10.0",
3
+ "version": "3.10.1",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",
@@ -57,4 +57,4 @@
57
57
  "engines": {
58
58
  "bun": ">=1.0.0"
59
59
  }
60
- }
60
+ }
@@ -76,6 +76,24 @@ export async function handleRun(options: CommandOptions) {
76
76
  });
77
77
  }
78
78
 
79
+ // Zombie sweep: kill any remaining bun processes matching this command
80
+ // This catches orphaned children that survived taskkill when the parent shell exited
81
+ const cmdToMatch = existingProcess.command;
82
+ if (cmdToMatch) {
83
+ await run.measure('Zombie sweep', async () => {
84
+ try {
85
+ const result = await $`powershell -Command "Get-Process -Name bun -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '${cmdToMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').split(' ')[1] || cmdToMatch}' } | Select-Object -ExpandProperty Id"`.nothrow().text();
86
+ const zombiePids = result.split('\n').map((l: string) => parseInt(l.trim())).filter((n: number) => !isNaN(n) && n > 0);
87
+ for (const zPid of zombiePids) {
88
+ await $`taskkill /F /T /PID ${zPid}`.nothrow().quiet();
89
+ }
90
+ if (zombiePids.length > 0) {
91
+ announce(`🧹 Swept ${zombiePids.length} zombie process(es)`, 'Zombie Cleanup');
92
+ }
93
+ } catch { /* best effort */ }
94
+ });
95
+ }
96
+
79
97
  await retryDatabaseOperation(() =>
80
98
  removeProcessByName(name!)
81
99
  );
package/src/platform.ts CHANGED
@@ -112,51 +112,38 @@ async function getChildPids(pid: number): Promise<number[]> {
112
112
  */
113
113
  export async function terminateProcess(pid: number, force: boolean = false): Promise<void> {
114
114
  await plat.measure(`Terminate PID ${pid}`, async (m) => {
115
- // First, kill children
116
- const children = await m('Get children', () => getChildPids(pid)) ?? [];
117
-
118
- for (const childPid of children) {
119
- try {
120
- if (isWindows()) {
121
- if (force) {
122
- await $`taskkill /F /PID ${childPid}`.nothrow().quiet();
123
- } else {
124
- await $`taskkill /PID ${childPid}`.nothrow().quiet();
125
- }
126
- } else {
127
- const signal = force ? 'KILL' : 'TERM';
128
- await $`kill -${signal} ${childPid}`.nothrow();
115
+ try {
116
+ if (isWindows()) {
117
+ // Always use /T (tree kill) on Windows to kill the entire process tree
118
+ // This prevents grandchild processes from surviving as zombies
119
+ await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
120
+ } else {
121
+ // On Unix, kill children first, then parent
122
+ const children = await m('Get children', () => getChildPids(pid)) ?? [];
123
+ const signal = force ? 'KILL' : 'TERM';
124
+ for (const childPid of children) {
125
+ try {
126
+ await $`kill -${signal} ${childPid}`.nothrow();
127
+ } catch { /* already dead */ }
129
128
  }
130
- } catch {
131
- // Ignore errors for already-dead processes
132
- }
133
- }
134
-
135
- // Wait a bit for graceful shutdown
136
- await Bun.sleep(500);
137
-
138
- // Then kill the parent if still running
139
- if (await isProcessRunning(pid)) {
140
- try {
141
- if (isWindows()) {
142
- if (force) {
143
- await $`taskkill /F /PID ${pid}`.nothrow().quiet();
144
- } else {
145
- await $`taskkill /PID ${pid}`.nothrow().quiet();
146
- }
147
- } else {
148
- const signal = force ? 'KILL' : 'TERM';
129
+ await Bun.sleep(500);
130
+ if (await isProcessRunning(pid)) {
149
131
  await $`kill -${signal} ${pid}`.nothrow();
150
132
  }
151
- } catch {
152
- // Ignore errors
153
133
  }
134
+ } catch {
135
+ // Ignore errors for already-dead processes
154
136
  }
137
+
138
+ // Wait for process to fully exit
139
+ await Bun.sleep(300);
155
140
  });
156
141
  }
157
142
 
158
143
  /**
159
144
  * Check if a port is free by attempting to bind to it.
145
+ * On Windows, also checks whether the process holding the port is actually alive
146
+ * (zombie sockets from dead processes don't block new binds on 0.0.0.0).
160
147
  */
161
148
  export async function isPortFree(port: number): Promise<boolean> {
162
149
  try {
@@ -165,14 +152,22 @@ export async function isPortFree(port: number): Promise<boolean> {
165
152
  const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
166
153
  for (const line of result.split('\n')) {
167
154
  // Only match exact port (avoid :35560 matching :3556)
168
- const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING`));
169
- if (match) return false;
155
+ const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
156
+ if (match) {
157
+ const pid = parseInt(match[2]);
158
+ // If the PID behind the socket is dead, it's a zombie socket
159
+ // A new process can still bind to the port on 0.0.0.0
160
+ if (pid > 0 && await isProcessRunning(pid)) {
161
+ return false; // Real process holding the port
162
+ }
163
+ // else: zombie socket — consider port free
164
+ }
170
165
  }
171
166
  return true;
172
167
  } else {
173
168
  const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
174
169
  // If output has more than the header line, port is in use
175
- const lines = result.trim().split('\n').filter(l => l.trim());
170
+ const lines = result.trim().split('\n').filter((l: string) => l.trim());
176
171
  return lines.length <= 1;
177
172
  }
178
173
  } catch {
@@ -202,6 +197,8 @@ export async function waitForPortFree(port: number, timeoutMs: number = 5000): P
202
197
  /**
203
198
  * Kill processes using a specific port.
204
199
  * Force-kills all processes bound to the port and verifies they're gone.
200
+ * On Windows, filters out zombie PIDs (sockets orphaned by dead processes)
201
+ * since taskkill can't kill those — they require a reboot or TCP stack reset.
205
202
  */
206
203
  export async function killProcessOnPort(port: number): Promise<void> {
207
204
  try {
@@ -221,9 +218,14 @@ export async function killProcessOnPort(port: number): Promise<void> {
221
218
  }
222
219
 
223
220
  for (const pid of pids) {
224
- // Force kill with /F /T (tree kill to get children too)
225
- await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
226
- console.log(`Killed process ${pid} using port ${port}`);
221
+ // Check if the process actually exists before trying to kill it
222
+ // This avoids the zombie PID issue where sockets linger after process death
223
+ const alive = await isProcessRunning(pid);
224
+ if (alive) {
225
+ await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
226
+ console.log(`Killed process ${pid} using port ${port}`);
227
+ }
228
+ // else: zombie socket — PID no longer exists but socket lingers in kernel
227
229
  }
228
230
  } else {
229
231
  // On Unix, use lsof
package/src/server.ts CHANGED
@@ -1,14 +1,25 @@
1
1
  /**
2
- * BGR Dashboard Server
2
+ * BGR Dashboard Server + Built-in Process Guard
3
3
  *
4
4
  * Uses Melina.js to serve the dashboard app with file-based routing.
5
5
  * All API endpoints and page rendering are handled by the dashboard/app/ directory.
6
6
  *
7
+ * v3.0: Built-in guard loop — the dashboard now monitors ALL registered
8
+ * processes and auto-restarts any that die. This eliminates the need for
9
+ * external guard scripts. The dashboard itself survives because bgrun
10
+ * registers it as a managed process on launch.
11
+ *
7
12
  * Port selection is handled entirely by Melina:
8
13
  * - If BUN_PORT env var is set → uses that (explicit, will fail if busy)
9
14
  * - Otherwise → defaults to 3000, falls back to next available if busy
10
15
  */
11
16
  import path from 'path';
17
+ import { getAllProcesses, getProcess } from './db';
18
+ import { isProcessRunning } from './platform';
19
+ import { handleRun } from './commands/run';
20
+
21
+ const GUARD_INTERVAL_MS = 30_000; // Check every 30 seconds
22
+ const GUARD_SKIP_NAMES = new Set(['bgr-dashboard']); // Don't try to restart ourselves
12
23
 
13
24
  export async function startServer() {
14
25
  // Dynamic import to avoid melina's side-effect console.log at bundle load time
@@ -24,4 +35,55 @@ export async function startServer() {
24
35
  globalCss: path.join(appDir, 'globals.css'),
25
36
  ...(explicitPort !== undefined && { port: explicitPort }),
26
37
  });
38
+
39
+ // Start the built-in process guard
40
+ startGuard();
41
+ }
42
+
43
+ /**
44
+ * Built-in Process Guard
45
+ *
46
+ * Runs as a background loop inside the dashboard process.
47
+ * Every GUARD_INTERVAL_MS, it checks all registered bgrun processes.
48
+ * If any process is dead (PID not running), it auto-restarts it using
49
+ * handleRun with force=true (same as `bgrun --restart <name>`).
50
+ *
51
+ * Why here and not as a separate process?
52
+ * - No external dependency — the dashboard IS the guardian
53
+ * - If the dashboard is running, all processes are monitored
54
+ * - If the dashboard dies, `bgrun --dashboard` will restart everything
55
+ * - Zero configuration — just works
56
+ */
57
+ function startGuard() {
58
+ console.log(`[guard] ✓ Built-in process guard started (checking every ${GUARD_INTERVAL_MS / 1000}s)`);
59
+
60
+ setInterval(async () => {
61
+ try {
62
+ const processes = getAllProcesses();
63
+ if (processes.length === 0) return;
64
+
65
+ for (const proc of processes) {
66
+ // Skip the dashboard itself
67
+ if (GUARD_SKIP_NAMES.has(proc.name)) continue;
68
+
69
+ const alive = await isProcessRunning(proc.pid, proc.command);
70
+ if (!alive) {
71
+ console.log(`[guard] ⚠ Process "${proc.name}" (PID ${proc.pid}) is dead, restarting...`);
72
+ try {
73
+ await handleRun({
74
+ action: 'run',
75
+ name: proc.name,
76
+ force: true,
77
+ remoteName: '',
78
+ });
79
+ console.log(`[guard] ✓ Restarted "${proc.name}"`);
80
+ } catch (err: any) {
81
+ console.error(`[guard] ✗ Failed to restart "${proc.name}": ${err.message}`);
82
+ }
83
+ }
84
+ }
85
+ } catch (err: any) {
86
+ console.error(`[guard] Error in guard loop: ${err.message}`);
87
+ }
88
+ }, GUARD_INTERVAL_MS);
27
89
  }