git-watchtower 1.14.7 → 1.14.9

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.
Files changed (2) hide show
  1. package/bin/git-watchtower.js +108 -32
  2. package/package.json +1 -1
@@ -748,42 +748,63 @@ function startServerProcess() {
748
748
  }
749
749
  }
750
750
 
751
+ /**
752
+ * Stop the user's dev-server child process and its entire process group.
753
+ *
754
+ * Returns a Promise that resolves when the process has actually exited (or a
755
+ * hard cap elapses). Callers on async exit paths — shutdown(), uncaughtException,
756
+ * unhandledRejection — must await this so the SIGKILL escalation timer fires
757
+ * before process.exit() drops it. Synchronous callers (startServerProcess's
758
+ * restart branch, the 'exit' fallback) can fire-and-forget; best effort only.
759
+ */
751
760
  function stopServerProcess() {
752
- if (!serverProcess) return;
761
+ if (!serverProcess) return Promise.resolve();
753
762
 
754
763
  addLog('Stopping server...', 'update');
755
764
 
756
765
  // Capture reference before nulling — needed for deferred SIGKILL
757
766
  const proc = serverProcess;
767
+ serverProcess = null;
768
+ store.setState({ serverRunning: false });
769
+
770
+ // Resolves when the child actually exits
771
+ const closedPromise = proc.exitCode !== null
772
+ ? Promise.resolve()
773
+ : new Promise((resolve) => { proc.once('close', () => resolve()); });
774
+
775
+ // Hard cap so callers can never hang forever if 'close' never fires
776
+ // (e.g. stdio pipe torn down, handle state corrupted). Grace period plus
777
+ // a small buffer for SIGKILL delivery + OS reap of the process group.
778
+ const hardCap = new Promise((resolve) => setTimeout(resolve, FORCE_KILL_GRACE_MS + 500));
758
779
 
759
- // Try graceful shutdown first
760
780
  if (process.platform === 'win32') {
781
+ // taskkill /f /t is already forceful and recursive; close should follow shortly
761
782
  spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
762
- } else {
763
- // Kill the entire process group (negative PID) so that
764
- // grandchildren (e.g. npm -> node -> vite) are also terminated.
783
+ return Promise.race([closedPromise, hardCap]);
784
+ }
785
+
786
+ // Kill the entire process group (negative PID) so that grandchildren
787
+ // (e.g. npm -> node -> vite) are also terminated.
788
+ try {
789
+ process.kill(-proc.pid, 'SIGTERM');
790
+ } catch (e) {
791
+ // Process group may already be dead
792
+ }
793
+ // Force kill after grace period if process hasn't exited
794
+ const forceKillTimeout = setTimeout(() => {
765
795
  try {
766
- process.kill(-proc.pid, 'SIGTERM');
796
+ process.kill(-proc.pid, 'SIGKILL');
767
797
  } catch (e) {
768
798
  // Process group may already be dead
769
799
  }
770
- // Force kill after grace period if process hasn't exited
771
- const forceKillTimeout = setTimeout(() => {
772
- try {
773
- process.kill(-proc.pid, 'SIGKILL');
774
- } catch (e) {
775
- // Process group may already be dead
776
- }
777
- }, FORCE_KILL_GRACE_MS);
800
+ }, FORCE_KILL_GRACE_MS);
778
801
 
779
- // Clear the force-kill timer if the process exits cleanly
780
- proc.once('close', () => {
781
- clearTimeout(forceKillTimeout);
782
- });
783
- }
802
+ // Clear the force-kill timer if the process exits cleanly
803
+ proc.once('close', () => {
804
+ clearTimeout(forceKillTimeout);
805
+ });
784
806
 
785
- serverProcess = null;
786
- store.setState({ serverRunning: false });
807
+ return Promise.race([closedPromise, hardCap]);
787
808
  }
788
809
 
