maxpool 1.5.12 → 1.5.13

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 +42 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.12",
3
+ "version": "1.5.13",
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
@@ -156,10 +156,20 @@ async function supervisorCommand() {
156
156
  // supervisor's acceptor is only LIVE during the brief cutover gap (it stops
157
157
  // once a worker confirms it is the sole acceptor). A bare handler is required
158
158
  // so the rare gap-accepted socket isn't left dangling.
159
- const relistenMaster = () => new Promise((resolve, reject) => {
159
+ const relistenMaster = (retriesOnBusy = 20) => new Promise((resolve, reject) => {
160
160
  if (masterServer.listening) { resolve(); return; }
161
- const onErr = err => { masterServer.removeListener('listening', onListen); reject(err); };
162
161
  const onListen = () => { masterServer.removeListener('error', onErr); resolve(); };
162
+ const onErr = err => {
163
+ masterServer.removeListener('listening', onListen);
164
+ // A just-SIGKILLed worker (seamless-reload fallback) may not have released the
165
+ // listening fd yet — SIGKILL is async; the port frees within a few ms. Retry
166
+ // bounded (~1s) rather than crashing the supervisor via handleServerListenError.
167
+ if (err.code === 'EADDRINUSE' && retriesOnBusy > 0) {
168
+ setTimeout(() => relistenMaster(retriesOnBusy - 1).then(resolve, reject), 50);
169
+ return;
170
+ }
171
+ reject(err);
172
+ };
163
173
  masterServer.once('error', onErr);
164
174
  masterServer.once('listening', onListen);
165
175
  masterServer.listen(port, host);
@@ -347,6 +357,19 @@ async function supervisorCommand() {
347
357
 
348
358
  try {
349
359
  while (true) {
360
+ // Ensure the master socket is LISTENING before handing it to a cold worker.
361
+ // After the previous primary worker sent MSG_PRIMARY the supervisor ran
362
+ // closeMasterAccept() (masterServer.close()), and the exiting worker released
363
+ // the last fd — so on every respawn masterServer is closed. Without this,
364
+ // spawnWorker sends a dead handle, the worker ignores it (falsy-handle guard),
365
+ // and never becomes primary → the interactive `r` restart hangs blank. Idempotent
366
+ // (no-op when already listening, e.g. the very first iteration after line 172).
367
+ try {
368
+ await relistenMaster();
369
+ } catch (err) {
370
+ handleServerListenError(err, host, port);
371
+ return;
372
+ }
350
373
  const worker = spawnWorker({ reload: false });
351
374
  const startedAt = Date.now();
352
375
  const result = await superviseTurn(worker);
@@ -725,7 +748,10 @@ async function serverWorkerCommand() {
725
748
  if (draining) return;
726
749
  // Interactive (live TUI) → full cold restart so the fresh worker re-renders the
727
750
  // TUI; headless/service → zero-downtime seamless baton (nothing visual to lose).
728
- if (reloadStrategy({ supervised, useTUI }) === 'seamless') {
751
+ // Test-only: force the interactive cold-restart path headless (a pty can't be
752
+ // allocated in-suite), so the exit-75 respawn is exercised without a real TUI.
753
+ const forceCold = process.env.MAXPOOL_TEST_FORCE_COLD_RESTART === '1';
754
+ if (!forceCold && reloadStrategy({ supervised, useTUI }) === 'seamless') {
729
755
  try {
730
756
  process.send({ type: MSG_RELOAD_REQUEST });
731
757
  return;
@@ -991,6 +1017,12 @@ async function serverWorkerCommand() {
991
1017
  // Cold start: take the socket and go primary immediately.
992
1018
  await listenOnHandle(handle);
993
1019
  await becomePrimary({ viaTakeover: false });
1020
+ } else if (msg?.type === MSG_LISTEN && !handle) {
1021
+ // Defense-in-depth for the stale-handle hang: if the supervisor ever hands
1022
+ // a cold worker a dead/absent socket, fail LOUD and exit-75 so it respawns —
1023
+ // never sit alive-but-not-primary (the blank-screen `r`-restart hang).
1024
+ console.error('[Maxpool] Cold worker received MSG_LISTEN with no socket handle — exiting for respawn.');
1025
+ process.exit(SERVER_RESTART_EXIT_CODE);
994
1026
  } else if (msg?.type === MSG_PROBE_READY) {
995
1027
  // Headless reload worker: we've booted the new code and restored quota
996
1028
  // in memory. Confirm readiness (we are NOT yet accepting / writing).
@@ -1013,9 +1045,13 @@ async function serverWorkerCommand() {
1013
1045
  await releaseBatonAndDrain();
1014
1046
  }
1015
1047
  } catch (err) {
1016
- // A failed reload worker must NOT exit(1) (kills the supervisor loop).
1017
- // Report failure so the supervisor rolls back; stay alive harmlessly.
1018
- console.error(`[Maxpool] Reload worker error: ${err.message}`);
1048
+ console.error(`[Maxpool] Worker message error: ${err.message}`);
1049
+ // A COLD worker that failed to listen / become primary has no baton to roll
1050
+ // back to — it would strand blank. Exit-75 so the supervisor respawns it
1051
+ // (closes the silent-non-primary hang class). A RELOAD worker mid-baton must
1052
+ // NOT exit (that kills the supervisor loop) — report MSG_FAILED so the
1053
+ // supervisor rolls back and the old worker stays primary (zero disruption).
1054
+ if (!isReloadWorker) process.exit(SERVER_RESTART_EXIT_CODE);
1019
1055
  try { process.send({ type: MSG_FAILED, reason: err.message }); } catch { /* ignore */ }
1020
1056
  }
1021
1057
  });