Skip to content

Commit 0a8f883

Browse files
authored
refactor: use cancellable timeouts via @apify/timeout (#62)
* refactor: use cancellable timeouts via `@apify/timeout` Related: apify/crawlee#1102 apify/crawlee#1216 * chore: fix spacing in package.json * chore: require newer @apify/timeout * chore: adjust rootDir for tests * chore: add test for abortable timeouts * refactor: do not try to close inactive browser controllers
1 parent 30bfad2 commit 0a8f883

File tree

9 files changed

+57
-24
lines changed

9 files changed

+57
-24
lines changed

jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default async (): Promise<Config.InitialOptions> => ({
55
preset: 'ts-jest',
66
testEnvironment: 'node',
77
testRunner: 'jest-circus/runner',
8-
testTimeout: 20_000,
8+
testTimeout: 30_000,
99
collectCoverage: true,
1010
collectCoverageFrom: [
1111
'**/src/**/*.ts',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
},
4545
"dependencies": {
4646
"@apify/log": "^1.1.1",
47+
"@apify/timeout": "^0.2.1",
4748
"fingerprint-generator": "^1.0.0",
4849
"fingerprint-injector": "^1.0.0",
4950
"lodash.merge": "^4.6.2",

src/abstract-classes/browser-controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { nanoid } from 'nanoid';
22
import { TypedEmitter } from 'tiny-typed-emitter';
3+
import { tryCancel } from '@apify/timeout';
34
import { BROWSER_CONTROLLER_EVENTS } from '../events';
45
import { LaunchContext } from '../launch-context';
56
import { log } from '../logger';
@@ -207,7 +208,9 @@ export abstract class BrowserController<
207208
this.totalPages++;
208209
await this.isActivePromise;
209210
const page = await this._newPage(pageOptions);
211+
tryCancel();
210212
this.lastPageOpenedAt = Date.now();
213+
211214
return page;
212215
}
213216

src/browser-pool.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import pLimit from 'p-limit';
22
import { nanoid } from 'nanoid';
33
import ow from 'ow';
44
import { TypedEmitter } from 'tiny-typed-emitter';
5+
import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
56
import { Fingerprint, FingerprintInjector } from 'fingerprint-injector';
67
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
78
// @ts-expect-error no types for this package yet
@@ -12,7 +13,7 @@ import { BrowserPlugin } from './abstract-classes/browser-plugin';
1213
import { BROWSER_POOL_EVENTS } from './events';
1314
import { LaunchContext } from './launch-context';
1415
import { log } from './logger';
15-
import { addTimeoutToPromise, InferBrowserPluginArray, UnwrapPromise } from './utils';
16+
import { InferBrowserPluginArray, UnwrapPromise } from './utils';
1617
import { createFingerprintPreLaunchHook, createPrePageCreateHook, createPostPageCreateHook } from './fingerprinting/hooks';
1718
import { FingerprintGeneratorOptions } from './fingerprinting/types';
1819

@@ -394,6 +395,7 @@ export class BrowserPool<
394395
return this.limiter(async () => {
395396
let browserController = this._pickBrowserWithFreeCapacity(browserPlugin);
396397
if (!browserController) browserController = await this._launchBrowser(id, { browserPlugin });
398+
tryCancel();
397399

398400
return this._createPageForBrowser(id, browserController, pageOptions, proxyUrl);
399401
});
@@ -417,6 +419,7 @@ export class BrowserPool<
417419
}
418420

419421
const browserController = await this._launchBrowser(id, { launchOptions, browserPlugin });
422+
tryCancel();
420423
return this._createPageForBrowser(id, browserController, pageOptions);
421424
}
422425

@@ -501,6 +504,7 @@ export class BrowserPool<
501504
// It's not ideal though, we need to come up with a better API.
502505
// eslint-disable-next-line dot-notation -- accessing private property
503506
await browserController['isActivePromise'];
507+
tryCancel();
504508

505509
const finalPageOptions = browserController.launchContext.useIncognitoPages ? pageOptions : undefined;
506510

@@ -509,15 +513,17 @@ export class BrowserPool<
509513
}
510514

511515
await this._executeHooks(this.prePageCreateHooks, pageId, browserController, finalPageOptions);
516+
tryCancel();
512517

513518
let page: PageReturn;
514519

515520
try {
516521
page = await addTimeoutToPromise(
517-
browserController.newPage(finalPageOptions),
522+
() => browserController.newPage(finalPageOptions),
518523
this.operationTimeoutMillis,
519524
'browserController.newPage() timed out.',
520525
) as PageReturn;
526+
tryCancel();
521527

522528
this.pages.set(pageId, page);
523529
this.pageIds.set(page, pageId);
@@ -536,6 +542,7 @@ export class BrowserPool<
536542
}
537543

538544
await this._executeHooks(this.postPageCreateHooks, page, browserController);
545+
tryCancel();
539546

540547
this.emit(BROWSER_POOL_EVENTS.PAGE_CREATED, page); // @TODO: CONSIDER renaming this event.
541548

@@ -581,12 +588,9 @@ export class BrowserPool<
581588
*/
582589
async closeAllBrowsers(): Promise<void> {
583590
const controllers = this._getAllBrowserControllers();
584-
585-
const promises: Promise<void>[] = [];
586-
587-
controllers.forEach((controller) => {
588-
promises.push(controller.close());
589-
});
591+
const promises = [...controllers]
592+
.filter((controller) => controller.isActive)
593+
.map((controller) => controller.close());
590594

591595
await Promise.all(promises);
592596
}
@@ -632,7 +636,9 @@ export class BrowserPool<
632636
// If the hooks or the launch fails, we need to delete the controller,
633637
// because otherwise it would be stuck in limbo without a browser.
634638
await this._executeHooks(this.preLaunchHooks, pageId, launchContext);
639+
tryCancel();
635640
const browser = await browserPlugin.launch(launchContext);
641+
tryCancel();
636642
browserController.assignBrowser(browser, launchContext);
637643
} catch (err) {
638644
this.activeBrowserControllers.delete(browserController);
@@ -656,6 +662,7 @@ export class BrowserPool<
656662
throw err;
657663
}
658664

