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 +1 -1
- package/src/server/coordinator.js +70 -4
- package/src/server/web.js +22 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
}
|