git-watchtower 1.12.4 → 1.12.6

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.
@@ -2059,13 +2059,21 @@ async function pollGitChanges() {
2059
2059
  }
2060
2060
 
2061
2061
  function schedulePoll() {
2062
+ // Bail out if shutdown has started: both here (no new timer) and again
2063
+ // inside the timer callback after each await (the in-flight poll may
2064
+ // have started before shutdown() cleared pollIntervalId, and clearTimeout
2065
+ // on a timer whose callback is already executing is a no-op).
2066
+ if (isShuttingDown) return;
2062
2067
  pollIntervalId = setTimeout(async () => {
2068
+ if (isShuttingDown) return;
2063
2069
  await pollGitChanges();
2070
+ if (isShuttingDown) return;
2064
2071
  schedulePoll();
2065
2072
  }, store.get('adaptivePollInterval'));
2066
2073
  }
2067
2074
 
2068
2075
  function restartPolling() {
2076
+ if (isShuttingDown) return;
2069
2077
  if (pollIntervalId) {
2070
2078
  clearTimeout(pollIntervalId);
2071
2079
  }
@@ -3347,39 +3355,80 @@ function restartProcess() {
3347
3355
  // ============================================================================
3348
3356
 
3349
3357
  let isShuttingDown = false;
3358
+ let _resourcesCleaned = false;
3350
3359
 
3351
- async function shutdown() {
3352
- if (isShuttingDown) return;
3353
- isShuttingDown = true;
3360
+ /**
3361
+ * Idempotent, best-effort cleanup of every long-lived resource we own:
3362
+ * terminal state, timers, file watcher, live-reload SSE clients, the
3363
+ * user's dev-server child process, and the web-dashboard / coordinator
3364
+ * (which unlinks the lock file and IPC socket). Safe to call multiple
3365
+ * times and from any exit path (shutdown, uncaughtException, 'exit').
3366
+ *
3367
+ * Every step is wrapped in try/catch so a failure in one resource does
3368
+ * not prevent the rest from being cleaned up. Stays synchronous so it
3369
+ * can run inside an 'exit' handler where async callbacks won't execute.
3370
+ */
3371
+ function cleanupResources() {
3372
+ if (_resourcesCleaned) return;
3373
+ _resourcesCleaned = true;
3374
+
3375
+ // Restore terminal first so the user sees a clean prompt even if a
3376
+ // later step throws.
3377
+ try { write(ansi.showCursor); } catch (_) { /* ignore */ }
3378
+ try { write(ansi.restoreScreen); } catch (_) { /* ignore */ }
3379
+ try { restoreTerminalTitle(); } catch (_) { /* ignore */ }
3380
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) { /* ignore */ }
3381
+ try { process.stdin.pause(); } catch (_) { /* ignore */ }
3354
3382
 
3355
- // Restore terminal
3356
- write(ansi.showCursor);
3357
- write(ansi.restoreScreen);
3358
- restoreTerminalTitle();
3383
+ if (pollIntervalId) {
3384
+ try { clearTimeout(pollIntervalId); } catch (_) { /* ignore */ }
3385
+ pollIntervalId = null;
3386
+ }
3359
3387
 
3360
- if (process.stdin.isTTY) {
3361
- process.stdin.setRawMode(false);
3388
+ if (periodicUpdateCheck) {
3389
+ try { periodicUpdateCheck.stop(); } catch (_) { /* ignore */ }
3362
3390
  }
3363
- process.stdin.pause();
3364
3391
 
3365
- if (fileWatcher) fileWatcher.close();
3366
- if (pollIntervalId) clearTimeout(pollIntervalId);
3392
+ if (fileWatcher) {
3393
+ try { fileWatcher.close(); } catch (_) { /* ignore */ }
3394
+ fileWatcher = null;
3395
+ }
3396
+
3397
+ // Live-reload SSE clients (static mode)
3398
+ if (SERVER_MODE === 'static') {
3399
+ try {
3400
+ clients.forEach((client) => {
3401
+ try { client.end(); } catch (_) { /* ignore */ }
3402
+ });
3403
+ clients.clear();
3404
+ } catch (_) { /* ignore */ }
3405
+ }
3367
3406
 
3368
- // Stop server based on mode
3407
+ // User's dev-server process (command mode)
3369
3408
  if (SERVER_MODE === 'command') {
3370
- stopServerProcess();
3371
- } else if (SERVER_MODE === 'static') {
3372
- clients.forEach(client => client.end());
3373
- clients.clear();
3409
+ try { stopServerProcess(); } catch (_) { /* ignore */ }
3410
+ }
3411
+
3412
+ // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
3413
+ try { stopWebDashboard(); } catch (_) { /* ignore */ }
3414
+ }
3415
+
3416
+ async function shutdown() {
3417
+ if (isShuttingDown) return;
3418
+ isShuttingDown = true;
3419
+
3420
+ cleanupResources();
3374
3421
 
3375
- const serverClosePromise = new Promise(resolve => server.close(resolve));
3376
- const timeoutPromise = new Promise(resolve => setTimeout(resolve, SERVER_CLOSE_TIMEOUT_MS));
3422
+ // For the static HTTP server, give in-flight connections a brief
3423
+ // grace period to drain. cleanupResources ended the SSE clients; this
3424
+ // races server.close against SERVER_CLOSE_TIMEOUT_MS so we never hang
3425
+ // forever on a stuck browser.
3426
+ if (SERVER_MODE === 'static' && server) {
3427
+ const serverClosePromise = new Promise((resolve) => server.close(resolve));
3428
+ const timeoutPromise = new Promise((resolve) => setTimeout(resolve, SERVER_CLOSE_TIMEOUT_MS));
3377
3429
  await Promise.race([serverClosePromise, timeoutPromise]);
3378
3430
  }
3379
3431
 
3380
- // Stop web dashboard and coordinator
3381
- stopWebDashboard();
3382
-
3383
3432
  // Flush telemetry
3384
3433
  telemetry.capture('session_ended', {
3385
3434
  duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
@@ -3394,19 +3443,23 @@ async function shutdown() {
3394
3443
 
3395
3444
  process.on('SIGINT', shutdown);
3396
3445
  process.on('SIGTERM', shutdown);
3397
- // Clean up long-lived timers on exit, regardless of which code path got us
3398
- // here (normal exit, uncaught exception, early failure in start()).
3446
+ // Belt-and-suspenders: if we exit via a path that didn't call
3447
+ // cleanupResources (e.g. a hard crash in startup before handlers were
3448
+ // registered), still do synchronous best-effort cleanup.
3399
3449
  process.on('exit', () => {
3400
- if (periodicUpdateCheck) periodicUpdateCheck.stop();
3450
+ cleanupResources();
3401
3451
  });
3402
3452
  process.on('uncaughtException', async (err) => {
3403
- telemetry.captureError(err);
3404
- write(ansi.showCursor);
3405
- write(ansi.restoreScreen);
3406
- restoreTerminalTitle();
3407
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
3453
+ isShuttingDown = true;
3454
+
3455
+ // Synchronous teardown first — so the coordinator socket, lock file,
3456
+ // dev-server child process group, and SSE clients are released even
3457
+ // if telemetry shutdown hangs or throws.
3458
+ cleanupResources();
3459
+
3460
+ try { telemetry.captureError(err); } catch (_) { /* ignore */ }
3408
3461
  console.error('Uncaught exception:', err);
3409
- await telemetry.shutdown();
3462
+ try { await telemetry.shutdown(); } catch (_) { /* ignore */ }
3410
3463
  process.exit(1);
3411
3464
  });
3412
3465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.12.4",
3
+ "version": "1.12.6",
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": {