Skip to content

Commit 72a9e13

Browse files
committed
fix(web-scraper): re-try local browser launch and do not cache failed launch promise
1 parent 3c62304 commit 72a9e13

4 files changed

Lines changed: 234 additions & 59 deletions

File tree

components/retrack-web-scraper/src/index.ts

Lines changed: 8 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
import { fastify } from 'fastify';
2-
import type { BrowserServer } from 'playwright-core';
32

43
import { registerRoutes } from './api/index.js';
5-
import type { LocalBrowserConfig } from './config.js';
64
import { configure } from './config.js';
7-
import { stopBrowserServer } from './utilities/browser.js';
8-
import { launchBrowserServer } from './utilities/browser.js';
5+
import { createBrowserServerManager } from './utilities/browser.js';
96

107
const config = configure();
118
if (!config.browser.chromium && !config.browser.firefox) {
129
throw new Error('At least one browser (Chromium or Firefox) should be configured.');
1310
}
1411

15-
const browserServer: {
16-
server?: Promise<BrowserServer>;
17-
shutdownInProgress?: Promise<void>;
18-
shutdownTimer?: NodeJS.Timeout;
19-
} = {};
20-
2112
const server = fastify({
2213
bodyLimit: config.server.bodyLimit,
2314
logger: config.isDev
@@ -26,52 +17,23 @@ const server = fastify({
2617
transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname' } },
2718
}
2819
: { level: config.logLevel },
29-
}).addHook('onClose', async () => {
30-
if (browserServer.server) {
31-
await browserServer.server.then((localServer) => stopBrowserServer(logger, localServer)).catch(() => {});
32-
}
3320
});
3421

3522
const logger = server.log;
3623
logger.debug(`Configuration: ${JSON.stringify(config, null, 2)}.`);
3724

25+
const browserServerManager = createBrowserServerManager(logger);
26+
server.addHook('onClose', async () => {
27+
await browserServerManager.close();
28+
});
29+
3830
await server.register(import('@fastify/compress'));
3931

4032
registerRoutes({
4133
server,
4234
config,
43-
isLocalBrowserServerRunning: () => !!browserServer.server,
44-
getLocalBrowserServer: async (locaConfig: LocalBrowserConfig) => {
45-
if (!browserServer.server) {
46-
browserServer.server = (browserServer.shutdownInProgress ?? Promise.resolve()).then(() =>
47-
launchBrowserServer(logger, locaConfig),
48-
);
49-
}
50-
51-
if (browserServer.shutdownTimer) {
52-
clearTimeout(browserServer.shutdownTimer);
53-
}
54-
browserServer.shutdownTimer = setTimeout(() => {
55-
if (!browserServer.server) {
56-
return;
57-
}
58-
59-
const browserServerInstance = browserServer.server;
60-
browserServer.server = undefined;
61-
62-
clearTimeout(browserServer.shutdownTimer);
63-
browserServer.shutdownTimer = undefined;
64-
65-
browserServer.shutdownInProgress = browserServerInstance
66-
.then((server) => stopBrowserServer(logger, server))
67-
.catch(() => {})
68-
.finally(() => {
69-
browserServer.shutdownInProgress = undefined;
70-
});
71-
}, locaConfig.ttlSec * 1000);
72-
73-
return browserServer.server;
74-
},
35+
isLocalBrowserServerRunning: () => browserServerManager.isRunning(),
36+
getLocalBrowserServer: (locaConfig) => browserServerManager.get(locaConfig),
7537
});
7638

7739
server.listen({ port: config.port, host: '0.0.0.0' }, (err, address) => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
import './api/status/get.test.js';
22
import './api/web_page/execute.test.js';
3+
import './utilities/browser.test.js';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as assert from 'node:assert/strict';
2+
import { mock, test } from 'node:test';
3+
import type { FastifyBaseLogger } from 'fastify';
4+
import type { BrowserServer } from 'playwright-core';
5+
6+
import type { LocalBrowserConfig } from '../config.js';
7+
import { createBrowserServerManager } from './browser.js';
8+
9+
function createLoggerMock(): FastifyBaseLogger {
10+
const logger = {
11+
info: () => {},
12+
error: () => {},
13+
warn: () => {},
14+
debug: () => {},
15+
trace: () => {},
16+
fatal: () => {},
17+
silent: () => {},
18+
level: 'silent',
19+
} as unknown as FastifyBaseLogger;
20+
(logger as unknown as { child: () => FastifyBaseLogger }).child = () => logger;
21+
return logger;
22+
}
23+
24+
function createServerMock(wsEndpoint: string) {
25+
return {
26+
wsEndpoint: () => wsEndpoint,
27+
close: mock.fn(() => Promise.resolve()),
28+
} as unknown as BrowserServer;
29+
}
30+
31+
const LOCAL_CONFIG: LocalBrowserConfig = {
32+
protocol: 'playwright',
33+
backend: 'chromium',
34+
executablePath: '/usr/local/bin/chromium',
35+
// Large TTL so the idle shutdown never fires during the test.
36+
ttlSec: 3600,
37+
headless: true,
38+
chromiumSandbox: false,
39+
};
40+
41+
await test('[browser server manager] launches a single shared server for concurrent callers', async () => {
42+
const server = createServerMock('ws://localhost/shared');
43+
const launch = mock.fn(() => Promise.resolve(server));
44+
const manager = createBrowserServerManager(createLoggerMock(), { launch, stop: () => Promise.resolve() });
45+
46+
const [first, second] = await Promise.all([manager.get(LOCAL_CONFIG), manager.get(LOCAL_CONFIG)]);
47+
48+
assert.strictEqual(first, server);
49+
assert.strictEqual(second, server);
50+
assert.strictEqual(launch.mock.callCount(), 1, 'expected a single launch shared across concurrent callers');
51+
52+
await manager.close();
53+
});
54+
55+
await test('[browser server manager] does not cache a failed launch and retries on the next request', async () => {
56+
const server = createServerMock('ws://localhost/recovered');
57+
let attempts = 0;
58+
const launch = mock.fn(() => {
59+
// First launch fails (simulating a transient browser crash), the second succeeds.
60+
attempts += 1;
61+
return attempts === 1 ? Promise.reject(new Error('Failed to launch browser.')) : Promise.resolve(server);
62+
});
63+
const manager = createBrowserServerManager(createLoggerMock(), { launch, stop: () => Promise.resolve() });
64+
65+
await assert.rejects(manager.get(LOCAL_CONFIG), /Failed to launch browser/);
66+
67+
// The previous failure must not be cached, so the manager reports no running server...
68+
assert.strictEqual(manager.isRunning(), false, 'a failed launch must not be cached');
69+
70+
// ...and the next request launches a fresh server successfully instead of replaying the failure.
71+
const recovered = await manager.get(LOCAL_CONFIG);
72+
assert.strictEqual(recovered, server);
73+
assert.strictEqual(launch.mock.callCount(), 2, 'expected the failed launch to be retried on the next request');
74+
75+
await manager.close();
76+
});
77+
78+
await test('[browser server manager] close stops the running server and resets state', async () => {
79+
const server = createServerMock('ws://localhost/closing');
80+
const stop = mock.fn(() => Promise.resolve());
81+
const manager = createBrowserServerManager(createLoggerMock(), { launch: () => Promise.resolve(server), stop });
82+
83+
await manager.get(LOCAL_CONFIG);
84+
assert.strictEqual(manager.isRunning(), true);
85+
86+
await manager.close();
87+
assert.strictEqual(stop.mock.callCount(), 1, 'expected the running server to be stopped on close');
88+
assert.strictEqual(manager.isRunning(), false);
89+
});

components/retrack-web-scraper/src/utilities/browser.ts

Lines changed: 136 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { setTimeout as delay } from 'node:timers/promises';
12
import type { FastifyBaseLogger } from 'fastify';
23
import type { BrowserServer, Logger } from 'playwright-core';
34
import { Diagnostics } from '../api/diagnostics.js';
@@ -16,6 +17,14 @@ export type BrowserProtocol = 'cdp' | 'playwright';
1617
// Timeout for connecting to a browser.
1718
const BROWSER_CONNECT_TIMEOUT_MS = 30000;
1819

20+
// Number of attempts to launch a local browser server before giving up. The browser process can
21+
// occasionally crash during startup (e.g. a transient `SIGSEGV` under momentary resource pressure
22+
// when many trackers fire at the same time), and such failures usually clear on an immediate retry.
23+
const BROWSER_LAUNCH_MAX_ATTEMPTS = 3;
24+
25+
// Delay between consecutive browser launch attempts.
26+
const BROWSER_LAUNCH_RETRY_DELAY_MS = 500;
27+
1928
/**
2029
* Connects to a remote browser over Playwright or CDP protocol:
2130
* * `browserType.connectOverCDP` is used to connect to a remote Chromium CDP session. In this case, Playwright
@@ -42,20 +51,33 @@ export async function launchBrowserServer(logger: FastifyBaseLogger, config: Loc
4251

4352
const { chromium, firefox } = await import('playwright-core');
4453
const backend = config.backend === 'chromium' ? chromium : firefox;
45-
try {
46-
const localServer = await backend.launchServer({
47-
executablePath: config.executablePath,
48-
headless: config.headless,
49-
args: ['--disable-web-security', '--disable-blink-features=AutomationControlled'],
50-
ignoreDefaultArgs: ['--enable-automation'],
51-
...(config.backend === 'chromium' ? { channel: 'chromium', chromiumSandbox: config.chromiumSandbox } : {}),
52-
});
53-
logger.info(`Browser server is running locally at ${localServer.wsEndpoint()}.`);
54-
return localServer;
55-
} catch (err) {
56-
logger.error(`Failed to run browser server locally: ${Diagnostics.errorMessage(err)}`);
57-
throw err;
54+
55+
let lastError: unknown;
56+
for (let attempt = 1; attempt <= BROWSER_LAUNCH_MAX_ATTEMPTS; attempt++) {
57+
try {
58+
const localServer = await backend.launchServer({
59+
executablePath: config.executablePath,
60+
headless: config.headless,
61+
args: ['--disable-web-security', '--disable-blink-features=AutomationControlled'],
62+
ignoreDefaultArgs: ['--enable-automation'],
63+
...(config.backend === 'chromium' ? { channel: 'chromium', chromiumSandbox: config.chromiumSandbox } : {}),
64+
});
65+
logger.info(`Browser server is running locally at ${localServer.wsEndpoint()}.`);
66+
return localServer;
67+
} catch (err) {
68+
lastError = err;
69+
logger.error(
70+
`Failed to run browser server locally (attempt ${attempt}/${BROWSER_LAUNCH_MAX_ATTEMPTS}): ${Diagnostics.errorMessage(
71+
err,
72+
)}`,
73+
);
74+
if (attempt < BROWSER_LAUNCH_MAX_ATTEMPTS) {
75+
await delay(BROWSER_LAUNCH_RETRY_DELAY_MS);
76+
}
77+
}
5878
}
79+
80+
throw lastError;
5981
}
6082

6183
export async function stopBrowserServer(logger: FastifyBaseLogger, server: BrowserServer) {
@@ -68,3 +90,104 @@ export async function stopBrowserServer(logger: FastifyBaseLogger, server: Brows
6890
logger.error(`Failed to stop local browser server: ${Diagnostics.errorMessage(err)}`);
6991
}
7092
}
93+
94+
/**
95+
* Dependencies of {@link createBrowserServerManager}. Primarily exists to allow tests to inject
96+
* fakes for the launch/stop primitives without spinning up a real browser.
97+
*/
98+
export interface BrowserServerManagerDeps {
99+
launch?: (logger: FastifyBaseLogger, config: LocalBrowserConfig) => Promise<BrowserServer>;
100+
stop?: (logger: FastifyBaseLogger, server: BrowserServer) => Promise<void>;
101+
}
102+
103+
/**
104+
* Manages a single, lazily-launched local browser server that is shared across all in-flight
105+
* extraction requests and torn down after an idle TTL.
106+
*/
107+
export interface BrowserServerManager {
108+
/**
109+
* Returns the shared browser server, launching it on first use. Concurrent callers share the
110+
* same in-flight launch. If the launch fails, the cached (rejected) launch is discarded so the
111+
* next caller retries from scratch instead of replaying the failure.
112+
*/
113+
get: (config: LocalBrowserConfig) => Promise<BrowserServer>;
114+
/** Whether a browser server is currently launched (or being launched). */
115+
isRunning: () => boolean;
116+
/** Stops the shared browser server (if any) and cancels the pending idle shutdown. */
117+
close: () => Promise<void>;
118+
}
119+
120+
export function createBrowserServerManager(
121+
logger: FastifyBaseLogger,
122+
deps: BrowserServerManagerDeps = {},
123+
): BrowserServerManager {
124+
const launch = deps.launch ?? launchBrowserServer;
125+
const stop = deps.stop ?? stopBrowserServer;
126+
127+
let serverPromise: Promise<BrowserServer> | undefined;
128+
let shutdownInProgress: Promise<void> | undefined;
129+
let shutdownTimer: NodeJS.Timeout | undefined;
130+
131+
const clearShutdownTimer = () => {
132+
if (shutdownTimer) {
133+
clearTimeout(shutdownTimer);
134+
shutdownTimer = undefined;
135+
}
136+
};
137+
138+
const scheduleShutdown = (ttlSec: number) => {
139+
clearShutdownTimer();
140+
shutdownTimer = setTimeout(() => {
141+
shutdownTimer = undefined;
142+
143+
const instance = serverPromise;
144+
if (!instance) {
145+
return;
146+
}
147+
148+
serverPromise = undefined;
149+
shutdownInProgress = instance
150+
.then((server) => stop(logger, server))
151+
.catch(() => {})
152+
.finally(() => {
153+
shutdownInProgress = undefined;
154+
});
155+
}, ttlSec * 1000);
156+
157+
// The idle shutdown timer must not, by itself, keep the process alive.
158+
shutdownTimer.unref?.();
159+
};
160+
161+
return {
162+
isRunning: () => !!serverPromise,
163+
get: (config: LocalBrowserConfig) => {
164+
if (!serverPromise) {
165+
const launchPromise = (shutdownInProgress ?? Promise.resolve()).then(() => launch(logger, config));
166+
serverPromise = launchPromise;
167+
168+
// A failed launch must not be cached: drop the rejected promise (and any pending idle
169+
// shutdown scheduled for it) so the next request attempts a fresh launch instead of
170+
// synchronously replaying the cached failure for the whole TTL window.
171+
launchPromise.catch(() => {
172+
if (serverPromise === launchPromise) {
173+
serverPromise = undefined;
174+
clearShutdownTimer();
175+
}
176+
});
177+
}
178+
179+
scheduleShutdown(config.ttlSec);
180+
181+
return serverPromise;
182+
},
183+
close: async () => {
184+
clearShutdownTimer();
185+
186+
const instance = serverPromise;
187+
serverPromise = undefined;
188+
if (instance) {
189+
await instance.then((server) => stop(logger, server)).catch(() => {});
190+
}
191+
},
192+
};
193+
}

0 commit comments

Comments
 (0)