git-watchtower 1.14.8 → 1.14.10

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.
@@ -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() {
@@ -3472,11 +3493,16 @@ let monitorLockFile = null;
3472
3493
  * times and from any exit path (shutdown, uncaughtException, 'exit').
3473
3494
  *
3474
3495
  * Every step is wrapped in try/catch so a failure in one resource does
3475
- * not prevent the rest from being cleaned up. Stays synchronous so it
3476
- * 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.
3477
3503
  */
3478
3504
  function cleanupResources() {
3479
- if (_resourcesCleaned) return;
3505
+ if (_resourcesCleaned) return Promise.resolve();
3480
3506
  _resourcesCleaned = true;
3481
3507
 
3482
3508
  // Restore terminal first so the user sees a clean prompt even if a
@@ -3511,9 +3537,14 @@ function cleanupResources() {
3511
3537
  } catch (_) { /* clients set may have mutated mid-iteration during shutdown */ }
3512
3538
  }
3513
3539
 
3514
- // 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();
3515
3546
  if (SERVER_MODE === 'command') {
3516
- try { stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
3547
+ try { serverStopPromise = stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
3517
3548
  }
3518
3549
 
3519
3550
  // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
@@ -3525,13 +3556,15 @@ function cleanupResources() {
3525
3556
  try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have been unlinked externally */ }
3526
3557
  monitorLockFile = null;
3527
3558
  }
3559
+
3560
+ return serverStopPromise;
3528
3561
  }
3529
3562
 
3530
3563
  async function shutdown() {
3531
3564
  if (isShuttingDown) return;
3532
3565
  isShuttingDown = true;
3533
3566
 
3534
- cleanupResources();
3567
+ const serverStopPromise = cleanupResources();
3535
3568
 
3536
3569
  // For the static HTTP server, give in-flight connections a brief
3537
3570
  // grace period to drain. cleanupResources ended the SSE clients; this
@@ -3543,6 +3576,12 @@ async function shutdown() {
3543
3576
  await Promise.race([serverClosePromise, timeoutPromise]);
3544
3577
  }
3545
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
+
3546
3585
  // Flush telemetry
3547
3586
  telemetry.capture('session_ended', {
3548
3587
  duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
@@ -3569,11 +3608,14 @@ process.on('uncaughtException', async (err) => {
3569
3608
  // Synchronous teardown first — so the coordinator socket, lock file,
3570
3609
  // dev-server child process group, and SSE clients are released even
3571
3610
  // if telemetry shutdown hangs or throws.
3572
- cleanupResources();
3611
+ const serverStopPromise = cleanupResources();
3573
3612
 
3574
3613
  try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3575
3614
  console.error('Uncaught exception:', err);
3576
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 */ }
3577
3619
  process.exit(1);
3578
3620
  });
3579
3621
 
@@ -3585,12 +3627,15 @@ process.on('uncaughtException', async (err) => {
3585
3627
  process.on('unhandledRejection', async (reason) => {
3586
3628
  isShuttingDown = true;
3587
3629
 
3588
- cleanupResources();
3630
+ const serverStopPromise = cleanupResources();
3589
3631
 
3590
3632
  const err = reason instanceof Error ? reason : new Error(String(reason));
3591
3633
  try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3592
3634
  console.error('Unhandled rejection:', reason);
3593
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 */ }
3594
3639
  process.exit(1);
3595
3640
  });
3596
3641
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.14.8",
3
+ "version": "1.14.10",
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": {
package/src/server/web.js CHANGED
@@ -63,6 +63,9 @@ class WebDashboardServer {
63
63
  this.pushInterval = null;
64
64
  this.lastPushedJson = '';
65
65
 
66
+ /** @type {Set<import('net').Socket>} Raw TCP sockets, tracked so stop() can force-close them */
67
+ this._sockets = new Set();
68
+
66
69
  // Multi-project support (populated by coordinator)
67
70
  /** @type {Map<string, Object>} */
68
71
  this.projects = new Map();
@@ -239,6 +242,14 @@ class WebDashboardServer {
239
242
  this._handleRequest(req, res);
240
243
  });
241
244
 
245
+ // Track raw TCP sockets so stop() can force-destroy lingering
246
+ // connections (long-lived SSE, paused browsers, proxied clients)
247
+ // instead of waiting for a full TCP FIN_WAIT2 timeout.
248
+ this.server.on('connection', (/** @type {import('net').Socket} */ socket) => {
249
+ this._sockets.add(socket);
250
+ socket.once('close', () => this._sockets.delete(socket));
251
+ });
252
+
242
253
  this.server.on('error', (/** @type {Error & {code?: string}} */ err) => {
243
254
  if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
244
255
  retries++;
@@ -270,7 +281,7 @@ class WebDashboardServer {
270
281
  this.pushInterval = null;
271
282
  }
272
283
 
273
- // Close all SSE connections
284
+ // End SSE response streams gracefully (sends FIN).
274
285
  for (const client of this.clients) {
275
286
  try { client.end(); } catch (e) { /* SSE client may already be disconnected */ }
276
287
  }
@@ -278,6 +289,16 @@ class WebDashboardServer {
278
289
 
279
290
  if (this.server) {
280
291
  this.server.close();
292
+
293
+ // Force-destroy any TCP sockets that didn't close after the
294
+ // graceful end() above. Without this, a paused browser, a
295
+ // suspended tab, or a slow proxy can pin server.close() for the
296
+ // full TCP FIN_WAIT2 timeout (typically 60 s), delaying shutdown.
297
+ for (const socket of this._sockets) {
298
+ try { socket.destroy(); } catch (e) { /* ignore */ }
299
+ }
300
+ this._sockets.clear();
301
+
281
302
  this.server = null;
282
303
  }
283
304
  }