git-watchtower 1.14.9 → 1.14.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.14.9",
3
+ "version": "1.14.11",
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": {
@@ -34,6 +34,18 @@ const WATCHTOWER_DIR = path.join(os.homedir(), '.watchtower');
34
34
  */
35
35
  const MAX_IPC_BUFFER = 1024 * 1024;
36
36
 
37
+ /**
38
+ * How long a worker waits for a `registered` ACK from the coordinator
39
+ * after the TCP connection completes and the `register` frame is written.
40
+ *
41
+ * The coordinator responds synchronously inside _handleWorkerMessage, so
42
+ * anything past a couple of seconds indicates the coordinator is wedged,
43
+ * crashed between accept() and the handler, or rejected the registration
44
+ * (duplicate ID). Callers treat timeout as a connect failure and either
45
+ * retry or fall back to reclaiming the coordinator role.
46
+ */
47
+ const REGISTRATION_ACK_TIMEOUT_MS = 2000;
48
+
37
49
  /**
38
50
  * Lock file path
39
51
  */
@@ -492,24 +504,73 @@ class Worker {
492
504
  this.onCommand = null;
493
505
  this._connected = false;
494
506
  this._buffer = '';
507
+ /** @type {((msg: Object) => void) | null} */
508
+ this._onRegistered = null;
495
509
  }
496
510
 
497
511
  /**
498
- * Connect to the coordinator.
512
+ * Connect to the coordinator and complete the register handshake.
513
+ *
514
+ * Resolves only once the coordinator has replied with a matching
515
+ * `registered` ACK. Without this, the worker would start pushing state
516
+ * the instant the TCP connection opens, even if the coordinator crashed
517
+ * or rejected the registration (e.g. duplicate ID, stale socket) before
518
+ * processing the `register` frame. In that scenario the pushes would
519
+ * silently disappear until something else forced a reconnect.
520
+ *
521
+ * Rejects if the socket errors, the peer closes before ACK, or the ACK
522
+ * doesn't arrive within REGISTRATION_ACK_TIMEOUT_MS.
523
+ *
499
524
  * @returns {Promise<void>}
500
525
  */
501
526
  connect() {
502
527
  return new Promise((resolve, reject) => {
528
+ let settled = false;
529
+ let ackTimer = null;
530
+
531
+ const cleanupHandshake = () => {
532
+ if (ackTimer) {
533
+ clearTimeout(ackTimer);
534
+ ackTimer = null;
535
+ }
536
+ this._onRegistered = null;
537
+ };
538
+
539
+ const settleResolve = () => {
540
+ if (settled) return;
541
+ settled = true;
542
+ cleanupHandshake();
543
+ resolve();
544
+ };
545
+
546
+ const settleReject = (err) => {
547
+ if (settled) return;
548
+ settled = true;
549
+ cleanupHandshake();
550
+ this._connected = false;
551
+ try { if (this.socket) this.socket.destroy(); } catch (_) { /* socket may already be torn down */ }
552
+ reject(err);
553
+ };
554
+
555
+ // Installed before the register frame is sent so an immediate reply
556
+ // can't race the assignment.
557
+ this._onRegistered = (msg) => {
558
+ if (msg && msg.id === this.id) settleResolve();
559
+ };
560
+
503
561
  this.socket = net.createConnection(this.socketPath, () => {
504
562
  this._connected = true;
505
- // Register with coordinator
563
+ // Register with coordinator. Don't resolve yet — wait for the ACK.
506
564
  this._send({
507
565
  type: 'register',
508
566
  id: this.id,
509
567
  projectPath: this.projectPath,
510
568
  projectName: this.projectName,
511
569
  });
512
- resolve();
570
+ ackTimer = setTimeout(() => {
571
+ settleReject(new Error('coordinator registration ACK timed out'));
572
+ }, REGISTRATION_ACK_TIMEOUT_MS);
573
+ if (ackTimer.unref) ackTimer.unref();
513
574
  });
514
575
 
515
576
  this.socket.on('data', (data) => {
@@ -538,11 +599,12 @@ class Worker {
538
599
 
539
600
  this.socket.on('error', (err) => {
540
601
  this._connected = false;
541
- reject(err);
602
+ settleReject(err);
542
603
  });
543
604
 
544
605
  this.socket.on('close', () => {
545
606
  this._connected = false;
607
+ settleReject(new Error('coordinator socket closed before registration'));
546
608
  });
547
609
  });
548
610
  }
@@ -597,6 +659,10 @@ class Worker {
597
659
  * @private
598
660
  */
599
661
  _handleMessage(msg) {
662
+ if (msg.type === 'registered' && this._onRegistered) {
663
+ this._onRegistered(msg);
664
+ return;
665
+ }
600
666
  if (msg.type === 'command' && this.onCommand) {
601
667
  this.onCommand(msg.action, msg.payload);
602
668
  }
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
  }