665+
tryCancel();
659666
browserController.activate();
660667
this.emit(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, browserController);
661668

src/playwright/playwright-controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Browser, BrowserType, Page } from 'playwright';
2+
import { tryCancel } from '@apify/timeout';
23
import { BrowserController, Cookie } from '../abstract-classes/browser-controller';
34

45
export class PlaywrightController extends BrowserController<BrowserType, Parameters<BrowserType['launch']>[0], Browser> {
@@ -27,6 +28,7 @@ export class PlaywrightController extends BrowserController<BrowserType, Paramet
2728
}
2829

2930
const page = await this.browser.newPage(contextOptions);
31+
tryCancel();
3032

3133
page.once('close', () => {
3234
this.activePages--;

src/puppeteer/puppeteer-controller.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { tryCancel } from '@apify/timeout';
12
import type Puppeteer from './puppeteer-proxy-per-page';
23
import { BrowserController, Cookie } from '../abstract-classes/browser-controller';
34
import { log } from '../logger';
@@ -29,13 +30,16 @@ export class PuppeteerController extends BrowserController<typeof Puppeteer> {
2930
}
3031

3132
const context = await this.browser.createIncognitoBrowserContext(contextOptions);
33+
tryCancel();
3234
const page = await context.newPage();
35+
tryCancel();
3336

3437
if (contextOptions.proxyUsername || contextOptions.proxyPassword) {
3538
await page.authenticate({
3639
username: contextOptions.proxyUsername ?? '',
3740
password: contextOptions.proxyPassword ?? '',
3841
});
42+
tryCancel();
3943
}
4044

4145
page.once('close', async () => {
@@ -46,12 +50,14 @@ export class PuppeteerController extends BrowserController<typeof Puppeteer> {
4650
} catch (error: any) {
4751
log.exception(error, 'Failed to close context.');
4852
}
53+
tryCancel();
4954
});
5055

5156
return page;
5257
}
5358

5459
const page = await this.browser.newPage();
60+
tryCancel();
5561

5662
page.once('close', () => {
5763
this.activePages--;

src/utils.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
11
import type { PlaywrightPlugin, PuppeteerPlugin } from '.';
22
import type { BrowserPlugin } from './abstract-classes/browser-plugin';
33

4-
export function addTimeoutToPromise<T>(promise: Promise<T>, timeoutMillis: number, errorMessage: string): Promise<T> {
5-
return new Promise(async (resolve, reject) => { // eslint-disable-line
6-
const timeout = setTimeout(() => reject(new Error(errorMessage)), timeoutMillis);
7-
try {
8-
const data = await promise;
9-
resolve(data);
10-
} catch (err) {
11-
reject(err);
12-
} finally {
13-
clearTimeout(timeout);
14-
}
15-
});
16-
};
17-
184
export type UnwrapPromise<T> = T extends PromiseLike<infer R> ? UnwrapPromise<R> : T;
195

206
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars

test/browser-pool.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { promisify } from 'util';
55
import { Server as ProxyChainServer } from 'proxy-chain';
66
import puppeteer, { Page } from 'puppeteer';
77
import playwright from 'playwright';
8+
import { addTimeoutToPromise } from '@apify/timeout';
89
import { BrowserPool, PrePageCreateHook } from '../src/browser-pool';
910
import { PuppeteerPlugin } from '../src/puppeteer/puppeteer-plugin';
1011
import { PlaywrightPlugin } from '../src/playwright/playwright-plugin';
@@ -89,6 +90,33 @@ describe('BrowserPool', () => {
8990
expect(page.close).toBeDefined();
9091
});
9192

93+
test('should allow early aborting in case of outer timeout', async () => {
94+
const timeout = browserPool.operationTimeoutMillis;
95+
browserPool.operationTimeoutMillis = 500;
96+
// @ts-expect-error mocking private method
97+
const spy = jest.spyOn(BrowserPool.prototype, '_executeHooks');
98+
99+
await browserPool.newPage();
100+
expect(spy).toBeCalledTimes(4);
101+
spy.mockReset();
102+
103+
await expect(addTimeoutToPromise(
104+
() => browserPool.newPage(),
105+
10,
106+
'opening new page timed out',
107+
)).rejects.toThrowError('opening new page timed out');
108+
109+
// We terminated early enough so only preLaunchHooks were not executed,
110+
// thanks to `tryCancel()` calls after each await. If we did not run
111+
// inside `addTimeoutToPromise()`, this would not work and we would get
112+
// 4 calls instead of just one.
113+
expect(spy).toBeCalledTimes(1);
114+
115+
spy.mockRestore();
116+
browserPool.operationTimeoutMillis = timeout;
117+
browserPool.retireAllBrowsers();
118+
});
119+
92120
test('proxy sugar syntax', async () => {
93121
const pool = new BrowserPool({
94122
browserPlugins: [

test/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": "../tsconfig.json",
33
"compilerOptions": {
4-
"rootDir": "./",
4+
"rootDir": "../",
55
"noEmit": true,
66
"allowJs": true
77
},

0 commit comments

Comments
 (0)