1+ import { setTimeout as delay } from 'node:timers/promises' ;
12import type { FastifyBaseLogger } from 'fastify' ;
23import type { BrowserServer , Logger } from 'playwright-core' ;
34import { Diagnostics } from '../api/diagnostics.js' ;
@@ -16,6 +17,14 @@ export type BrowserProtocol = 'cdp' | 'playwright';
1617// Timeout for connecting to a browser.
1718const 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
6183export 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