Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions src/auth/AuthSwitcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ class AuthSwitcher {
// return available[nextIndexInArray];
// }

_withTimeout(promise, ms, message) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

async switchToNextAuth() {
const available = this.authSource.getRotationIndices();

Expand All @@ -75,7 +83,11 @@ class AuthSwitcher {
this.logger.info("==================================================");

try {
await this.browserManager.launchOrSwitchContext(singleIndex);
await this._withTimeout(
this.browserManager.launchOrSwitchContext(singleIndex),
60_000,
`Single account #${singleIndex} restart timed out after 60s`
);
this.resetCounters();
this.browserManager.rebalanceContextPool().catch(err => {
this.logger.error(`[Auth] Background rebalance failed: ${err.message}`);
Expand Down Expand Up @@ -131,8 +143,14 @@ class AuthSwitcher {

try {
// Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts
await this.browserManager.preCleanupForSwitch(accountIndex);
await this.browserManager.switchAccount(accountIndex);
await this._withTimeout(
(async () => {
await this.browserManager.preCleanupForSwitch(accountIndex);
await this.browserManager.switchAccount(accountIndex);
})(),
60_000,
`Account #${accountIndex} switch timed out after 60s`
);
this.resetCounters();
this.browserManager.rebalanceContextPool().catch(err => {
this.logger.error(`[Auth] Background rebalance failed: ${err.message}`);
Expand Down Expand Up @@ -166,8 +184,14 @@ class AuthSwitcher {

try {
// Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts
await this.browserManager.preCleanupForSwitch(originalStartAccount);
await this.browserManager.switchAccount(originalStartAccount);
await this._withTimeout(
(async () => {
await this.browserManager.preCleanupForSwitch(originalStartAccount);
await this.browserManager.switchAccount(originalStartAccount);
})(),
60_000,
`Fallback account #${originalStartAccount} switch timed out after 60s`
);
this.resetCounters();
this.browserManager.rebalanceContextPool().catch(err => {
this.logger.error(`[Auth] Background rebalance failed: ${err.message}`);
Expand Down Expand Up @@ -227,8 +251,14 @@ class AuthSwitcher {
try {
this.logger.info(`🔄 [Auth] Starting switch to specified account #${targetIndex}...`);
// Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts
await this.browserManager.preCleanupForSwitch(targetIndex);
await this.browserManager.switchAccount(targetIndex);
await this._withTimeout(
(async () => {
await this.browserManager.preCleanupForSwitch(targetIndex);
await this.browserManager.switchAccount(targetIndex);
})(),
60_000,
`Switch to account #${targetIndex} timed out after 60s`
);
this.resetCounters();
this.browserManager.rebalanceContextPool().catch(err => {
this.logger.error(`[Auth] Background rebalance failed: ${err.message}`);
Expand Down
197 changes: 183 additions & 14 deletions src/core/BrowserManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class BrowserManager {

// ConnectionRegistry reference (set after construction to avoid circular dependency)
this.connectionRegistry = null;
this._onAuthQueuesDrained = null;
this.pendingContextClosures = new Map();

// Background wakeup service status (instance-level, tracks this.page)
// Prevents multiple BackgroundWakeup instances from running simultaneously
Expand Down Expand Up @@ -148,7 +150,20 @@ class BrowserManager {
* @param {ConnectionRegistry} connectionRegistry - The ConnectionRegistry instance
*/
setConnectionRegistry(connectionRegistry) {
if (this.connectionRegistry && this._onAuthQueuesDrained) {
this.connectionRegistry.off("authQueuesDrained", this._onAuthQueuesDrained);
}
this.connectionRegistry = connectionRegistry;
if (this.connectionRegistry) {
this._onAuthQueuesDrained = authIndex => {
this._closePendingContextIfIdle(authIndex).catch(error => {
this.logger.error(
`[ContextPool] Failed to close pending context #${authIndex} after queue drain: ${error.message}`
);
});
};
this.connectionRegistry.on("authQueuesDrained", this._onAuthQueuesDrained);
}
}

/**
Expand Down Expand Up @@ -206,9 +221,7 @@ class BrowserManager {
// Check if background preload was aborted (only for background tasks)
if (isBackgroundTask && this._backgroundPreloadAbort) {
this.logger.info(`${logPrefix} WebSocket wait aborted (background preload aborted)`);
throw new Error(
`Context initialization aborted for index ${authIndex} (background preload aborted)`
);
throw new ContextAbortedError(authIndex, "background preload aborted");
}

// Read state fresh each iteration
Expand Down Expand Up @@ -1491,23 +1504,48 @@ class BrowserManager {
* @returns {Promise<void>} Resolves when the background task has been aborted and cleaned up
*/
async abortBackgroundPreload() {
if (!this._backgroundPreloadTask) {
const currentTask = this._backgroundPreloadTask;
if (!currentTask) {
return; // No task to abort
}

this.logger.info(`[ContextPool] Aborting background preload task...`);
this._backgroundPreloadAbort = true;
const timeoutMessage = "Background preload abort timed out after 10s";

try {
await this._backgroundPreloadTask;
await this._withTimeout(currentTask, 10_000, timeoutMessage);
} catch (error) {
// Ignore errors from aborted task
if (error.message === timeoutMessage) {
this.logger.warn(
`[ContextPool] Background preload did not stop within 10s, forcing browser/context pool reset.`
);
if (this.browser) {
await this.closeBrowser();
} else {
this._cleanupAllContexts();
}
if (this._backgroundPreloadTask === currentTask) {
this._backgroundPreloadTask = null;
}
return;
}

// Ignore non-timeout errors from aborted task
this.logger.debug(`[ContextPool] Background preload aborted: ${error.message}`);
}

this.logger.info(`[ContextPool] Background preload aborted successfully`);
}

_withTimeout(promise, ms, message) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

/**
* Background sequential initialization of contexts (fire-and-forget)
* Only one instance should be active at a time - new calls abort old ones
Expand Down Expand Up @@ -1537,6 +1575,105 @@ class BrowserManager {
});
}

_getActiveQueueCountForAuth(authIndex) {
if (!this.connectionRegistry?.getMessageQueueCountForAuth) {
return 0;
}
return this.connectionRegistry.getMessageQueueCountForAuth(authIndex);
}

_cancelPendingContextClosure(authIndex, reason = "closure_cancelled") {
if (!this.pendingContextClosures.has(authIndex)) {
return false;
}
this.pendingContextClosures.delete(authIndex);
this.logger.info(`[ContextPool] Cancelled pending close for context #${authIndex} (${reason}).`);
return true;
}

_scheduleContextClosureWhenIdle(authIndex, reason, activeQueueCount) {
if (!this.contexts.has(authIndex)) {
return false;
}
const existingReason = this.pendingContextClosures.get(authIndex);
if (existingReason) {
this.logger.debug(
`[ContextPool] Context #${authIndex} is already pending close (${existingReason}), active queues: ${activeQueueCount}`
);
return false;
}
this.pendingContextClosures.set(authIndex, reason);
this.logger.info(
`[ContextPool] Deferring close for busy context #${authIndex} (${activeQueueCount} active queue(s), reason: ${reason})`
);
return false;
}

async _closeContextForPoolIfPossible(authIndex, reason) {
const activeQueueCount = this._getActiveQueueCountForAuth(authIndex);
if (activeQueueCount > 0) {
return this._scheduleContextClosureWhenIdle(authIndex, reason, activeQueueCount);
}

this.pendingContextClosures.delete(authIndex);
await this.closeContext(authIndex);
return true;
}

async _closePendingContextIfIdle(authIndex) {
if (!this.pendingContextClosures.has(authIndex)) {
return false;
}
if (authIndex === this._currentAuthIndex) {
this.logger.debug(
`[ContextPool] Skipping pending close for context #${authIndex} because it is active again as current.`
);
return false;
}
if (!this.contexts.has(authIndex)) {
this.pendingContextClosures.delete(authIndex);
return false;
}

const activeQueueCount = this._getActiveQueueCountForAuth(authIndex);
if (activeQueueCount > 0) {
this.logger.debug(
`[ContextPool] Pending close for context #${authIndex} is still waiting on ${activeQueueCount} active queue(s).`
);
return false;
}

const pendingReason = this.pendingContextClosures.get(authIndex);
this.pendingContextClosures.delete(authIndex);
this.logger.info(
`[ContextPool] Closing deferred context #${authIndex} now that all queues are drained (reason: ${pendingReason}).`
);
await this.closeContext(authIndex);
return true;
}

async _flushPendingContextClosures() {
for (const authIndex of [...this.pendingContextClosures.keys()]) {
await this._closePendingContextIfIdle(authIndex);
}
}

_prioritizeContextsForRemoval(indices) {
const idle = [];
const busy = [];

for (const authIndex of indices) {
const activeQueueCount = this._getActiveQueueCountForAuth(authIndex);
if (activeQueueCount > 0) {
busy.push({ activeQueueCount, authIndex });
} else {
idle.push(authIndex);
}
}

return { busy, idle };
}

/**
* Internal method to execute the actual preload task
* @private
Expand Down Expand Up @@ -1759,15 +1896,32 @@ class BrowserManager {
}
}

// Remove contexts according to priority until we have enough space
const toRemove = removalPriority.slice(0, removeCount);
const { busy, idle } = this._prioritizeContextsForRemoval(removalPriority);
const toRemoveNow = idle.slice(0, removeCount);
const toDefer = busy.slice(0, Math.max(0, removeCount - toRemoveNow.length));

this.logger.info(
`[ContextPool] Pre-cleanup: removing ${toRemove.length} contexts before switch to #${targetAuthIndex}: [${toRemove}] (${this.contexts.size} ready + ${this.initializingContexts.size} initializing)`
`[ContextPool] Pre-cleanup before switch to #${targetAuthIndex}: immediate=[${toRemoveNow}], deferred=[${toDefer.map(
entry => `${entry.authIndex}:${entry.activeQueueCount}`
)}] (${this.contexts.size} ready + ${this.initializingContexts.size} initializing)`
);

for (const idx of toRemove) {
await this.closeContext(idx);
for (const idx of toRemoveNow) {
await this._closeContextForPoolIfPossible(idx, "pre_cleanup_for_switch");
}

for (const entry of toDefer) {
this._scheduleContextClosureWhenIdle(
entry.authIndex,
"pre_cleanup_for_switch",
entry.activeQueueCount
);
}

if (toDefer.length > 0) {
this.logger.warn(
`[ContextPool] Allowing temporary MAX_CONTEXTS overflow while busy contexts drain before switch to #${targetAuthIndex}.`
);
}
}

Expand Down Expand Up @@ -1840,12 +1994,20 @@ class BrowserManager {
// If a foreground task is running, _executePreloadTask will skip it (line 1382)
const candidates = ordered.filter(idx => !activeContexts.has(idx));

const { busy, idle } = this._prioritizeContextsForRemoval(toRemove);

this.logger.info(
`[ContextPool] Rebalance: targets=[${[...targets]}], remove=[${toRemove}], candidates=[${candidates}]`
`[ContextPool] Rebalance: targets=[${[...targets]}], removeNow=[${idle}], removeDeferred=[${busy.map(
entry => `${entry.authIndex}:${entry.activeQueueCount}`
)}], candidates=[${candidates}]`
);

for (const idx of toRemove) {
await this.closeContext(idx);
for (const idx of idle) {
await this._closeContextForPoolIfPossible(idx, "rebalance");
}

for (const entry of busy) {
this._scheduleContextClosureWhenIdle(entry.authIndex, "rebalance", entry.activeQueueCount);
}

// Preload candidates if we have room in the pool
Expand Down Expand Up @@ -2120,6 +2282,8 @@ class BrowserManager {
throw new Error(`Invalid authIndex: ${authIndex}. Must be >= 0.`);
}

this._cancelPendingContextClosure(authIndex, "context_reused");

// [Auth Switch] Save current auth data before switching
if (this.browser && this._currentAuthIndex >= 0 && this._currentAuthIndex !== authIndex) {
try {
Expand Down Expand Up @@ -2199,6 +2363,7 @@ class BrowserManager {

// Switch to new context
this._activateContext(contextData.context, contextData.page, authIndex);
await this._flushPendingContextClosures();

this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`);
return;
Expand Down Expand Up @@ -2254,6 +2419,7 @@ class BrowserManager {
const { context, page } = await this._initializeContext(authIndex, false);

this._activateContext(context, page, authIndex);
await this._flushPendingContextClosures();

// If this account was marked as expired but login succeeded, restore it
if (this.authSource.isExpired(authIndex)) {
Expand Down Expand Up @@ -2461,6 +2627,8 @@ class BrowserManager {
* @param {number} authIndex - The auth index to close
*/
async closeContext(authIndex) {
this.pendingContextClosures.delete(authIndex);

// If context is being initialized in background, signal abort and wait
if (this.initializingContexts.has(authIndex)) {
this.logger.info(`[Browser] Context #${authIndex} is being initialized, marking for abort and waiting...`);
Expand Down Expand Up @@ -2554,6 +2722,7 @@ class BrowserManager {
this.contexts.clear();
this.initializingContexts.clear();
this.abortedContexts.clear();
this.pendingContextClosures.clear();
this._wsInitState.clear();
this.context = null;
this.page = null;
Expand Down
Loading