maxpool 1.5.3 → 1.5.4
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/index.js +65 -13
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -26,6 +26,23 @@ const SERVER_WORKER_ENV = 'MAXPOOL_SERVER_WORKER';
|
|
|
26
26
|
// worker boots HEADLESS (plain logs, no writer lease) and waits for the baton.
|
|
27
27
|
const SERVER_RELOAD_WORKER_ENV = 'MAXPOOL_RELOAD_WORKER';
|
|
28
28
|
|
|
29
|
+
// Is maxpool attached to an interactive terminal? Governs SIGNAL semantics: in a
|
|
30
|
+
// terminal a SIGHUP means "the window hung up → shut down" (never reload into a
|
|
31
|
+
// headless orphan that outlives the terminal and squats the port); headless /
|
|
32
|
+
// service mode keeps SIGHUP as the conventional reload trigger. MAXPOOL_FORCE_TTY
|
|
33
|
+
// lets the (non-pty) test suite exercise the terminal-hangup path deterministically.
|
|
34
|
+
function isInteractiveTerminal() {
|
|
35
|
+
return Boolean(process.stdout.isTTY) || process.env.MAXPOOL_FORCE_TTY === '1';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Which reload path an in-process reload request takes. Interactive (a live TUI)
|
|
39
|
+
// reloads via a FULL cold restart so the fresh worker re-renders the TUI — a
|
|
40
|
+
// seamless reload worker boots headless and would strand the user in raw logs.
|
|
41
|
+
// Headless/service keeps the zero-downtime baton (nothing visual to lose).
|
|
42
|
+
function reloadStrategy({ supervised, useTUI }) {
|
|
43
|
+
return supervised && !useTUI ? 'seamless' : 'cold-restart';
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
switch (command) {
|
|
30
47
|
case 'server':
|
|
31
48
|
await serverCommand();
|
|
@@ -149,9 +166,18 @@ async function supervisorCommand() {
|
|
|
149
166
|
const forwardSignal = sig => { try { activeWorker?.child.kill(sig); } catch { /* ignore */ } };
|
|
150
167
|
process.on('SIGINT', () => { /* delivered to the worker by the TTY group */ });
|
|
151
168
|
process.on('SIGTERM', () => { /* delivered to the worker by the TTY group */ });
|
|
152
|
-
// SIGHUP
|
|
153
|
-
//
|
|
154
|
-
|
|
169
|
+
// SIGHUP: in a terminal, a window hangup delivers SIGHUP to the whole foreground
|
|
170
|
+
// group, so the worker ALSO received it and (interactive) is already shutting
|
|
171
|
+
// itself down — the supervisor must NOT forward a second signal, or the worker
|
|
172
|
+
// gets a doubled signal that force-quits it mid-drain (non-zero exit → a fast
|
|
173
|
+
// exit is then misread as a crash → respawn into a headless orphan, the exact
|
|
174
|
+
// bug). Only forward in headless/service mode, where SIGHUP is the conventional
|
|
175
|
+
// reload trigger and reaches the supervisor alone.
|
|
176
|
+
process.on('SIGHUP', () => { if (!isInteractiveTerminal()) forwardSignal('SIGHUP'); });
|
|
177
|
+
// A dead controlling terminal (window closed) makes writes to stdout/stderr emit
|
|
178
|
+
// EPIPE/EIO — swallow them so a shutdown-time log can't crash the supervisor.
|
|
179
|
+
process.stdout.on('error', () => {});
|
|
180
|
+
process.stderr.on('error', () => {});
|
|
155
181
|
// A spawn failure / stray rejection must NOT kill the supervisor (it would
|
|
156
182
|
// wedge the port and drop the service). Log and let the supervision loop or
|
|
157
183
|
// the reload's own error handling recover.
|
|
@@ -563,9 +589,15 @@ async function serverWorkerCommand() {
|
|
|
563
589
|
console.error(`[Maxpool] Worker uncaughtException: ${err?.stack || err}`);
|
|
564
590
|
restoreTerminal();
|
|
565
591
|
// A reload worker must NEVER exit(1) (escapes the supervisor exit-75 loop).
|
|
566
|
-
// Stay alive so the supervisor's baton timeouts roll us back cleanly.
|
|
567
|
-
|
|
592
|
+
// Stay alive so the supervisor's baton timeouts roll us back cleanly. Also
|
|
593
|
+
// skip the exit-75 restart while DRAINING: a shutdown-time write to a dead
|
|
594
|
+
// terminal can throw here, and restarting then would respawn the very orphan
|
|
595
|
+
// a terminal-close shutdown is trying to avoid — let the drain finish instead.
|
|
596
|
+
if (!isReloadWorker && !draining) process.exit(SERVER_RESTART_EXIT_CODE);
|
|
568
597
|
});
|
|
598
|
+
// Swallow EPIPE/EIO from writes to a hung-up terminal so they can't crash us.
|
|
599
|
+
process.stdout.on('error', () => {});
|
|
600
|
+
process.stderr.on('error', () => {});
|
|
569
601
|
process.on('unhandledRejection', reason => {
|
|
570
602
|
console.error(`[Maxpool] Worker unhandledRejection: ${reason}`);
|
|
571
603
|
});
|
|
@@ -636,7 +668,17 @@ async function serverWorkerCommand() {
|
|
|
636
668
|
// the respawned worker doesn't boot from a half-written state. Bounded so the
|
|
637
669
|
// abrupt path stays fast even if a write hangs.
|
|
638
670
|
Promise.race([
|
|
639
|
-
(async () => {
|
|
671
|
+
(async () => {
|
|
672
|
+
// Drain in-flight token refreshes before exit so the cold-respawned worker
|
|
673
|
+
// never boots mid-rotation of a single-use refresh token (→ invalid_grant
|
|
674
|
+
// → bricked account). Mirrors shutdownGracefully + the seamless baton's
|
|
675
|
+
// drainRefreshes — required now that interactive reload routes through here.
|
|
676
|
+
await releaseLease();
|
|
677
|
+
await accountManager.drainRefreshes();
|
|
678
|
+
await persistQuotaState(true);
|
|
679
|
+
await flushConfigWrites();
|
|
680
|
+
await flushStateWrites();
|
|
681
|
+
})(),
|
|
640
682
|
delay(2000),
|
|
641
683
|
]).finally(() => process.exit(SERVER_RESTART_EXIT_CODE));
|
|
642
684
|
};
|
|
@@ -646,7 +688,9 @@ async function serverWorkerCommand() {
|
|
|
646
688
|
// (not supervised, no IPC), fall back to the abrupt restart.
|
|
647
689
|
const requestReload = () => {
|
|
648
690
|
if (draining) return;
|
|
649
|
-
|
|
691
|
+
// Interactive (live TUI) → full cold restart so the fresh worker re-renders the
|
|
692
|
+
// TUI; headless/service → zero-downtime seamless baton (nothing visual to lose).
|
|
693
|
+
if (reloadStrategy({ supervised, useTUI }) === 'seamless') {
|
|
650
694
|
try {
|
|
651
695
|
process.send({ type: MSG_RELOAD_REQUEST });
|
|
652
696
|
return;
|
|
@@ -661,9 +705,15 @@ async function serverWorkerCommand() {
|
|
|
661
705
|
});
|
|
662
706
|
|
|
663
707
|
const shutdownGracefully = (reason, options = {}) => {
|
|
708
|
+
// A terminal-close shutdown must NEVER exit non-zero: the supervisor reads a
|
|
709
|
+
// fast non-zero exit as a crash and respawns a fresh worker — onto the now-dead
|
|
710
|
+
// terminal, re-creating the headless orphan. So even a drain-timeout or close
|
|
711
|
+
// error during a terminal close exits 0 (the terminal is gone; there's nothing
|
|
712
|
+
// to keep alive for).
|
|
713
|
+
const cleanExitCode = options.terminalClose ? 0 : 1;
|
|
664
714
|
if (draining) {
|
|
665
715
|
console.error(`\n[Maxpool] Force exiting with ${restartController.activeRequests.size} active request(s) still open.`);
|
|
666
|
-
process.exit(
|
|
716
|
+
process.exit(cleanExitCode);
|
|
667
717
|
}
|
|
668
718
|
|
|
669
719
|
draining = true;
|
|
@@ -704,14 +754,14 @@ async function serverWorkerCommand() {
|
|
|
704
754
|
|
|
705
755
|
timeoutTimer = setTimeout(() => {
|
|
706
756
|
console.error(`[Maxpool] Drain timeout after ${Math.ceil(drainTimeoutMs / 1000)}s; exiting with ${restartController.activeRequests.size} active request(s) still open.`);
|
|
707
|
-
finish(
|
|
757
|
+
finish(cleanExitCode);
|
|
708
758
|
}, drainTimeoutMs);
|
|
709
759
|
timeoutTimer.unref();
|
|
710
760
|
|
|
711
761
|
server.close(err => {
|
|
712
762
|
if (err) {
|
|
713
763
|
console.error(`[Maxpool] Shutdown error: ${err.message}`);
|
|
714
|
-
finish(
|
|
764
|
+
finish(cleanExitCode);
|
|
715
765
|
return;
|
|
716
766
|
}
|
|
717
767
|
console.log('[Maxpool] Shutdown complete.');
|
|
@@ -936,9 +986,11 @@ async function serverWorkerCommand() {
|
|
|
936
986
|
|
|
937
987
|
process.on('SIGINT', () => shutdownGracefully('SIGINT'));
|
|
938
988
|
process.on('SIGTERM', () => shutdownGracefully('SIGTERM'));
|
|
939
|
-
// SIGHUP
|
|
940
|
-
//
|
|
941
|
-
|
|
989
|
+
// SIGHUP: in a terminal this means the controlling window hung up (closed) →
|
|
990
|
+
// drain + exit cleanly so we never reload into a headless orphan that outlives
|
|
991
|
+
// the terminal and squats the port. Headless/service mode keeps SIGHUP as the
|
|
992
|
+
// conventional reload signal (baton under the supervisor, else exit-75).
|
|
993
|
+
process.on('SIGHUP', () => { if (isInteractiveTerminal()) shutdownGracefully('SIGHUP', { terminalClose: true }); else requestReload(); });
|
|
942
994
|
}
|
|
943
995
|
|
|
944
996
|
function logPlainServerStart({ host, port, accounts, threshold, config }) {
|