diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index a34777d89..c1df57ba5 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -460,8 +460,9 @@ export default class Interpreter extends EventEmitter { for (const link of links) { // eslint-disable-next-line this.concurrency.addJob(async () => { + let newPage = null; try { - const newPage = await context.newPage(); + newPage = await context.newPage(); await newPage.goto(link); await newPage.waitForLoadState('networkidle'); await this.runLoop(newPage, this.initializedWorkflow!); @@ -470,6 +471,14 @@ export default class Interpreter extends EventEmitter { // but newPage(), goto() and waitForLoadState() don't (and will kill // the interpreter by throwing). this.log(e, Level.ERROR); + } finally { + if (newPage && !newPage.isClosed()) { + try { + await newPage.close(); + } catch (closeError) { + this.log('Failed to close enqueued page', Level.WARN); + } + } } }); } @@ -1463,41 +1472,57 @@ export default class Interpreter extends EventEmitter { * User-requested concurrency should be entirely managed by the concurrency manager, * e.g. via `enqueueLinks`. */ - p.on('popup', (popup) => { + const popupHandler = (popup) => { this.concurrency.addJob(() => this.runLoop(popup, workflowCopy)); - }); + }; + p.on('popup', popupHandler); /* eslint no-constant-condition: ["warn", { "checkLoops": false }] */ let loopIterations = 0; const MAX_LOOP_ITERATIONS = 1000; // Circuit breaker + + // Cleanup function to remove popup listener + const cleanup = () => { + try { + if (!p.isClosed()) { + p.removeListener('popup', popupHandler); + } + } catch (cleanupError) { + } + }; while (true) { if (this.isAborted) { this.log('Workflow aborted during step execution', Level.WARN); + cleanup(); return; } // Circuit breaker to prevent infinite loops if (++loopIterations > MAX_LOOP_ITERATIONS) { this.log('Maximum loop iterations reached, terminating to prevent infinite loop', Level.ERROR); + cleanup(); return; } // Checks whether the page was closed from outside, // or the workflow execution has been stopped via `interpreter.stop()` if (p.isClosed() || !this.stopper) { + cleanup(); return; } try { await p.waitForLoadState(); } catch (e) { + cleanup(); await p.close(); return; } if (workflowCopy.length === 0) { this.log('All actions completed. Workflow finished.', Level.LOG); + cleanup(); return; } @@ -1589,6 +1614,7 @@ export default class Interpreter extends EventEmitter { } } else { //await this.disableAdBlocker(p); + cleanup(); return; } } @@ -1681,4 +1707,44 @@ export default class Interpreter extends EventEmitter { throw new Error('Cannot stop, there is no running workflow!'); } } + /** + * Cleanup method to release resources and prevent memory leaks + * Call this when the interpreter is no longer needed + */ + public async cleanup(): Promise { + try { + // Stop any running workflows first + if (this.stopper) { + try { + await this.stop(); + } catch (error: any) { + this.log(`Error stopping workflow during cleanup: ${error.message}`, Level.WARN); + } + } + + // Clear ad-blocker resources + if (this.blocker) { + try { + this.blocker = null; + this.log('Ad-blocker resources cleared', Level.DEBUG); + } catch (error: any) { + this.log(`Error cleaning up ad-blocker: ${error.message}`, Level.WARN); + } + } + + // Clear accumulated data to free memory + this.cumulativeResults = []; + this.namedResults = {}; + this.serializableDataByType = { scrapeList: {}, scrapeSchema: {} }; + + // Reset state + this.isAborted = false; + this.initializedWorkflow = null; + + this.log('Interpreter cleanup completed', Level.DEBUG); + } catch (error: any) { + this.log(`Error during interpreter cleanup: ${error.message}`, Level.ERROR); + throw error; + } + } } \ No newline at end of file