git-watchtower 2.3.9 → 2.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server/web.js +80 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.3.9",
3
+ "version": "2.3.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
@@ -36,6 +36,16 @@ const MAX_PORT_RETRIES = 20;
36
36
  */
37
37
  const SSE_KEEPALIVE_INTERVAL = 15000;
38
38
 
39
+ /**
40
+ * Maximum number of consecutive periodic-state pushes a stalled client (one
41
+ * whose internal write buffer hasn't drained) is allowed to miss before we
42
+ * force-close it. At a 500 ms STATE_PUSH_INTERVAL this gives roughly 30 s
43
+ * of grace before eviction — enough for a paused tab to wake up, short
44
+ * enough that a permanently dead socket can't pin memory or keep
45
+ * `clientCount` misleadingly high.
46
+ */
47
+ const MAX_STALLED_PUSHES = 60;
48
+
39
49
  /**
40
50
  * Actions the web dashboard is allowed to POST to /api/action. Every entry
41
51
  * here MUST be matched by a `case` in `handleWebAction` in bin/git-watchtower.js
@@ -86,6 +96,15 @@ class WebDashboardServer {
86
96
  /** @type {Set<import('net').Socket>} Raw TCP sockets, tracked so stop() can force-close them */
87
97
  this._sockets = new Set();
88
98
 
99
+ /**
100
+ * Per-client count of consecutive state pushes skipped because the
101
+ * client's internal buffer hasn't drained. Reset to 0 on a successful
102
+ * write; a paused tab whose count crosses MAX_STALLED_PUSHES gets
103
+ * evicted to bound memory.
104
+ * @type {WeakMap<import('http').ServerResponse, number>}
105
+ */
106
+ this._stalledPushes = new WeakMap();
107
+
89
108
  // Multi-project support (populated by coordinator)
90
109
  /** @type {Map<string, Object>} */
91
110
  this.projects = new Map();
@@ -341,11 +360,9 @@ class WebDashboardServer {
341
360
  */
342
361
  flash(text, type) {
343
362
  const data = JSON.stringify({ text, type: type || 'info' });
363
+ const message = 'event: flash\ndata: ' + data + '\n\n';
344
364
  for (const client of this.clients) {
345
- try {
346
- client.write('event: flash\n');
347
- client.write('data: ' + data + '\n\n');
348
- } catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
365
+ this._writeToClient(client, message);
349
366
  }
350
367
  }
351
368
 
@@ -355,11 +372,9 @@ class WebDashboardServer {
355
372
  */
356
373
  sendPreview(data) {
357
374
  const json = JSON.stringify(data);
375
+ const message = 'event: preview\ndata: ' + json + '\n\n';
358
376
  for (const client of this.clients) {
359
- try {
360
- client.write('event: preview\n');
361
- client.write('data: ' + json + '\n\n');
362
- } catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
377
+ this._writeToClient(client, message);
363
378
  }
364
379
  }
365
380
 
@@ -369,14 +384,51 @@ class WebDashboardServer {
369
384
  */
370
385
  sendActionResult(result) {
371
386
  const json = JSON.stringify(result);
387
+ const message = 'event: actionResult\ndata: ' + json + '\n\n';
372
388
  for (const client of this.clients) {
373
- try {
374
- client.write('event: actionResult\n');
375
- client.write('data: ' + json + '\n\n');
376
- } catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
389
+ this._writeToClient(client, message);
377
390
  }
378
391
  }
379
392
 
393
+ /**
394
+ * Write a frame to a single SSE client, respecting backpressure.
395
+ * Skips the write when the client's internal buffer hasn't drained
396
+ * (`writableNeedDrain`), so a paused tab can't cause unbounded
397
+ * server-side queueing. Drops the client on synchronous write errors
398
+ * (most often "write after end" on an abruptly-closed socket).
399
+ *
400
+ * @param {import('http').ServerResponse} client
401
+ * @param {string} message - Pre-formatted SSE frame (event + data + \n\n)
402
+ * @returns {boolean} `true` if the write was issued, `false` if skipped or failed
403
+ * @private
404
+ */
405
+ _writeToClient(client, message) {
406
+ if (client.writableNeedDrain) {
407
+ // Client hasn't acknowledged earlier writes — skip this frame so
408
+ // backpressure doesn't accumulate. The next push retries naturally.
409
+ return false;
410
+ }
411
+ try {
412
+ client.write(message);
413
+ this._stalledPushes.set(client, 0);
414
+ return true;
415
+ } catch (e) {
416
+ this._dropClient(client);
417
+ return false;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Force-close and forget a client. Idempotent.
423
+ * @param {import('http').ServerResponse} client
424
+ * @private
425
+ */
426
+ _dropClient(client) {
427
+ try { client.end(); } catch (_) { /* already torn down */ }
428
+ this.clients.delete(client);
429
+ this._stalledPushes.delete(client);
430
+ }
431
+
380
432
  /**
381
433
  * Get the number of connected web clients.
382
434
  * @returns {number}
@@ -586,8 +638,22 @@ class WebDashboardServer {
586
638
 
587
639
  const message = 'event: state\ndata: ' + json + '\n\n';
588
640
  for (const client of this.clients) {
641
+ if (client.writableNeedDrain) {
642
+ // Backpressure: client's internal buffer hasn't drained from a
643
+ // previous write. Track how many consecutive periodic pushes
644
+ // we've skipped so a paused/dead tab can't accumulate stalled
645
+ // writes forever. The non-periodic emitters (flash/preview/
646
+ // actionResult) skip silently — only `_pushState` evicts.
647
+ const n = (this._stalledPushes.get(client) || 0) + 1;
648
+ this._stalledPushes.set(client, n);
649
+ if (n >= MAX_STALLED_PUSHES) {
650
+ this._dropClient(client);
651
+ }
652
+ continue;
653
+ }
589
654
  try {
590
655
  client.write(message);
656
+ this._stalledPushes.set(client, 0);
591
657
  } catch (e) {
592
658
  // Write failed — proactively prune the dead client instead of
593
659
  // waiting for req.on('close') to fire. On abrupt socket resets
@@ -595,8 +661,7 @@ class WebDashboardServer {
595
661
  // hit the same failed write, accumulating exception work and
596
662
  // keeping clientCount misleadingly high. Set.delete during
597
663
  // iteration is safe; the for-of iterator handles it.
598
- try { client.end(); } catch (_) { /* already torn down */ }
599
- this.clients.delete(client);
664
+ this._dropClient(client);
600
665
  }
601
666
  }
602
667
  }
@@ -606,5 +671,6 @@ module.exports = {
606
671
  WebDashboardServer,
607
672
  DEFAULT_WEB_PORT,
608
673
  STATE_PUSH_INTERVAL,
674
+ MAX_STALLED_PUSHES,
609
675
  ALLOWED_ACTIONS,
610
676
  };