@@ -14,14 +14,29 @@ const PDF_TIMEOUT = 5 * 60 * 1000;
1414const MAX_RENDER_ATTEMPTS = 3 ;
1515const SCALE = 96 / 72 ;
1616
17+ const BROWSER_RECYCLE_AFTER = 50 ;
18+
1719let browser = null ;
20+ let renderCount = 0 ;
1821const cwd = process . cwd ( ) ;
1922
2023const fileOutputDir = path . join ( cwd , 'output' ) ;
2124
2225const pdfPath = id => path . join ( fileOutputDir , `${ id } .pdf` ) ;
2326const csvPath = id => path . join ( fileOutputDir , `${ id } .csv` ) ;
2427
28+ async function closeBrowser ( ) {
29+ if ( browser ) {
30+ const b = browser ;
31+ browser = null ; // null first so the 'disconnected' handler is a no-op
32+ try {
33+ await b . close ( ) ;
34+ } catch ( err ) {
35+ console . error ( `[browser] Error closing browser: ${ err . message } ` ) ;
36+ }
37+ }
38+ }
39+
2540async function initialize ( ) {
2641 const launchOptions = {
2742 args : [ '--no-sandbox' , '--disable-setuid-sandbox' , '--disable-web-security' ] ,
@@ -30,7 +45,9 @@ async function initialize() {
3045 launchOptions . executablePath = process . env . PUPPETEER_EXECUTABLE_PATH ;
3146 }
3247 browser = await puppeteer . launch ( launchOptions ) ;
48+ renderCount = 0 ;
3349 browser . on ( 'disconnected' , ( ) => {
50+ console . log ( '[browser] Browser disconnected unexpectedly, will re-launch on next job.' ) ;
3451 browser = null ;
3552 } ) ;
3653}
@@ -72,19 +89,37 @@ async function waitFile(filePath) {
7289}
7390
7491/**
75- * Renders component to PDF or CSV file
76- * @returns {Promise }
92+ * Renders component to PDF or CSV file.
93+ *
94+ * The caller is responsible for passing an AbortSignal-like token via
95+ * `options.abortSignal` so this function can close its page when a timeout
96+ * fires from outside.
97+ *
98+ * @returns {Promise<boolean> } posterUploaded
7799 */
78100async function renderComponent ( options ) {
79101 const { id, component, template, props, onInfo, onError } = options ;
80102
81103 const page = await browser . newPage ( ) ;
82104
105+ let pageClosed = false ;
106+ async function safeClosePage ( ) {
107+ if ( ! pageClosed ) {
108+ pageClosed = true ;
109+ try {
110+ await page . close ( ) ;
111+ } catch ( err ) {
112+ console . error ( `[page] Error closing page for ${ id } : ${ err . message } ` ) ;
113+ }
114+ }
115+ }
116+
83117 await page . exposeFunction ( 'serverLog' , log ) ;
84118
85- page . on ( 'error' , error => {
86- page . close ( ) ;
87- browser . close ( ) ;
119+ page . on ( 'error' , async error => {
120+ console . error ( `[page] Page crashed for ${ id } : ${ error . message } ` ) ;
121+ await safeClosePage ( ) ;
122+ await closeBrowser ( ) ;
88123 onError ( error ) ;
89124 } ) ;
90125
@@ -99,79 +134,80 @@ async function renderComponent(options) {
99134
100135 const pageUrl = generateRenderUrl ( component , template , props , id ) ;
101136
102- console . log ( `Opening ${ pageUrl } in Puppeteer.` ) ;
137+ console . log ( `[render] Opening ${ pageUrl } in Puppeteer.` ) ;
103138
104- if ( component === 'StopRoutePlate' && ( props . downloadTable || props . downloadSummary ) ) {
105- // Allow the downloading of CSV file since the component just sends it to the client instead of actually rendering
106- const client = await page . createCDPSession ( ) ;
107- await client . send ( 'Page.setDownloadBehavior' , {
108- behavior : 'allow' ,
109- downloadPath : fileOutputDir ,
110- } ) ;
139+ try {
140+ if ( component === 'StopRoutePlate' && ( props . downloadTable || props . downloadSummary ) ) {
141+ // Allow the downloading of CSV file since the component just sends it to the client instead of actually rendering
142+ const client = await page . createCDPSession ( ) ;
143+ await client . send ( 'Page.setDownloadBehavior' , {
144+ behavior : 'allow' ,
145+ downloadPath : fileOutputDir ,
146+ } ) ;
111147
112- const csvFilePath = props . downloadSummary ? csvPath ( `summary-${ id } ` ) : csvPath ( id ) ;
148+ const csvFilePath = props . downloadSummary ? csvPath ( `summary-${ id } ` ) : csvPath ( id ) ;
113149
114- try {
115150 await page . goto ( pageUrl ) ;
116151 await waitFile ( csvFilePath ) ;
117152 const posterUploaded = await uploadPosterToCloud ( csvFilePath ) ;
118- await page . close ( ) ;
153+ await safeClosePage ( ) ;
119154 return posterUploaded ;
120- } catch ( err ) {
121- throw new Error ( 'StopRoutePlate CSV rendering failed' ) ;
122155 }
123- }
124156
125- await page . goto ( pageUrl , {
126- timeout : RENDER_TIMEOUT ,
127- } ) ;
157+ await page . goto ( pageUrl , {
158+ timeout : RENDER_TIMEOUT ,
159+ } ) ;
128160
129- const { error = null , width, height } = await page . evaluate (
130- ( ) =>
131- new Promise ( resolve => {
132- window . callPhantom = opts => resolve ( opts ) ;
133- } ) ,
134- ) ;
161+ const { error = null , width, height } = await page . evaluate (
162+ ( ) =>
163+ new Promise ( resolve => {
164+ window . callPhantom = opts => resolve ( opts ) ;
165+ } ) ,
166+ ) ;
135167
136- if ( error ) {
137- throw new Error ( error ) ;
138- }
168+ if ( error ) {
169+ throw new Error ( error ) ;
170+ }
139171
140- await page . emulateMediaType ( 'screen' ) ;
141-
142- let printOptions = { } ;
143- if ( props . printTimetablesAsA4 || component === 'CoverPage' ) {
144- printOptions = {
145- printBackground : true ,
146- format : 'A4' ,
147- margin : 0 ,
148- timeout : PDF_TIMEOUT ,
149- } ;
150- } else if ( props . printAsA5 ) {
151- printOptions = {
152- printBackground : true ,
153- format : 'A5' ,
154- margin : 0 ,
155- timeout : PDF_TIMEOUT ,
156- } ;
157- } else {
158- printOptions = {
159- printBackground : true ,
160- width : width * SCALE ,
161- height : height * SCALE ,
162- pageRanges : '1' ,
163- scale : SCALE ,
164- } ;
165- }
172+ await page . emulateMediaType ( 'screen' ) ;
173+
174+ let printOptions = { } ;
175+ if ( props . printTimetablesAsA4 || component === 'CoverPage' ) {
176+ printOptions = {
177+ printBackground : true ,
178+ format : 'A4' ,
179+ margin : 0 ,
180+ timeout : PDF_TIMEOUT ,
181+ } ;
182+ } else if ( props . printAsA5 ) {
183+ printOptions = {
184+ printBackground : true ,
185+ format : 'A5' ,
186+ margin : 0 ,
187+ timeout : PDF_TIMEOUT ,
188+ } ;
189+ } else {
190+ printOptions = {
191+ printBackground : true ,
192+ width : width * SCALE ,
193+ height : height * SCALE ,
194+ pageRanges : '1' ,
195+ scale : SCALE ,
196+ } ;
197+ }
166198
167- const contents = await page . pdf ( printOptions ) ;
199+ const contents = await page . pdf ( printOptions ) ;
168200
169- const pdfFilePath = pdfPath ( id ) ;
170- await fs . outputFile ( pdfFilePath , contents ) ;
171- await page . close ( ) ;
201+ const pdfFilePath = pdfPath ( id ) ;
202+ await fs . outputFile ( pdfFilePath , contents ) ;
203+ await safeClosePage ( ) ;
172204
173- const posterUploaded = await uploadPosterToCloud ( pdfFilePath ) ;
174- return posterUploaded ;
205+ const posterUploaded = await uploadPosterToCloud ( pdfFilePath ) ;
206+ return posterUploaded ;
207+ } catch ( err ) {
208+ await safeClosePage ( ) ;
209+ throw err ;
210+ }
175211}
176212
177213async function generate ( options ) {
@@ -185,15 +221,31 @@ async function generate(options) {
185221 onInfo ( 'Creating new browser instance' ) ;
186222 await initialize ( ) ;
187223 }
224+
225+ // Recycle the browser periodically to reclaim Chromium-internal heap.
226+ if ( renderCount > 0 && renderCount % BROWSER_RECYCLE_AFTER === 0 ) {
227+ await closeBrowser ( ) ;
228+ await initialize ( ) ;
229+ }
230+
231+ let timeoutHandle ;
188232 const timeout = new Promise ( ( resolve , reject ) => {
189- setTimeout ( reject , RENDER_TIMEOUT , new Error ( 'Render timeout' ) ) ;
233+ timeoutHandle = setTimeout ( reject , RENDER_TIMEOUT , new Error ( 'Render timeout' ) ) ;
190234 } ) ;
191235
192- const posterUploaded = await Promise . race ( [ renderComponent ( options ) , timeout ] ) ;
236+ let posterUploaded ;
237+ try {
238+ posterUploaded = await Promise . race ( [ renderComponent ( options ) , timeout ] ) ;
239+ } finally {
240+ // Always clear the timeout so the closure is released immediately.
241+ clearTimeout ( timeoutHandle ) ;
242+ }
243+
193244 const uploadFailed = ! posterUploaded && AZURE_STORAGE_ACCOUNT && AZURE_STORAGE_KEY ;
194245
195246 if ( ! uploadFailed ) {
196247 onInfo ( 'Rendered successfully.' ) ;
248+ renderCount += 1 ;
197249 } else {
198250 const err = { message : 'Rendered successfully but uploading poster failed.' , stack : '' } ;
199251 throw err ;
@@ -202,6 +254,17 @@ async function generate(options) {
202254 return { success : true , uploaded : ! uploadFailed } ;
203255 } catch ( error ) {
204256 onError ( error ) ;
257+ if ( browser ) {
258+ try {
259+ const pages = await browser . pages ( ) ;
260+ console . log ( `[browser] Pages open after error: ${ pages . length } ` ) ;
261+ } catch ( inspectErr ) {
262+ console . error (
263+ `[browser] Cannot inspect pages after error, closing browser: ${ inspectErr . message } ` ,
264+ ) ;
265+ await closeBrowser ( ) ;
266+ }
267+ }
205268 }
206269 }
207270
@@ -212,4 +275,5 @@ module.exports = {
212275 generate,
213276 generateRenderUrl,
214277 csvPath,
278+ closeBrowser,
215279} ;
0 commit comments