maxpool 1.5.28 → 1.5.29
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 +25 -0
- package/src/restart-controller.js +19 -0
- package/src/server.js +34 -1
- package/src/tui.js +23 -7
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
const args = process.argv.slice(2);
|
|
24
24
|
const command = args[0];
|
|
25
25
|
const SERVER_RESTART_EXIT_CODE = 75;
|
|
26
|
+
// If a seamless reload doesn't release us (→ take over) within this window, the new
|
|
27
|
+
// worker rolled back — self-heal admission so we don't 503 forever. > the baton
|
|
28
|
+
// readiness timeout (10s) + margin.
|
|
29
|
+
const RELOAD_ROLLBACK_SELFHEAL_MS = Math.max(15_000, Number(process.env.MAXPOOL_RELOAD_SELFHEAL_MS) || 30_000);
|
|
26
30
|
const SERVER_WORKER_ENV = 'MAXPOOL_SERVER_WORKER';
|
|
27
31
|
// Set by the supervisor when it spawns a worker for a seamless reload: that
|
|
28
32
|
// worker boots HEADLESS (plain logs, no writer lease) and waits for the baton.
|
|
@@ -683,6 +687,9 @@ async function serverWorkerCommand() {
|
|
|
683
687
|
// so it can't flip the shared TTY's raw-mode/alt-screen back and clobber the new
|
|
684
688
|
// worker's live TUI. Only the current terminal owner (or the supervisor) restores.
|
|
685
689
|
let terminalHandedOff = false;
|
|
690
|
+
// Self-heal timer for a seamless reload that rolls back (new worker fails to boot,
|
|
691
|
+
// we're never released). Cleared on MSG_RELEASE; fires cancelRestart otherwise.
|
|
692
|
+
let reloadWatchdog = null;
|
|
686
693
|
|
|
687
694
|
// Best-effort terminal restore on ANY abnormal exit path (uncaughtException,
|
|
688
695
|
// a bare process.exit, a crash) so the user's shell is never left in raw mode
|
|
@@ -813,6 +820,20 @@ async function serverWorkerCommand() {
|
|
|
813
820
|
if (!forceCold && reloadStrategy({ supervised }) === 'seamless') {
|
|
814
821
|
try {
|
|
815
822
|
process.send({ type: MSG_RELOAD_REQUEST });
|
|
823
|
+
// Arm the rollback self-heal: restartController already latched
|
|
824
|
+
// pending/restarting + paused admission (in _restart, before we got here).
|
|
825
|
+
// On a SUCCESSFUL reload the supervisor sends MSG_RELEASE (→ releaseBaton
|
|
826
|
+
// AndDrain clears this) then we exit. On a ROLLBACK we're never released and
|
|
827
|
+
// never exit — so if this fires, resume serving instead of 503-ing forever.
|
|
828
|
+
if (reloadWatchdog) clearTimeout(reloadWatchdog);
|
|
829
|
+
reloadWatchdog = setTimeout(() => {
|
|
830
|
+
reloadWatchdog = null;
|
|
831
|
+
if (draining) return; // MSG_RELEASE already arrived → a real reload, not a rollback
|
|
832
|
+
if (restartController?.cancelRestart()) {
|
|
833
|
+
console.log('[Maxpool] Reload rolled back (new worker never took over) — resumed serving.');
|
|
834
|
+
}
|
|
835
|
+
}, RELOAD_ROLLBACK_SELFHEAL_MS);
|
|
836
|
+
reloadWatchdog.unref?.();
|
|
816
837
|
return;
|
|
817
838
|
} catch { /* IPC gone — fall through to abrupt restart */ }
|
|
818
839
|
}
|
|
@@ -821,6 +842,7 @@ async function serverWorkerCommand() {
|
|
|
821
842
|
|
|
822
843
|
restartController = new RestartController({
|
|
823
844
|
pauseAdmission: () => accountManager.setAdmissionPaused(true),
|
|
845
|
+
resumeAdmission: () => accountManager.setAdmissionPaused(false),
|
|
824
846
|
restartNow: requestReload,
|
|
825
847
|
// Configurable so ops can tune the bounded pre-restart drain, and so the
|
|
826
848
|
// integration test can exercise the force-restart path without a 10s wait.
|
|
@@ -1044,6 +1066,9 @@ async function serverWorkerCommand() {
|
|
|
1044
1066
|
const releaseBatonAndDrain = async () => {
|
|
1045
1067
|
if (draining) { try { process.send({ type: MSG_RELEASED }); } catch { /* ignore */ } return; }
|
|
1046
1068
|
draining = true;
|
|
1069
|
+
// MSG_RELEASE arrived → the new worker booted OK and this reload is really
|
|
1070
|
+
// proceeding (not a rollback). Disarm the rollback self-heal.
|
|
1071
|
+
if (reloadWatchdog) { clearTimeout(reloadWatchdog); reloadWatchdog = null; }
|
|
1047
1072
|
if (syncTimer) clearInterval(syncTimer);
|
|
1048
1073
|
// Stop accepting NEW connections; KEEP in-flight requests alive.
|
|
1049
1074
|
server.maxpoolBeginDrain?.();
|
|
@@ -3,6 +3,7 @@ const NON_UPSTREAM_ROUTES = new Set(['(queued)', '(none available)']);
|
|
|
3
3
|
export class RestartController {
|
|
4
4
|
constructor({
|
|
5
5
|
pauseAdmission,
|
|
6
|
+
resumeAdmission = () => {},
|
|
6
7
|
restartNow,
|
|
7
8
|
log = console.log,
|
|
8
9
|
// Bounded drain: wait at most this long for in-flight upstream requests to
|
|
@@ -15,6 +16,7 @@ export class RestartController {
|
|
|
15
16
|
clearTimeoutFn = clearTimeout,
|
|
16
17
|
}) {
|
|
17
18
|
this.pauseAdmission = pauseAdmission;
|
|
19
|
+
this.resumeAdmission = resumeAdmission;
|
|
18
20
|
this.restartNow = restartNow;
|
|
19
21
|
this.log = log;
|
|
20
22
|
this.drainTimeoutMs = drainTimeoutMs;
|
|
@@ -33,6 +35,23 @@ export class RestartController {
|
|
|
33
35
|
return true;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Abort a restart that was requested but never completed because the process is
|
|
40
|
+
* STILL ALIVE — a seamless reload that rolled back (the new worker failed to boot,
|
|
41
|
+
* so the old worker was never released and never exited). Without this the latched
|
|
42
|
+
* `pending`/`restarting` flags make requestStarted() return false forever → the
|
|
43
|
+
* worker 503s EVERY request until a manual restart. Reset the flags + resume
|
|
44
|
+
* admission so it keeps serving. No-op if no restart is in progress.
|
|
45
|
+
*/
|
|
46
|
+
cancelRestart() {
|
|
47
|
+
if (!this.pending && !this.restarting) return false;
|
|
48
|
+
this.pending = false;
|
|
49
|
+
this.restarting = false;
|
|
50
|
+
if (this._drainTimer) { this.clearTimeoutFn(this._drainTimer); this._drainTimer = null; }
|
|
51
|
+
this.resumeAdmission();
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
requestRouted(id, account) {
|
|
37
56
|
if (!this.activeRequests.has(id)) return false;
|
|
38
57
|
if (NON_UPSTREAM_ROUTES.has(account)) this.upstreamRequests.delete(id);
|
package/src/server.js
CHANGED
|
@@ -25,6 +25,11 @@ const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) ||
|
|
|
25
25
|
const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 300_000); // max gap BETWEEN streamed chunks (reset per chunk)
|
|
26
26
|
const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
|
|
27
27
|
const CLIENT_DRAIN_MS = Math.max(5_000, Number(process.env.MAXPOOL_DRAIN_MS) || 60_000); // max wait for a backpressured client to drain (half-open client → free the lease)
|
|
28
|
+
// Backstop reaper idle ceiling. Floored WELL above the longest a legit NON-streaming
|
|
29
|
+
// request can go silent (no heartbeat there): TTFB 120s + nonStreamMaxWaitMs 300s +
|
|
30
|
+
// UPSTREAM_BODY_MS 300s ≈ 720s — so a slow-but-alive non-streaming request is never
|
|
31
|
+
// reaped at completion. Streaming holds heartbeat, so they're safe at any ceiling.
|
|
32
|
+
const REQUEST_IDLE_MAX_MS = Math.max(900_000, Number(process.env.MAXPOOL_REQUEST_IDLE_MAX_MS) || 1_200_000);
|
|
28
33
|
|
|
29
34
|
const DEFAULT_QUEUE = {
|
|
30
35
|
enabled: true,
|
|
@@ -177,6 +182,9 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
177
182
|
// undefined (a body-read throw lands before describeRequest runs).
|
|
178
183
|
const ctx = { account: null, status: null };
|
|
179
184
|
let requestInfo = {};
|
|
185
|
+
// Backstop reaper (defense-in-depth for any UNKNOWN future hang the specific
|
|
186
|
+
// TTFB/idle/body/drain guards don't cover). See startIdleRequestReaper.
|
|
187
|
+
const reaperTimer = startIdleRequestReaper(res, reqId, REQUEST_IDLE_MAX_MS);
|
|
180
188
|
try {
|
|
181
189
|
// Buffer request body (needed for retry on 429)
|
|
182
190
|
const bodyChunks = [];
|
|
@@ -218,6 +226,7 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
218
226
|
});
|
|
219
227
|
}
|
|
220
228
|
} finally {
|
|
229
|
+
clearInterval(reaperTimer);
|
|
221
230
|
hooks.onRequestEnd?.(reqId, {
|
|
222
231
|
method: req.method, path: req.url,
|
|
223
232
|
account: ctx.account, status: ctx.status,
|
|
@@ -1109,7 +1118,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1109
1118
|
return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
|
|
1110
1119
|
}
|
|
1111
1120
|
|
|
1112
|
-
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse };
|
|
1121
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
|
|
1113
1122
|
|
|
1114
1123
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1115
1124
|
if (!upstreamRes.body) return '';
|
|
@@ -1760,6 +1769,30 @@ function mappedModel(originalModel, account) {
|
|
|
1760
1769
|
/**
|
|
1761
1770
|
* Stream an SSE response to the client, parsing usage data along the way.
|
|
1762
1771
|
*/
|
|
1772
|
+
/**
|
|
1773
|
+
* Backstop reaper for a single request: force-abort it if it makes ZERO client-write
|
|
1774
|
+
* progress for idleMs. IDLE-keyed via res.socket.bytesWritten (a legit streaming HOLD
|
|
1775
|
+
* heartbeats → bytesWritten advances → never trips; only a genuinely stuck request
|
|
1776
|
+
* with no writes is reaped). res.destroy() routes into the normal onClose→finally
|
|
1777
|
+
* cleanup so the account lease + onRequestEnd release — never a direct releaseAccount
|
|
1778
|
+
* (which has no idempotency guard). Clock/timer injectable for tests. Returns the
|
|
1779
|
+
* interval handle (caller clearInterval()s it in the request's finally).
|
|
1780
|
+
*/
|
|
1781
|
+
function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setIntervalFn = setInterval } = {}) {
|
|
1782
|
+
let lastBytes = res.socket?.bytesWritten ?? 0;
|
|
1783
|
+
let lastProgressAt = now();
|
|
1784
|
+
const timer = setIntervalFn(() => {
|
|
1785
|
+
const bytes = res.socket?.bytesWritten ?? lastBytes;
|
|
1786
|
+
if (bytes !== lastBytes) { lastBytes = bytes; lastProgressAt = now(); return; }
|
|
1787
|
+
if (now() - lastProgressAt >= idleMs && !res.writableEnded && !res.destroyed) {
|
|
1788
|
+
console.error(`[Maxpool] Request ${reqId} — no write progress for ${Math.round(idleMs / 1000)}s (backstop reaper); force-aborting a stuck request to free its account slot`);
|
|
1789
|
+
res.destroy();
|
|
1790
|
+
}
|
|
1791
|
+
}, Math.min(60_000, idleMs));
|
|
1792
|
+
timer.unref?.();
|
|
1793
|
+
return timer;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1763
1796
|
async function streamResponse(webStream, res, status, responseHeaders, accountIndex, accountManager, streamLog, requestInfo = {}, idleMs = STREAM_IDLE_MS) {
|
|
1764
1797
|
const reader = webStream.getReader();
|
|
1765
1798
|
const decoder = new TextDecoder();
|
package/src/tui.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
-
import { fetchProfile, loginOAuth } from './oauth.js';
|
|
2
|
+
import { fetchProfile, loginOAuth, tokenFingerprint } from './oauth.js';
|
|
3
3
|
import { appendEventLog } from './event-log.js';
|
|
4
4
|
|
|
5
5
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
@@ -398,7 +398,12 @@ export class TUI {
|
|
|
398
398
|
this._confirm(
|
|
399
399
|
'Restart Maxpool?',
|
|
400
400
|
'Pause new requests, drain active work, then start the updated server.',
|
|
401
|
-
|
|
401
|
+
// Do NOT stop the TUI here — let the reload path own it: cold restart stops
|
|
402
|
+
// it in restartWorkerNow, a seamless reload in releaseBatonAndDrain (on
|
|
403
|
+
// MSG_RELEASE). If the reload ROLLS BACK (new worker fails to boot), neither
|
|
404
|
+
// fires, so the TUI keeps rendering instead of being stranded in plain-log
|
|
405
|
+
// mode. (The worker also self-heals admission — see index.js reload watchdog.)
|
|
406
|
+
() => { this.onRestart?.(); },
|
|
402
407
|
);
|
|
403
408
|
} else if (k === 'a') {
|
|
404
409
|
this.mode = 'accounts';
|
|
@@ -725,6 +730,13 @@ export class TUI {
|
|
|
725
730
|
throw error;
|
|
726
731
|
}
|
|
727
732
|
this._addLog(`Updated account "${name}"`);
|
|
733
|
+
// Confirm the fresh token actually reached DISK (saveConfig awaited above +
|
|
734
|
+
// the AM was updated before it, so disk now holds creds.refreshToken). This
|
|
735
|
+
// is the verifiable proof a re-auth "stuck" — its fp must match the on-disk
|
|
736
|
+
// token; a later "REJECTED sent fp=<other>" then means an upstream revocation,
|
|
737
|
+
// not a lost persist. console.log (not _addLog) so it's VISIBLE during the
|
|
738
|
+
// stopped-TUI login flow, mirroring the sibling "Persisted rotated token" line.
|
|
739
|
+
console.log(`[Maxpool] Re-authenticated "${name}" — fresh token persisted (fp=${tokenFingerprint(creds.refreshToken)})`);
|
|
728
740
|
return { updated: true, name };
|
|
729
741
|
} else {
|
|
730
742
|
this.config.accounts.push(entry);
|
|
@@ -948,12 +960,11 @@ export class TUI {
|
|
|
948
960
|
lines.push('');
|
|
949
961
|
// Aligned column header (dim + underline so it reads as chrome, not data). It
|
|
950
962
|
// labels the fixed columns that have no inline label; the Ses/Wk/Tok/Req and
|
|
951
|
-
// Now/15m/1h labels stay inline per row.
|
|
952
|
-
//
|
|
963
|
+
// Now/15m/1h labels stay inline per row. It sits DIRECTLY above the rows — the
|
|
964
|
+
// abbreviation glossary is a FOOTER below the rows (a caption), never between
|
|
965
|
+
// the header and the data (a left-aligned sentence there reads as a broken,
|
|
966
|
+
// misaligned second header).
|
|
953
967
|
lines.push(dimUnderline(acctHeader(W)));
|
|
954
|
-
if (W >= 88) {
|
|
955
|
-
lines.push(' ' + dim('Ses 5h · Wk 7d · Now in-flight (weight) · 15m/1h served (avg · f fails)'));
|
|
956
|
-
}
|
|
957
968
|
const showBoth = W >= 70;
|
|
958
969
|
const bw = showBoth
|
|
959
970
|
? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
|
|
@@ -962,6 +973,11 @@ export class TUI {
|
|
|
962
973
|
for (let i = 0; i < this.am.accounts.length; i++) {
|
|
963
974
|
lines.push(this._renderAcct(i, bw, showBoth));
|
|
964
975
|
}
|
|
976
|
+
// Glossary FOOTER (expands the abbreviations the header + inline labels can't
|
|
977
|
+
// spell out). Below the rows so it never breaks the header↔column alignment.
|
|
978
|
+
if (W >= 88) {
|
|
979
|
+
lines.push(' ' + dim('Ses 5h · Wk 7d · Now in-flight (weight) · 15m/1h served (avg · f fails)'));
|
|
980
|
+
}
|
|
965
981
|
}
|
|
966
982
|
|
|
967
983
|
// ── Activity header
|