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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +65 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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 (from `kill -HUP <supervisor-pid>`) reaches ONLY the supervisor →
153
- // forward it so the worker requests a seamless reload.
154
- process.on('SIGHUP', () => forwardSignal('SIGHUP'));
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
- if (!isReloadWorker) process.exit(SERVER_RESTART_EXIT_CODE);
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 () => { await persistQuotaState(); await releaseLease(); await flushConfigWrites(); await flushStateWrites(); })(),
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
- if (supervised) {
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(1);
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(1);
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(1);
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 requests a seamless reload (the conventional "reload" signal). Under
940
- // the supervisor this runs the baton; otherwise it falls back to exit-75.
941
- process.on('SIGHUP', () => requestReload());
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 }) {