git-watchtower 1.14.7 → 1.14.9
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 +108 -32
- package/package.json +1 -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() {
|
|
@@ -3390,9 +3411,34 @@ function restartProcess() {
|
|
|
3390
3411
|
restoreTerminalTitle();
|
|
3391
3412
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
3392
3413
|
|
|
3393
|
-
//
|
|
3394
|
-
|
|
3395
|
-
|
|
3414
|
+
// Silence the parent before the replacement takes over the TTY. The parent
|
|
3415
|
+
// stays alive waiting on child.on('close') to forward the exit code, and
|
|
3416
|
+
// stdio is inherited — so any stray listener here will race with the child
|
|
3417
|
+
// (keystrokes consumed twice, render() drawing frames on top of the child's
|
|
3418
|
+
// UI, Ctrl+C intercepted by both, etc.).
|
|
3419
|
+
try { process.stdin.removeAllListeners('data'); } catch (_) { /* stdin may be detached */ }
|
|
3420
|
+
try { process.stdin.pause(); } catch (_) { /* stdin may already be paused */ }
|
|
3421
|
+
try { process.stdout.removeAllListeners('resize'); } catch (_) { /* stdout may be detached */ }
|
|
3422
|
+
try { process.removeAllListeners('SIGWINCH'); } catch (_) { /* no SIGWINCH handler registered */ }
|
|
3423
|
+
try { process.removeAllListeners('SIGINT'); } catch (_) { /* no SIGINT handler registered */ }
|
|
3424
|
+
try { process.removeAllListeners('SIGTERM'); } catch (_) { /* no SIGTERM handler registered */ }
|
|
3425
|
+
|
|
3426
|
+
// Stop every scheduler that can trigger a render while we're waiting on the
|
|
3427
|
+
// child. periodicUpdateCheck in particular will fire render() on completion
|
|
3428
|
+
// and would draw over the replacement's frames.
|
|
3429
|
+
if (pollIntervalId) {
|
|
3430
|
+
try { clearTimeout(pollIntervalId); } catch (_) { /* defensive */ }
|
|
3431
|
+
pollIntervalId = null;
|
|
3432
|
+
}
|
|
3433
|
+
if (periodicUpdateCheck) {
|
|
3434
|
+
try { periodicUpdateCheck.stop(); } catch (_) { /* interval may already be cleared */ }
|
|
3435
|
+
}
|
|
3436
|
+
if (fileWatcher) {
|
|
3437
|
+
try { fileWatcher.close(); } catch (_) { /* watcher may already be closed */ }
|
|
3438
|
+
fileWatcher = null;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// Stop server, SSE clients, web dashboard
|
|
3396
3442
|
if (SERVER_MODE === 'command') stopServerProcess();
|
|
3397
3443
|
else if (SERVER_MODE === 'static') {
|
|
3398
3444
|
clients.forEach(client => client.end());
|
|
@@ -3408,6 +3454,12 @@ function restartProcess() {
|
|
|
3408
3454
|
monitorLockFile = null;
|
|
3409
3455
|
}
|
|
3410
3456
|
|
|
3457
|
+
// The parent's 'exit' handler (process.on('exit', cleanupResources)) writes
|
|
3458
|
+
// ANSI escapes — showCursor / restoreScreen — to the shared TTY. Once the
|
|
3459
|
+
// child owns the screen those writes would corrupt its UI on parent exit,
|
|
3460
|
+
// so mark cleanup as already done.
|
|
3461
|
+
_resourcesCleaned = true;
|
|
3462
|
+
|
|
3411
3463
|
console.log('\n♻ Restarting git-watchtower...\n');
|
|
3412
3464
|
|
|
3413
3465
|
const { spawn: spawnChild } = require('child_process');
|
|
@@ -3441,11 +3493,16 @@ let monitorLockFile = null;
|
|
|
3441
3493
|
* times and from any exit path (shutdown, uncaughtException, 'exit').
|
|
3442
3494
|
*
|
|
3443
3495
|
* Every step is wrapped in try/catch so a failure in one resource does
|
|
3444
|
-
* not prevent the rest from being cleaned up.
|
|
3445
|
-
*
|
|
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.
|
|
3446
3503
|
*/
|
|
3447
3504
|
function cleanupResources() {
|
|
3448
|
-
if (_resourcesCleaned) return;
|
|
3505
|
+
if (_resourcesCleaned) return Promise.resolve();
|
|
3449
3506
|
_resourcesCleaned = true;
|
|
3450
3507
|
|
|
3451
3508
|
// Restore terminal first so the user sees a clean prompt even if a
|
|
@@ -3480,9 +3537,14 @@ function cleanupResources() {
|
|
|
3480
3537
|
} catch (_) { /* clients set may have mutated mid-iteration during shutdown */ }
|
|
3481
3538
|
}
|
|
3482
3539
|
|
|
3483
|
-
// 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();
|
|
3484
3546
|
if (SERVER_MODE === 'command') {
|
|
3485
|
-
try { stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
|
|
3547
|
+
try { serverStopPromise = stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
|
|
3486
3548
|
}
|
|
3487
3549
|
|
|
3488
3550
|
// Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
|
|
@@ -3494,13 +3556,15 @@ function cleanupResources() {
|
|
|
3494
3556
|
try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have been unlinked externally */ }
|
|
3495
3557
|
monitorLockFile = null;
|
|
3496
3558
|
}
|
|
3559
|
+
|
|
3560
|
+
return serverStopPromise;
|
|
3497
3561
|
}
|
|
3498
3562
|
|
|
3499
3563
|
async function shutdown() {
|
|
3500
3564
|
if (isShuttingDown) return;
|
|
3501
3565
|
isShuttingDown = true;
|
|
3502
3566
|
|
|
3503
|
-
cleanupResources();
|
|
3567
|
+
const serverStopPromise = cleanupResources();
|
|
3504
3568
|
|
|
3505
3569
|
// For the static HTTP server, give in-flight connections a brief
|
|
3506
3570
|
// grace period to drain. cleanupResources ended the SSE clients; this
|
|
@@ -3512,6 +3576,12 @@ async function shutdown() {
|
|
|
3512
3576
|
await Promise.race([serverClosePromise, timeoutPromise]);
|
|
3513
3577
|
}
|
|
3514
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
|
+
|
|
3515
3585
|
// Flush telemetry
|
|
3516
3586
|
telemetry.capture('session_ended', {
|
|
3517
3587
|
duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
|
|
@@ -3538,11 +3608,14 @@ process.on('uncaughtException', async (err) => {
|
|
|
3538
3608
|
// Synchronous teardown first — so the coordinator socket, lock file,
|
|
3539
3609
|
// dev-server child process group, and SSE clients are released even
|
|
3540
3610
|
// if telemetry shutdown hangs or throws.
|
|
3541
|
-
cleanupResources();
|
|
3611
|
+
const serverStopPromise = cleanupResources();
|
|
3542
3612
|
|
|
3543
3613
|
try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
|
|
3544
3614
|
console.error('Uncaught exception:', err);
|
|
3545
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 */ }
|
|
3546
3619
|
process.exit(1);
|
|
3547
3620
|
});
|
|
3548
3621
|
|
|
@@ -3554,12 +3627,15 @@ process.on('uncaughtException', async (err) => {
|
|
|
3554
3627
|
process.on('unhandledRejection', async (reason) => {
|
|
3555
3628
|
isShuttingDown = true;
|
|
3556
3629
|
|
|
3557
|
-
cleanupResources();
|
|
3630
|
+
const serverStopPromise = cleanupResources();
|
|
3558
3631
|
|
|
3559
3632
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
3560
3633
|
try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
|
|
3561
3634
|
console.error('Unhandled rejection:', reason);
|
|
3562
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 */ }
|
|
3563
3639
|
process.exit(1);
|
|
3564
3640
|
});
|
|
3565
3641
|
|
package/package.json
CHANGED