git-watchtower 1.14.8 → 1.14.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/bin/git-watchtower.js +74 -29
- package/package.json +1 -1
- package/src/server/web.js +22 -1
package/bin/git-watchtower.js
CHANGED
|
@@ -748,42 +748,63 @@ function startServerProcess() {
|
|
|
748
748
|
}
|
|
749
749
|
}
|
|
750
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Stop the user's dev-server child process and its entire process group.
|
|
753
|
+
*
|
|
754
|
+
* Returns a Promise that resolves when the process has actually exited (or a
|
|
755
|
+
* hard cap elapses). Callers on async exit paths — shutdown(), uncaughtException,
|
|
756
|
+
* unhandledRejection — must await this so the SIGKILL escalation timer fires
|
|
757
|
+
* before process.exit() drops it. Synchronous callers (startServerProcess's
|
|
758
|
+
* restart branch, the 'exit' fallback) can fire-and-forget; best effort only.
|
|
759
|
+
*/
|
|
751
760
|
function stopServerProcess() {
|
|
752
|
-
if (!serverProcess) return;
|
|
761
|
+
if (!serverProcess) return Promise.resolve();
|
|
753
762
|
|
|
754
763
|
addLog('Stopping server...', 'update');
|
|
755
764
|
|
|
756
765
|
// Capture reference before nulling — needed for deferred SIGKILL
|
|
757
766
|
const proc = serverProcess;
|
|
767
|
+
serverProcess = null;
|
|
768
|
+
store.setState({ serverRunning: false });
|
|
769
|
+
|
|
770
|
+
// Resolves when the child actually exits
|
|
771
|
+
const closedPromise = proc.exitCode !== null
|
|
772
|
+
? Promise.resolve()
|
|
773
|
+
: new Promise((resolve) => { proc.once('close', () => resolve()); });
|
|
774
|
+
|
|
775
|
+
// Hard cap so callers can never hang forever if 'close' never fires
|
|
776
|
+
// (e.g. stdio pipe torn down, handle state corrupted). Grace period plus
|
|
777
|
+
// a small buffer for SIGKILL delivery + OS reap of the process group.
|
|
778
|
+
const hardCap = new Promise((resolve) => setTimeout(resolve, FORCE_KILL_GRACE_MS + 500));
|
|
758
779
|
|
|
759
|
-
// Try graceful shutdown first
|
|
760
780
|
if (process.platform === 'win32') {
|
|
781
|
+
// taskkill /f /t is already forceful and recursive; close should follow shortly
|
|
761
782
|
spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
783
|
+
return Promise.race([closedPromise, hardCap]);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Kill the entire process group (negative PID) so that grandchildren
|
|
787
|
+
// (e.g. npm -> node -> vite) are also terminated.
|
|
788
|
+
try {
|
|
789
|
+
process.kill(-proc.pid, 'SIGTERM');
|
|
790
|
+
} catch (e) {
|
|
791
|
+
// Process group may already be dead
|
|
792
|
+
}
|
|
793
|
+
// Force kill after grace period if process hasn't exited
|
|
794
|
+
const forceKillTimeout = setTimeout(() => {
|
|
765
795
|
try {
|
|
766
|
-
process.kill(-proc.pid, '
|
|
796
|
+
process.kill(-proc.pid, 'SIGKILL');
|
|
767
797
|
} catch (e) {
|
|
768
798
|
// Process group may already be dead
|
|
769
799
|
}
|
|
770
|
-
|
|
771
|
-
const forceKillTimeout = setTimeout(() => {
|
|
772
|
-
try {
|
|
773
|
-
process.kill(-proc.pid, 'SIGKILL');
|
|
774
|
-
} catch (e) {
|
|
775
|
-
// Process group may already be dead
|
|
776
|
-
}
|
|
777
|
-
}, FORCE_KILL_GRACE_MS);
|
|
800
|
+
}, FORCE_KILL_GRACE_MS);
|
|
778
801
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
802
|
+
// Clear the force-kill timer if the process exits cleanly
|
|
803
|
+
proc.once('close', () => {
|
|
804
|
+
clearTimeout(forceKillTimeout);
|
|
805
|
+
});
|
|
784
806
|
|
|
785
|
-
|
|
786
|
-
store.setState({ serverRunning: false });
|
|
807
|
+
return Promise.race([closedPromise, hardCap]);
|
|
787
808
|
}
|
|
788
809
|
|
|
789
810
|
function restartServerProcess() {
|
|
@@ -3472,11 +3493,16 @@ let monitorLockFile = null;
|
|
|
3472
3493
|
* times and from any exit path (shutdown, uncaughtException, 'exit').
|
|
3473
3494
|
*
|
|
3474
3495
|
* Every step is wrapped in try/catch so a failure in one resource does
|
|
3475
|
-
* not prevent the rest from being cleaned up.
|
|
3476
|
-
*
|
|
3496
|
+
* not prevent the rest from being cleaned up. The body is synchronous
|
|
3497
|
+
* so it still runs inside an 'exit' handler where async callbacks won't
|
|
3498
|
+
* execute; the dev-server close promise is bubbled up so async callers
|
|
3499
|
+
* can await it before process.exit().
|
|
3500
|
+
*
|
|
3501
|
+
* @returns {Promise<void>} resolves when the dev-server child has exited
|
|
3502
|
+
* (or a hard cap elapses). Synchronous callers may ignore it.
|
|
3477
3503
|
*/
|
|
3478
3504
|
function cleanupResources() {
|
|
3479
|
-
if (_resourcesCleaned) return;
|
|
3505
|
+
if (_resourcesCleaned) return Promise.resolve();
|
|
3480
3506
|
_resourcesCleaned = true;
|
|
3481
3507
|
|
|
3482
3508
|
// Restore terminal first so the user sees a clean prompt even if a
|
|
@@ -3511,9 +3537,14 @@ function cleanupResources() {
|
|
|
3511
3537
|
} catch (_) { /* clients set may have mutated mid-iteration during shutdown */ }
|
|
3512
3538
|
}
|
|
3513
3539
|
|
|
3514
|
-
// User's dev-server process (command mode)
|
|
3540
|
+
// User's dev-server process (command mode). Capture the close promise so
|
|
3541
|
+
// async callers (shutdown/uncaughtException/unhandledRejection) can await
|
|
3542
|
+
// it before process.exit() — otherwise the SIGKILL escalation timer gets
|
|
3543
|
+
// dropped and a dev server that ignored SIGTERM survives as a detached
|
|
3544
|
+
// orphan (it was spawned in its own process group on Unix).
|
|
3545
|
+
let serverStopPromise = Promise.resolve();
|
|
3515
3546
|
if (SERVER_MODE === 'command') {
|
|
3516
|
-
try { stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
|
|
3547
|
+
try { serverStopPromise = stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
|
|
3517
3548
|
}
|
|
3518
3549
|
|
|
3519
3550
|
// Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
|
|
@@ -3525,13 +3556,15 @@ function cleanupResources() {
|
|
|
3525
3556
|
try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have been unlinked externally */ }
|
|
3526
3557
|
monitorLockFile = null;
|
|
3527
3558
|
}
|
|
3559
|
+
|
|
3560
|
+
return serverStopPromise;
|
|
3528
3561
|
}
|
|
3529
3562
|
|
|
3530
3563
|
async function shutdown() {
|
|
3531
3564
|
if (isShuttingDown) return;
|
|
3532
3565
|
isShuttingDown = true;
|
|
3533
3566
|
|
|
3534
|
-
cleanupResources();
|
|
3567
|
+
const serverStopPromise = cleanupResources();
|
|
3535
3568
|
|
|
3536
3569
|
// For the static HTTP server, give in-flight connections a brief
|
|
3537
3570
|
// grace period to drain. cleanupResources ended the SSE clients; this
|
|
@@ -3543,6 +3576,12 @@ async function shutdown() {
|
|
|
3543
3576
|
await Promise.race([serverClosePromise, timeoutPromise]);
|
|
3544
3577
|
}
|
|
3545
3578
|
|
|
3579
|
+
// Wait for the user's dev-server child to actually exit (SIGTERM +
|
|
3580
|
+
// FORCE_KILL_GRACE_MS SIGKILL escalation inside stopServerProcess).
|
|
3581
|
+
// Without this the escalation timer gets dropped by process.exit() below
|
|
3582
|
+
// and a dev server that ignored SIGTERM survives as a detached orphan.
|
|
3583
|
+
try { await serverStopPromise; } catch (_) { /* best-effort */ }
|
|
3584
|
+
|
|
3546
3585
|
// Flush telemetry
|
|
3547
3586
|
telemetry.capture('session_ended', {
|
|
3548
3587
|
duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
|
|
@@ -3569,11 +3608,14 @@ process.on('uncaughtException', async (err) => {
|
|
|
3569
3608
|
// Synchronous teardown first — so the coordinator socket, lock file,
|
|
3570
3609
|
// dev-server child process group, and SSE clients are released even
|
|
3571
3610
|
// if telemetry shutdown hangs or throws.
|
|
3572
|
-
cleanupResources();
|
|
3611
|
+
const serverStopPromise = cleanupResources();
|
|
3573
3612
|
|
|
3574
3613
|
try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
|
|
3575
3614
|
console.error('Uncaught exception:', err);
|
|
3576
3615
|
try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
|
|
3616
|
+
// Wait for the dev-server SIGKILL escalation before exiting — otherwise
|
|
3617
|
+
// process.exit() drops the pending timer and a stuck server orphans.
|
|
3618
|
+
try { await serverStopPromise; } catch (_) { /* best-effort */ }
|
|
3577
3619
|
process.exit(1);
|
|
3578
3620
|
});
|
|
3579
3621
|
|
|
@@ -3585,12 +3627,15 @@ process.on('uncaughtException', async (err) => {
|
|
|
3585
3627
|
process.on('unhandledRejection', async (reason) => {
|
|
3586
3628
|
isShuttingDown = true;
|
|
3587
3629
|
|
|
3588
|
-
cleanupResources();
|
|
3630
|
+
const serverStopPromise = cleanupResources();
|
|
3589
3631
|
|
|
3590
3632
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
3591
3633
|
try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
|
|
3592
3634
|
console.error('Unhandled rejection:', reason);
|
|
3593
3635
|
try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
|
|
3636
|
+
// Wait for the dev-server SIGKILL escalation before exiting — otherwise
|
|
3637
|
+
// process.exit() drops the pending timer and a stuck server orphans.
|
|
3638
|
+
try { await serverStopPromise; } catch (_) { /* best-effort */ }
|
|
3594
3639
|
process.exit(1);
|
|
3595
3640
|
});
|
|
3596
3641
|
|
package/package.json
CHANGED
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
|
}
|