git-watchtower 2.3.8 → 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.
- package/package.json +1 -1
- package/src/server/web.js +80 -14
- package/src/ui/renderer.js +8 -3
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/ui/renderer.js
CHANGED
|
@@ -1505,13 +1505,19 @@ function renderUpdateModal(state, write) {
|
|
|
1505
1505
|
lines.push(updateCmd);
|
|
1506
1506
|
lines.push('');
|
|
1507
1507
|
|
|
1508
|
+
// Tracks where the selectable option block starts so the highlight
|
|
1509
|
+
// logic below can reverse-map line index → option index. -1 when
|
|
1510
|
+
// there are no options (the updateInProgress path). Computed from
|
|
1511
|
+
// `lines.length` so adding any line above never silently desyncs the
|
|
1512
|
+
// highlight — previously this was a hardcoded `7` that happened to
|
|
1513
|
+
// match the line count above by coincidence.
|
|
1514
|
+
let optionStartIdx = -1;
|
|
1508
1515
|
if (state.updateInProgress) {
|
|
1509
1516
|
lines.push('Updating...');
|
|
1510
1517
|
lines.push('');
|
|
1511
1518
|
lines.push('Please wait...');
|
|
1512
1519
|
} else {
|
|
1513
|
-
|
|
1514
|
-
const optionStartIdx = lines.length;
|
|
1520
|
+
optionStartIdx = lines.length;
|
|
1515
1521
|
for (const opt of options) {
|
|
1516
1522
|
lines.push(opt);
|
|
1517
1523
|
}
|
|
@@ -1519,7 +1525,6 @@ function renderUpdateModal(state, write) {
|
|
|
1519
1525
|
lines.push('[Enter] Select [Esc] Dismiss');
|
|
1520
1526
|
}
|
|
1521
1527
|
|
|
1522
|
-
const optionStartIdx = state.updateInProgress ? -1 : 7;
|
|
1523
1528
|
const height = lines.length + 2;
|
|
1524
1529
|
|
|
1525
1530
|
// Draw magenta double-border box
|