maxpool 1.5.2 → 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/account-manager.js +29 -2
- package/src/config.js +4 -0
- package/src/index.js +65 -13
- package/src/oauth.js +7 -0
- package/src/server.js +4 -1
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -48,6 +48,10 @@ const DEFAULT_SCHEDULER = {
|
|
|
48
48
|
safetyMaxGlobalActive: 150,
|
|
49
49
|
cooldownMs: 30_000,
|
|
50
50
|
maxCooldownMs: 15 * 60_000,
|
|
51
|
+
// Fixed cooldown for NETWORK-class failures (lost connectivity / token-refresh
|
|
52
|
+
// fetch-failed). Short + non-escalating so the fleet auto-recovers seconds after
|
|
53
|
+
// connectivity returns, instead of the exponential maxCooldownMs bench.
|
|
54
|
+
networkCooldownMs: 5_000,
|
|
51
55
|
weeklySoftThreshold: 0.65,
|
|
52
56
|
weeklyReserveThreshold: 0.85,
|
|
53
57
|
weeklyCriticalThreshold: 0.95,
|
|
@@ -1698,9 +1702,28 @@ export class AccountManager {
|
|
|
1698
1702
|
console.log(`[Maxpool] Account "${account.name}" disabled after HTTP ${status} (${reason})`);
|
|
1699
1703
|
}
|
|
1700
1704
|
|
|
1701
|
-
markTransientFailure(accountIndex, reason = 'transient_error') {
|
|
1705
|
+
markTransientFailure(accountIndex, reason = 'transient_error', { network = false } = {}) {
|
|
1702
1706
|
const account = this.accounts[accountIndex];
|
|
1703
1707
|
if (!account) return;
|
|
1708
|
+
|
|
1709
|
+
if (network) {
|
|
1710
|
+
// A network-class failure (lost connectivity / token-refresh `fetch failed`)
|
|
1711
|
+
// is a FLEET-WIDE condition, not this account's fault — every account fails at
|
|
1712
|
+
// once. Use a SHORT FIXED cooldown so the whole fleet retries within seconds of
|
|
1713
|
+
// connectivity returning and recovers AUTOMATICALLY, never the exponential
|
|
1714
|
+
// 15-min bench that stranded the fleet long after a multi-hour outage and forced
|
|
1715
|
+
// a manual restart (2026-06-29 hotel-network nightly cutoff). Deliberately does
|
|
1716
|
+
// NOT bump consecutiveFailures: a network blip must not poison the scoring
|
|
1717
|
+
// penalty (_scoreAccount) or prime the next REAL per-account failure for the max
|
|
1718
|
+
// cooldown — so the counter stays a pure request-health signal and needs no reset.
|
|
1719
|
+
account.failedRequests++;
|
|
1720
|
+
account.lastError = reason;
|
|
1721
|
+
account.lastErrorAt = Date.now();
|
|
1722
|
+
account.cooldownUntil = Date.now() + this.scheduler.networkCooldownMs;
|
|
1723
|
+
console.log(`[Maxpool] Account "${account.name}" cooling down for ${Math.ceil(this.scheduler.networkCooldownMs / 1000)}s after ${reason} (network — short fixed, auto-recovers)`);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1704
1727
|
const failures = Math.max(1, account.consecutiveFailures + 1);
|
|
1705
1728
|
const cooldown = Math.min(
|
|
1706
1729
|
this.scheduler.maxCooldownMs,
|
|
@@ -1812,7 +1835,11 @@ export class AccountManager {
|
|
|
1812
1835
|
// a failed proactive refresh shouldn't kill a still-valid token
|
|
1813
1836
|
if (!account.expiresAt || Date.now() >= account.expiresAt) {
|
|
1814
1837
|
if (err.retryable) {
|
|
1815
|
-
|
|
1838
|
+
// A retryable refresh failure with NO HTTP status is a network/connectivity
|
|
1839
|
+
// failure (fetch failed / timeout) → short fixed cooldown so it auto-recovers
|
|
1840
|
+
// when the network returns. A retryable HTTP status (429 / 5xx from the OAuth
|
|
1841
|
+
// endpoint) is server-side → keep the exponential backoff.
|
|
1842
|
+
this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`, { network: !err.status });
|
|
1816
1843
|
} else {
|
|
1817
1844
|
this.markAuthFailed(accountIndex, err.status || 401, 'token_refresh_failed');
|
|
1818
1845
|
}
|
package/src/config.js
CHANGED
|
@@ -59,6 +59,10 @@ export function createDefaultConfig() {
|
|
|
59
59
|
safetyMaxGlobalActive: 150,
|
|
60
60
|
cooldownMs: 30_000,
|
|
61
61
|
maxCooldownMs: 15 * 60_000,
|
|
62
|
+
// Fixed cooldown for network-class failures (lost connectivity / token-refresh
|
|
63
|
+
// fetch-failed) — short + non-escalating so the fleet auto-recovers seconds after
|
|
64
|
+
// connectivity returns, never the exponential maxCooldownMs bench.
|
|
65
|
+
networkCooldownMs: 5_000,
|
|
62
66
|
// Weekly (7d) quota tiers — how aggressively to de-prioritise an account
|
|
63
67
|
// as its weekly usage climbs. Each is a fraction (0..1) of the weekly
|
|
64
68
|
// limit. Below soft = full speed; soft..reserve = mild penalty;
|
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 }) {
|
package/src/oauth.js
CHANGED
|
@@ -17,6 +17,11 @@ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
|
17
17
|
export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
|
|
18
18
|
const maxRetries = 2;
|
|
19
19
|
const baseDelayMs = 500;
|
|
20
|
+
// Bound each refresh POST so a hung connect during an outage can't pin the
|
|
21
|
+
// single-flight _refreshPromise indefinitely (which would stall that account's
|
|
22
|
+
// recovery). Comfortably above a normal refresh latency; a timeout is classified
|
|
23
|
+
// as a network error below and retried / short-cooled.
|
|
24
|
+
const perAttemptTimeoutMs = 10_000;
|
|
20
25
|
|
|
21
26
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
22
27
|
try {
|
|
@@ -37,6 +42,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
37
42
|
refresh_token: refreshToken,
|
|
38
43
|
client_id: DEFAULT_CLIENT_ID,
|
|
39
44
|
}),
|
|
45
|
+
signal: AbortSignal.timeout(perAttemptTimeoutMs),
|
|
40
46
|
});
|
|
41
47
|
|
|
42
48
|
if (!res.ok) {
|
|
@@ -60,6 +66,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
60
66
|
} catch (err) {
|
|
61
67
|
const isNetworkError = err instanceof Error &&
|
|
62
68
|
(err.message.includes('fetch failed') ||
|
|
69
|
+
err.name === 'TimeoutError' || err.name === 'AbortError' ||
|
|
63
70
|
(err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
|
|
64
71
|
err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
|
|
65
72
|
|
package/src/server.js
CHANGED
|
@@ -855,7 +855,10 @@ async function forwardRequest(
|
|
|
855
855
|
err.message.includes('terminated'));
|
|
856
856
|
|
|
857
857
|
if (isTransient) {
|
|
858
|
-
|
|
858
|
+
// Network-class (ECONNRESET / fetch failed / timeout / terminated): short fixed
|
|
859
|
+
// cooldown, no exponential escalation — the fleet auto-recovers seconds after
|
|
860
|
+
// connectivity returns instead of being benched for up to 15 min.
|
|
861
|
+
accountManager.markTransientFailure(account.index, err.code || err.message || 'network_error', { network: true });
|
|
859
862
|
accountManager.releaseAccount(lease);
|
|
860
863
|
excludedIndexes.add(account.index);
|
|
861
864
|
if (canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|