789
810
  function restartServerProcess() {
@@ -3390,9 +3411,34 @@ function restartProcess() {
3390
3411
  restoreTerminalTitle();
3391
3412
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
3392
3413
 
3393
- // Stop server, watcher, polling
3394
- if (fileWatcher) fileWatcher.close();
3395
- if (pollIntervalId) clearTimeout(pollIntervalId);
3414
+ // Silence the parent before the replacement takes over the TTY. The parent
3415
+ // stays alive waiting on child.on('close') to forward the exit code, and
3416
+ // stdio is inherited — so any stray listener here will race with the child
3417
+ // (keystrokes consumed twice, render() drawing frames on top of the child's
3418
+ // UI, Ctrl+C intercepted by both, etc.).
3419
+ try { process.stdin.removeAllListeners('data'); } catch (_) { /* stdin may be detached */ }
3420
+ try { process.stdin.pause(); } catch (_) { /* stdin may already be paused */ }
3421
+ try { process.stdout.removeAllListeners('resize'); } catch (_) { /* stdout may be detached */ }
3422
+ try { process.removeAllListeners('SIGWINCH'); } catch (_) { /* no SIGWINCH handler registered */ }
3423
+ try { process.removeAllListeners('SIGINT'); } catch (_) { /* no SIGINT handler registered */ }
3424
+ try { process.removeAllListeners('SIGTERM'); } catch (_) { /* no SIGTERM handler registered */ }
3425
+
3426
+ // Stop every scheduler that can trigger a render while we're waiting on the
3427
+ // child. periodicUpdateCheck in particular will fire render() on completion
3428
+ // and would draw over the replacement's frames.
3429
+ if (pollIntervalId) {
3430
+ try { clearTimeout(pollIntervalId); } catch (_) { /* defensive */ }
3431
+ pollIntervalId = null;
3432
+ }
3433
+ if (periodicUpdateCheck) {
3434
+ try { periodicUpdateCheck.stop(); } catch (_) { /* interval may already be cleared */ }
3435
+ }
3436
+ if (fileWatcher) {
3437
+ try { fileWatcher.close(); } catch (_) { /* watcher may already be closed */ }
3438
+ fileWatcher = null;
3439
+ }
3440
+
3441
+ // Stop server, SSE clients, web dashboard
3396
3442
  if (SERVER_MODE === 'command') stopServerProcess();
3397
3443
  else if (SERVER_MODE === 'static') {
3398
3444
  clients.forEach(client => client.end());
@@ -3408,6 +3454,12 @@ function restartProcess() {
3408
3454
  monitorLockFile = null;
3409
3455
  }
3410
3456
 
3457
+ // The parent's 'exit' handler (process.on('exit', cleanupResources)) writes
3458
+ // ANSI escapes — showCursor / restoreScreen — to the shared TTY. Once the
3459
+ // child owns the screen those writes would corrupt its UI on parent exit,
3460
+ // so mark cleanup as already done.
3461
+ _resourcesCleaned = true;
3462
+
3411
3463
  console.log('\n♻ Restarting git-watchtower...\n');
3412
3464
 
3413
3465
  const { spawn: spawnChild } = require('child_process');
@@ -3441,11 +3493,16 @@ let monitorLockFile = null;
3441
3493
  * times and from any exit path (shutdown, uncaughtException, 'exit').
3442
3494
  *
3443
3495
  * Every step is wrapped in try/catch so a failure in one resource does
3444
- * not prevent the rest from being cleaned up. Stays synchronous so it
3445
- * can run inside an 'exit' handler where async callbacks won't execute.
3496
+ * not prevent the rest from being cleaned up. The body is synchronous
3497
+ * so it still runs inside an 'exit' handler where async callbacks won't
3498
+ * execute; the dev-server close promise is bubbled up so async callers
3499
+ * can await it before process.exit().
3500
+ *
3501
+ * @returns {Promise<void>} resolves when the dev-server child has exited
3502
+ * (or a hard cap elapses). Synchronous callers may ignore it.
3446
3503
  */
3447
3504
  function cleanupResources() {
3448
- if (_resourcesCleaned) return;
3505
+ if (_resourcesCleaned) return Promise.resolve();
3449
3506
  _resourcesCleaned = true;
3450
3507
 
3451
3508
  // Restore terminal first so the user sees a clean prompt even if a
@@ -3480,9 +3537,14 @@ function cleanupResources() {
3480
3537
  } catch (_) { /* clients set may have mutated mid-iteration during shutdown */ }
3481
3538
  }
3482
3539
 
3483
- // User's dev-server process (command mode)
3540
+ // User's dev-server process (command mode). Capture the close promise so
3541
+ // async callers (shutdown/uncaughtException/unhandledRejection) can await
3542
+ // it before process.exit() — otherwise the SIGKILL escalation timer gets
3543
+ // dropped and a dev server that ignored SIGTERM survives as a detached
3544
+ // orphan (it was spawned in its own process group on Unix).
3545
+ let serverStopPromise = Promise.resolve();
3484
3546
  if (SERVER_MODE === 'command') {
3485
- try { stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
3547
+ try { serverStopPromise = stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
3486
3548
  }
3487
3549
 
3488
3550
  // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
@@ -3494,13 +3556,15 @@ function cleanupResources() {
3494
3556
  try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have been unlinked externally */ }
3495
3557
  monitorLockFile = null;
3496
3558
  }
3559
+
3560
+ return serverStopPromise;
3497
3561
  }
3498
3562
 
3499
3563
  async function shutdown() {
3500
3564
  if (isShuttingDown) return;
3501
3565
  isShuttingDown = true;
3502
3566
 
3503
- cleanupResources();
3567
+ const serverStopPromise = cleanupResources();
3504
3568
 
3505
3569
  // For the static HTTP server, give in-flight connections a brief
3506
3570
  // grace period to drain. cleanupResources ended the SSE clients; this
@@ -3512,6 +3576,12 @@ async function shutdown() {
3512
3576
  await Promise.race([serverClosePromise, timeoutPromise]);
3513
3577
  }
3514
3578
 
3579
+ // Wait for the user's dev-server child to actually exit (SIGTERM +
3580
+ // FORCE_KILL_GRACE_MS SIGKILL escalation inside stopServerProcess).
3581
+ // Without this the escalation timer gets dropped by process.exit() below
3582
+ // and a dev server that ignored SIGTERM survives as a detached orphan.
3583
+ try { await serverStopPromise; } catch (_) { /* best-effort */ }
3584
+
3515
3585
  // Flush telemetry
3516
3586
  telemetry.capture('session_ended', {
3517
3587
  duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
@@ -3538,11 +3608,14 @@ process.on('uncaughtException', async (err) => {
3538
3608
  // Synchronous teardown first — so the coordinator socket, lock file,
3539
3609
  // dev-server child process group, and SSE clients are released even
3540
3610
  // if telemetry shutdown hangs or throws.
3541
- cleanupResources();
3611
+ const serverStopPromise = cleanupResources();
3542
3612
 
3543
3613
  try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3544
3614
  console.error('Uncaught exception:', err);
3545
3615
  try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3616
+ // Wait for the dev-server SIGKILL escalation before exiting — otherwise
3617
+ // process.exit() drops the pending timer and a stuck server orphans.
3618
+ try { await serverStopPromise; } catch (_) { /* best-effort */ }
3546
3619
  process.exit(1);
3547
3620
  });
3548
3621
 
@@ -3554,12 +3627,15 @@ process.on('uncaughtException', async (err) => {
3554
3627
  process.on('unhandledRejection', async (reason) => {
3555
3628
  isShuttingDown = true;
3556
3629
 
3557
- cleanupResources();
3630
+ const serverStopPromise = cleanupResources();
3558
3631
 
3559
3632
  const err = reason instanceof Error ? reason : new Error(String(reason));
3560
3633
  try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3561
3634
  console.error('Unhandled rejection:', reason);
3562
3635
  try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3636
+ // Wait for the dev-server SIGKILL escalation before exiting — otherwise
3637
+ // process.exit() drops the pending timer and a stuck server orphans.
3638
+ try { await serverStopPromise; } catch (_) { /* best-effort */ }
3563
3639
  process.exit(1);
3564
3640
  });
3565
3641
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.14.7",
3
+ "version": "1.14.9",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {