@crawlee/browser-pool 4.0.0-beta.170 → 4.0.0-beta.172

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.
@@ -138,6 +138,23 @@ export declare abstract class BrowserController<Library extends CommonLibrary =
138
138
  * @ignore
139
139
  */
140
140
  newPage(pageOptions?: NewPageOptions): Promise<NewPageResult>;
141
+ /**
142
+ * Accounts for a page no longer being open. Callable from either side and safe to call twice:
143
+ * the page's own `close` event, or the pool when it gives up on a close that never settles.
144
+ * Tracked in a `WeakSet` rather than via `isClosed()`, because that reports the same event.
145
+ * @internal
146
+ */
147
+ registerPageClosed(page: NewPageResult): void;
148
+ /**
149
+ * Registers cleanup belonging to a single page: an anonymizing proxy server, an incognito
150
+ * context. It runs from `registerPageClosed`, so it happens whether the page closed on its own
151
+ * or the pool gave up on a close that never settled, and it runs at most once.
152
+ *
153
+ * Hanging it off the page's `close` event instead leaks it in exactly the case this controller
154
+ * now handles, because that event does not arrive for a page the browser never destroyed.
155
+ * @ignore
156
+ */
157
+ protected registerPageTeardown(page: NewPageResult, teardown: () => Promise<void>): void;
141
158
  setCookies(page: NewPageResult, cookies: Cookie[]): Promise<void>;
142
159
  getCookies(page: NewPageResult): Promise<Cookie[]>;
143
160
  /**
@@ -34,6 +34,8 @@ export class BrowserController extends TypedEmitter {
34
34
  proxyUrl;
35
35
  isActive = false;
36
36
  activePages = 0;
37
+ #closedPages = new WeakSet();
38
+ #pageTeardowns = new WeakMap();
37
39
  totalPages = 0;
38
40
  lastPageOpenedAt = Date.now();
39
41
  #activate;
@@ -126,6 +128,37 @@ export class BrowserController extends TypedEmitter {
126
128
  this.lastPageOpenedAt = Date.now();
127
129
  return page;
128
130
  }
131
+ /**
132
+ * Accounts for a page no longer being open. Callable from either side and safe to call twice:
133
+ * the page's own `close` event, or the pool when it gives up on a close that never settles.
134
+ * Tracked in a `WeakSet` rather than via `isClosed()`, because that reports the same event.
135
+ * @internal
136
+ */
137
+ registerPageClosed(page) {
138
+ if (this.#closedPages.has(page))
139
+ return;
140
+ this.#closedPages.add(page);
141
+ this.activePages--;
142
+ const teardown = this.#pageTeardowns.get(page);
143
+ if (!teardown)
144
+ return;
145
+ this.#pageTeardowns.delete(page);
146
+ teardown().catch((error) => {
147
+ this.log.debug(`Could not clean up after a closed page.\nCause:${error.message}`, { id: this.id });
148
+ });
149
+ }
150
+ /**
151
+ * Registers cleanup belonging to a single page: an anonymizing proxy server, an incognito
152
+ * context. It runs from `registerPageClosed`, so it happens whether the page closed on its own
153
+ * or the pool gave up on a close that never settled, and it runs at most once.
154
+ *
155
+ * Hanging it off the page's `close` event instead leaks it in exactly the case this controller
156
+ * now handles, because that event does not arrive for a page the browser never destroyed.
157
+ * @ignore
158
+ */
159
+ registerPageTeardown(page, teardown) {
160
+ this.#pageTeardowns.set(page, teardown);
161
+ }
129
162
  async setCookies(page, cookies) {
130
163
  return this._setCookies(page, cookies);
131
164
  }
package/browser-pool.js CHANGED
@@ -353,8 +353,10 @@ export class BrowserPool extends TypedEmitter {
353
353
  if (options?.error instanceof SessionError) {
354
354
  this.retireBrowserByPage(page);
355
355
  }
356
- // Puppeteer 25+ can hang `page.close()` indefinitely when the page's navigation was aborted, don't let it block the crawler.
357
- await addTimeoutToPromise(async () => page.close(), PAGE_CLOSE_TIMEOUT_MILLIS, `page.close() timed out after ${PAGE_CLOSE_TIMEOUT_MILLIS / 1000} seconds`);
356
+ // The bound on this lives in `overridePageClose`, where it covers the pool's own
357
+ // bookkeeping as well. Wrapping it here instead would time out the override and abandon
358
+ // that bookkeeping, leaving the browser holding a slot it can never get back.
359
+ await page.close();
358
360
  }
359
361
  /**
360
362
  * Extracts the relevant state (currently just cookies) from a page via its
@@ -544,11 +546,45 @@ export class BrowserPool extends TypedEmitter {
544
546
  const browserController = this.pageToBrowserController.get(page);
545
547
  const pageId = this.getPageId(page);
546
548
  page.close = async (...args) => {
547
- await this.executeHooks(this.prePageCloseHooks, page, browserController);
548
- await originalPageClose.apply(page, args).catch((err) => {
549
- this.#log.debug(`Could not close page.\nCause:${err.message}`, { id: browserController.id });
550
- });
551
- await this.executeHooks(this.postPageCloseHooks, pageId, browserController);
549
+ // A browser can acknowledge the close and then never destroy the target, in which case
550
+ // this never settles and the page's own `close` event never fires either (Chromium
551
+ // 536385539). Bound the whole sequence so nothing here can hang the caller, then
552
+ // reconcile the pool regardless of the outcome, so a page that refuses to close cannot
553
+ // leave the pool believing it is still open. `Promise.race` rather than
554
+ // `addTimeoutToPromise`: the latter inherits its AbortController from the calling frame,
555
+ // so a timeout here would cancel the task of whoever awaited `page.close()`.
556
+ let pageClosed = false;
557
+ const closing = (async () => {
558
+ await this.executeHooks(this.prePageCloseHooks, page, browserController);
559
+ await originalPageClose.apply(page, args).catch((err) => {
560
+ this.#log.debug(`Could not close page.\nCause:${err.message}`, { id: browserController.id });
561
+ });
562
+ // Whether the page itself is gone. Tracked separately from the sequence finishing,
563
+ // so that a slow hook does not get the browser retired.
564
+ pageClosed = true;
565
+ await this.executeHooks(this.postPageCloseHooks, pageId, browserController);
566
+ })();
567
+ let timeout;
568
+ const finished = await Promise.race([
569
+ closing.then(() => true, (err) => {
570
+ this.#log.warning(`Closing a page failed, releasing it from the pool anyway.\nCause:${err.message}`, { id: browserController.id, pageId });
571
+ return true;
572
+ }),
573
+ new Promise((resolve) => {
574
+ timeout = setTimeout(() => resolve(false), PAGE_CLOSE_TIMEOUT_MILLIS);
575
+ }),
576
+ ]);
577
+ clearTimeout(timeout);
578
+ if (!finished) {
579
+ this.#log.warning(`Closing a page did not finish within ${PAGE_CLOSE_TIMEOUT_MILLIS / 1000} seconds, ` +
580
+ 'releasing it from the pool anyway.', { id: browserController.id, pageId });
581
+ }
582
+ if (!pageClosed) {
583
+ // The page is still attached, so this browser cannot be trusted with more work.
584
+ // Retiring it lets the reclamation below close the process once its pages drain.
585
+ this.retireBrowserController(browserController);
586
+ }
587
+ browserController.registerPageClosed(page);
552
588
  this.pages.delete(pageId);
553
589
  this.closeRetiredBrowserWithNoPages(browserController);
554
590
  this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/browser-pool",
3
- "version": "4.0.0-beta.170",
3
+ "version": "4.0.0-beta.172",
4
4
  "description": "Rotate multiple browsers using popular automation libraries such as Playwright or Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -31,9 +31,9 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@apify/timeout": "^1.0.1",
34
- "@crawlee/core": "4.0.0-beta.170",
35
- "@crawlee/types": "4.0.0-beta.170",
36
- "@crawlee/utils": "4.0.0-beta.170",
34
+ "@crawlee/core": "4.0.0-beta.172",
35
+ "@crawlee/types": "4.0.0-beta.172",
36
+ "@crawlee/utils": "4.0.0-beta.172",
37
37
  "fingerprint-generator": "^2.1.68",
38
38
  "fingerprint-injector": "^2.1.68",
39
39
  "lodash.merge": "^4.6.2",
@@ -64,5 +64,5 @@
64
64
  }
65
65
  }
66
66
  },
67
- "gitHead": "86e1c891da983125e9e18f1a2327fbcf5e0f5fc2"
67
+ "gitHead": "619809c59ea633121f3aa9c8f333efeb9c6167af"
68
68
  }
@@ -46,9 +46,9 @@ export class PlaywrightController extends BrowserController {
46
46
  }
47
47
  try {
48
48
  const page = await this.browser.newPage(contextOptions);
49
- page.once('close', async () => {
50
- this.activePages--;
51
- await close();
49
+ this.registerPageTeardown(page, close);
50
+ page.once('close', () => {
51
+ this.registerPageClosed(page);
52
52
  });
53
53
  tryCancel();
54
54
  return page;
@@ -51,17 +51,18 @@ export class PuppeteerController extends BrowserController {
51
51
  tryCancel();
52
52
  }
53
53
  */
54
- page.once('close', async () => {
55
- this.activePages--;
56
- try {
57
- await context.close();
58
- }
59
- catch (error) {
54
+ this.registerPageTeardown(page, async () => {
55
+ // The proxy server is not chained behind the context close: that can hang on
56
+ // the same target the page hung on, and the proxy runs in this process, so it
57
+ // has to be reclaimed either way.
58
+ const contextClosed = context.close().catch((error) => {
60
59
  this.log.exception(error, 'Failed to close context.');
61
- }
62
- finally {
63
- await close();
64
- }
60
+ });
61
+ await close();
62
+ await contextClosed;
63
+ });
64
+ page.once('close', () => {
65
+ this.registerPageClosed(page);
65
66
  });
66
67
  return page;
67
68
  }
@@ -73,7 +74,7 @@ export class PuppeteerController extends BrowserController {
73
74
  const page = await this.browser.newPage();
74
75
  tryCancel();
75
76
  page.once('close', () => {
76
- this.activePages--;
77
+ this.registerPageClosed(page);
77
78
  });
78
79
  return page;
79
80
  }