From 09e3599edc8958df0aff571d0d6cc42f20251708 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Mon, 13 Jul 2026 09:34:37 +0700 Subject: [PATCH] feat: use hot prowser instance to improve stability Signed-off-by: Alexander Onnikov --- services/print/pod-print/src/browser.ts | 153 ++++++++++++++ services/print/pod-print/src/config.ts | 12 +- services/print/pod-print/src/main.ts | 16 +- services/print/pod-print/src/print.test.ts | 230 +++++++++++++++++++++ services/print/pod-print/src/print.ts | 156 +++++++------- services/print/pod-print/src/semaphore.ts | 35 ++++ services/print/pod-print/src/server.ts | 17 +- 7 files changed, 540 insertions(+), 79 deletions(-) create mode 100644 services/print/pod-print/src/browser.ts create mode 100644 services/print/pod-print/src/print.test.ts create mode 100644 services/print/pod-print/src/semaphore.ts diff --git a/services/print/pod-print/src/browser.ts b/services/print/pod-print/src/browser.ts new file mode 100644 index 00000000000..5352a39d212 --- /dev/null +++ b/services/print/pod-print/src/browser.ts @@ -0,0 +1,153 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import puppeteer, { type Browser, type BrowserContext, type Page } from 'puppeteer' + +import config from './config' + +const BROWSER_PAGE_INIT_TIMEOUT_MS = 2000 + +export type BrowserStatus = 'not_started' | 'launching' | 'connected' | 'disconnected' + +export interface PrintBrowserPage { + context: BrowserContext + page: Page +} + +function getLaunchOptions (): Parameters[0] { + return { + headless: true, + args: [ + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-setuid-sandbox', + ...config.PuppeteerArgs + ] + } +} + +async function withTimeout (promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | undefined + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error('Browser page initialization timed out')) + }, timeoutMs) + }) + ]) + } finally { + if (timeout !== undefined) { + clearTimeout(timeout) + } + } +} + +export class PrintBrowserManager { + private browser: Browser | undefined + private launchPromise: Promise | undefined + + getStatus (): BrowserStatus { + if (this.browser !== undefined) { + return this.browser.connected ? 'connected' : 'disconnected' + } + + return this.launchPromise !== undefined ? 'launching' : 'not_started' + } + + async getBrowser (): Promise { + if (this.browser !== undefined && this.browser.connected) { + return this.browser + } + + if (this.browser !== undefined) { + await this.resetBrowser(this.browser) + } + + if (this.launchPromise === undefined) { + this.launchPromise = puppeteer + .launch(getLaunchOptions()) + .then((launchedBrowser) => { + this.browser = launchedBrowser + launchedBrowser.on('disconnected', () => { + if (this.browser === launchedBrowser) { + this.browser = undefined + this.launchPromise = undefined + } + }) + return launchedBrowser + }) + .catch((err) => { + this.launchPromise = undefined + throw err + }) + } + + return await this.launchPromise + } + + async createPage (): Promise { + const browser = await this.getBrowser() + + try { + return await this.createPageInBrowser(browser) + } catch (err) { + if (this.browser === browser) { + await this.resetBrowser(browser) + return await this.createPageInBrowser(await this.getBrowser()) + } + throw err + } + } + + async close (): Promise { + const currentBrowser = this.browser + const currentLaunchPromise = this.launchPromise + + this.browser = undefined + this.launchPromise = undefined + + const launchedBrowser = currentBrowser ?? (await currentLaunchPromise?.catch(() => undefined)) + + if (this.browser === launchedBrowser) { + this.browser = undefined + } + + if (launchedBrowser !== undefined) { + await this.closeBrowser(launchedBrowser) + } + } + + private async createPageInBrowser (browser: Browser): Promise { + let context: BrowserContext | undefined + + try { + context = await withTimeout(browser.createBrowserContext(), BROWSER_PAGE_INIT_TIMEOUT_MS) + const page = await withTimeout(context.newPage(), BROWSER_PAGE_INIT_TIMEOUT_MS) + return { context, page } + } catch { + if (context !== undefined) { + await context.close().catch(() => {}) + } + throw new Error('Failed to create browser page') + } + } + + private async resetBrowser (browser: Browser): Promise { + if (this.browser === browser) { + this.browser = undefined + this.launchPromise = undefined + } + await this.closeBrowser(browser) + } + + private async closeBrowser (browser: Browser): Promise { + if (browser.connected) { + await browser.close().catch(() => {}) + } + } +} diff --git a/services/print/pod-print/src/config.ts b/services/print/pod-print/src/config.ts index 3eae6360ea0..0e6dad0e5fe 100644 --- a/services/print/pod-print/src/config.ts +++ b/services/print/pod-print/src/config.ts @@ -13,10 +13,19 @@ export interface Config { FrontUrl: string AllowedHostnames: string[] PuppeteerArgs: string[] + PrintConcurrency: number } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) +const parsePositiveInteger = (str: string | undefined, defaultValue: number): number => { + const value = parseNumber(str) ?? defaultValue + if (!Number.isInteger(value) || value <= 0) { + throw Error(`Invalid positive integer config value: ${str}`) + } + return value +} + const config: Config = (() => { const allowedHostnames = process.env.ALLOWED_HOSTNAMES const puppeteerArgs = process.env.PUPPETEER_ARGS ?? '' @@ -27,7 +36,8 @@ const config: Config = (() => { AccountsUrl: process.env.ACCOUNTS_URL, FrontUrl: process.env.FRONT_URL, AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','), - PuppeteerArgs: puppeteerArgs.split(',') + PuppeteerArgs: puppeteerArgs.split(','), + PrintConcurrency: parsePositiveInteger(process.env.PRINT_CONCURRENCY, 1) } const missingEnv = (Object.keys(params) as Array).filter((key) => params[key] === undefined) diff --git a/services/print/pod-print/src/main.ts b/services/print/pod-print/src/main.ts index c2c70d4283f..d1bfecdfb14 100644 --- a/services/print/pod-print/src/main.ts +++ b/services/print/pod-print/src/main.ts @@ -22,10 +22,22 @@ export const main = async (): Promise => { const storageConfig = storageConfigFromEnv() const { app, close } = createServer(storageConfig, config.AllowedHostnames) const server = listen(app, config.Port) + let shuttingDown = false const shutdown = (): void => { - close() - server.close(() => process.exit()) + if (shuttingDown) { + return + } + shuttingDown = true + + server.close(() => { + void close() + .then(() => process.exit()) + .catch((err) => { + console.error(err) + process.exit(1) + }) + }) } process.on('SIGINT', shutdown) diff --git a/services/print/pod-print/src/print.test.ts b/services/print/pod-print/src/print.test.ts new file mode 100644 index 00000000000..73aa6a87072 --- /dev/null +++ b/services/print/pod-print/src/print.test.ts @@ -0,0 +1,230 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { type MeasureContext } from '@hcengineering/core' + +interface Deferred { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: unknown) => void +} + +function deferred (): Deferred { + let _resolve!: (value: T | PromiseLike) => void + let _reject!: (reason?: unknown) => void + const promise = new Promise((resolve, reject) => { + _resolve = resolve + _reject = reject + }) + return { promise, resolve: _resolve, reject: _reject } +} + +async function flush (): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +function createCtx (): MeasureContext { + return { + info: jest.fn(), + warn: jest.fn(), + with: jest.fn( + async (_name: string, _metrics: object, fn: (ctx: MeasureContext) => Promise) => await fn(createCtx()) + ) + } as any +} + +function createPage (overrides: Partial> = {}): any { + const page = { + on: jest.fn().mockReturnThis(), + setViewport: jest.fn().mockResolvedValue(undefined), + goto: jest.fn().mockResolvedValue(undefined), + waitForNetworkIdle: jest.fn().mockResolvedValue(undefined), + emulateMediaType: jest.fn().mockResolvedValue(undefined), + evaluate: jest.fn().mockResolvedValue(''), + pdf: jest.fn().mockResolvedValue(Buffer.from('pdf')), + screenshot: jest.fn().mockResolvedValue(Buffer.from('png')), + close: jest.fn().mockResolvedValue(undefined), + ...overrides + } + + return page +} + +function createBrowser (pageFactory: () => any = () => createPage(), overrides: Record = {}): any { + const browser: any = { + connected: true, + listeners: new Map void>(), + contexts: [] as any[], + on: jest.fn((event: string, listener: () => void) => { + browser.listeners.set(event, listener) + return browser + }), + close: jest.fn(async () => { + browser.connected = false + }), + createBrowserContext: jest.fn(async () => { + const page = pageFactory() + const context = { + page, + newPage: jest.fn(async () => page), + close: jest.fn().mockResolvedValue(undefined) + } + browser.contexts.push(context) + return context + }), + disconnect: () => { + browser.connected = false + browser.listeners.get('disconnected')?.() + }, + ...overrides + } + + return browser +} + +async function loadPrintModule ( + launch: jest.Mock, + printConcurrency = '1' +): Promise<{ + print: (ctx: MeasureContext, url: string, options?: import('./print').PrintOptions) => Promise + closePrintBrowser: () => Promise + }> { + jest.resetModules() + process.env.SECRET = 'secret' + process.env.ACCOUNTS_URL = 'http://accounts' + process.env.FRONT_URL = 'http://front' + process.env.PRINT_CONCURRENCY = printConcurrency + + jest.doMock('puppeteer', () => ({ + __esModule: true, + default: { + launch + } + })) + + const printModule = await import('./print') + const { PrintBrowserManager } = await import('./browser') + const browserManager = new PrintBrowserManager() + + return { + print: async (ctx, url, options) => await printModule.print(ctx, url, options, browserManager), + closePrintBrowser: async () => { + await browserManager.close() + } + } +} + +describe('print', () => { + afterEach(() => { + jest.dontMock('puppeteer') + delete process.env.PRINT_CONCURRENCY + }) + + it('reuses the same browser for sequential print jobs', async () => { + const browser = createBrowser() + const launch = jest.fn().mockResolvedValue(browser) + const { closePrintBrowser, print } = await loadPrintModule(launch) + + await print(createCtx(), 'http://example.com/one') + await print(createCtx(), 'http://example.com/two') + + expect(launch).toHaveBeenCalledTimes(1) + expect(browser.createBrowserContext).toHaveBeenCalledTimes(2) + expect(browser.contexts[0].close).toHaveBeenCalledTimes(1) + expect(browser.contexts[1].close).toHaveBeenCalledTimes(1) + + await closePrintBrowser() + }) + + it('serializes print jobs with PRINT_CONCURRENCY=1', async () => { + const firstPdf = deferred() + let pageCount = 0 + const browser = createBrowser(() => { + pageCount++ + return createPage(pageCount === 1 ? { pdf: jest.fn(() => firstPdf.promise) } : {}) + }) + const launch = jest.fn().mockResolvedValue(browser) + const { closePrintBrowser, print } = await loadPrintModule(launch) + + const first = print(createCtx(), 'http://example.com/one') + await flush() + + const second = print(createCtx(), 'http://example.com/two') + await flush() + + expect(browser.createBrowserContext).toHaveBeenCalledTimes(1) + + firstPdf.resolve(Buffer.from('first')) + await first + await second + + expect(browser.createBrowserContext).toHaveBeenCalledTimes(2) + + await closePrintBrowser() + }) + + it('closes context and releases the semaphore when rendering fails', async () => { + let pageCount = 0 + const browser = createBrowser(() => { + pageCount++ + return createPage(pageCount === 1 ? { pdf: jest.fn().mockRejectedValue(new Error('pdf failed')) } : {}) + }) + const launch = jest.fn().mockResolvedValue(browser) + const { closePrintBrowser, print } = await loadPrintModule(launch) + + await expect(print(createCtx(), 'http://example.com/fail')).rejects.toThrow('pdf failed') + await print(createCtx(), 'http://example.com/next') + + expect(browser.contexts[0].close).toHaveBeenCalledTimes(1) + expect(browser.createBrowserContext).toHaveBeenCalledTimes(2) + + await closePrintBrowser() + }) + + it('launches a new browser after disconnect', async () => { + const firstBrowser = createBrowser() + const secondBrowser = createBrowser() + const launch = jest.fn().mockResolvedValueOnce(firstBrowser).mockResolvedValueOnce(secondBrowser) + const { closePrintBrowser, print } = await loadPrintModule(launch) + + await print(createCtx(), 'http://example.com/one') + firstBrowser.disconnect() + await print(createCtx(), 'http://example.com/two') + + expect(launch).toHaveBeenCalledTimes(2) + expect(firstBrowser.createBrowserContext).toHaveBeenCalledTimes(1) + expect(secondBrowser.createBrowserContext).toHaveBeenCalledTimes(1) + + await closePrintBrowser() + }) + + it('launches a new browser when the cached browser cannot create a page', async () => { + const firstBrowser = createBrowser() + const secondBrowser = createBrowser() + const launch = jest.fn().mockResolvedValueOnce(firstBrowser).mockResolvedValueOnce(secondBrowser) + const { closePrintBrowser, print } = await loadPrintModule(launch) + + await print(createCtx(), 'http://example.com/one') + firstBrowser.createBrowserContext.mockRejectedValueOnce(new Error('browser is not responding')) + await print(createCtx(), 'http://example.com/two') + + expect(launch).toHaveBeenCalledTimes(2) + expect(firstBrowser.close).toHaveBeenCalledTimes(1) + expect(secondBrowser.createBrowserContext).toHaveBeenCalledTimes(1) + + await closePrintBrowser() + }) + + it('closes the hot browser on shutdown', async () => { + const browser = createBrowser() + const launch = jest.fn().mockResolvedValue(browser) + const { closePrintBrowser, print } = await loadPrintModule(launch) + + await print(createCtx(), 'http://example.com/one') + await closePrintBrowser() + await closePrintBrowser() + + expect(browser.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/services/print/pod-print/src/print.ts b/services/print/pod-print/src/print.ts index b817ee5cce6..e36878c14cc 100644 --- a/services/print/pod-print/src/print.ts +++ b/services/print/pod-print/src/print.ts @@ -2,9 +2,11 @@ // Copyright © 2024 Hardcore Engineering Inc. // import { MeasureContext } from '@hcengineering/core' -import puppeteer, { Page, Viewport } from 'puppeteer' +import { type BrowserContext, type Page, type Viewport } from 'puppeteer' +import { type PrintBrowserManager } from './browser' import config from './config' +import { Semaphore } from './semaphore' export interface PrintOptions { kind?: ExportKind @@ -18,6 +20,8 @@ export const validPageOrientations = ['portrait', 'landscape'] as const export type ExportKind = (typeof validKinds)[number] export type PageOrientation = (typeof validPageOrientations)[number] +const printSemaphore = new Semaphore(config.PrintConcurrency) + /** * Prints a webpage with the specified options * @public @@ -25,91 +29,101 @@ export type PageOrientation = (typeof validPageOrientations)[number] * @param options - The options to use when printing the webpage. * @returns Buffer with the printed content. */ -export async function print (ctx: MeasureContext, url: string, options?: PrintOptions): Promise { +export async function print ( + ctx: MeasureContext, + url: string, + options: PrintOptions | undefined, + browserManager: PrintBrowserManager +): Promise { const kind = options?.kind ?? 'pdf' const orientation = options?.orientation ?? 'portrait' const viewport = options?.viewport ?? { width: 1440, height: 900 } + const release = await printSemaphore.acquire() - ctx.info('print', { url, kind, orientation, viewport }) - - // TODO: think of having a "hot" browser instance to avoid the overhead of launching a new one every time - const browser = await puppeteer.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-gpu', - '--disable-dev-shm-usage', - '--disable-extensions', - '--disable-setuid-sandbox', - ...config.PuppeteerArgs - ] - }) - const page = await browser.newPage() - - page - .on('pageerror', (err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - ctx.warn('pageerror', { message }) - }) - .on('requestfailed', (request) => { - ctx.warn('requestfailed', { url: request.url(), errorText: request.failure()?.errorText }) - }) + let context: BrowserContext | undefined + let page: Page | undefined - await page.setViewport(viewport) + try { + ctx.info('print', { url, kind, orientation, viewport }) - // NOTE: Issues opened with a guest link worked fine only with networkidle0 here and - // waitForNetworkIdle 1000 afterwards. Also tried 700 but sometimes it was not enough. - await page.goto(url, { - waitUntil: ['domcontentloaded', 'networkidle0'] - }) - await page.waitForNetworkIdle({ idleTime: 1000 }) + const browserPage = await browserManager.createPage() + context = browserPage.context + page = browserPage.page + const currentPage = page - let res: Uint8Array | undefined + currentPage + .on('pageerror', (err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + ctx.warn('pageerror', { message }) + }) + .on('requestfailed', (request) => { + ctx.warn('requestfailed', { url: request.url(), errorText: request.failure()?.errorText }) + }) - if (kind === 'pdf') { - await page.emulateMediaType('print') - // Scroll throught the page to render all the content (e.g. as images are only rendered - // when they are visible in the viewport) - await scrollThrough(page) + await currentPage.setViewport(viewport) - // Read page header and footer if defined - const pageHeader = await page.evaluate(() => { - const header = document.querySelector('#page-header') - return header?.innerHTML ?? '' + // NOTE: Issues opened with a guest link worked fine only with networkidle0 here and + // waitForNetworkIdle 1000 afterwards. Also tried 700 but sometimes it was not enough. + await currentPage.goto(url, { + waitUntil: ['domcontentloaded', 'networkidle0'] }) + await currentPage.waitForNetworkIdle({ idleTime: 1000 }) - const pageFooter = await page.evaluate(() => { - const footer = document.querySelector('#page-footer') - return footer?.innerHTML ?? '' - }) + let res: Uint8Array | undefined + + if (kind === 'pdf') { + await currentPage.emulateMediaType('print') + // Scroll throught the page to render all the content (e.g. as images are only rendered + // when they are visible in the viewport) + await scrollThrough(currentPage) - const displayHeaderFooter = pageHeader !== '' || pageFooter !== '' - - res = await ctx.with('pdf', {}, () => - page.pdf({ - format: 'A4', - landscape: orientation === 'landscape', - timeout: 0, - headerTemplate: pageHeader, - footerTemplate: pageFooter, - displayHeaderFooter, - margin: { - top: '1.5cm', - right: '1cm', - bottom: '1.5cm', - left: '1cm' - } + // Read page header and footer if defined + const pageHeader = await currentPage.evaluate(() => { + const header = document.querySelector('#page-header') + return header?.innerHTML ?? '' }) - ) - } else { - // Note: currently we do not take the full page screenshot - only the viewport - // might make it configurable in the future - res = await ctx.with('screenshot', { kind }, () => page.screenshot({ type: kind })) - } - await browser.close() + const pageFooter = await currentPage.evaluate(() => { + const footer = document.querySelector('#page-footer') + return footer?.innerHTML ?? '' + }) - return res !== undefined ? Buffer.from(res) : undefined + const displayHeaderFooter = pageHeader !== '' || pageFooter !== '' + + res = await ctx.with('pdf', {}, () => + currentPage.pdf({ + format: 'A4', + landscape: orientation === 'landscape', + timeout: 0, + headerTemplate: pageHeader, + footerTemplate: pageFooter, + displayHeaderFooter, + margin: { + top: '1.5cm', + right: '1cm', + bottom: '1.5cm', + left: '1cm' + } + }) + ) + } else { + // Note: currently we do not take the full page screenshot - only the viewport + // might make it configurable in the future + res = await ctx.with('screenshot', { kind }, () => currentPage.screenshot({ type: kind })) + } + + return res !== undefined ? Buffer.from(res) : undefined + } finally { + try { + if (context !== undefined) { + await context.close() + } else if (page !== undefined) { + await page.close() + } + } finally { + release() + } + } } async function scrollThrough (page: Page): Promise { diff --git a/services/print/pod-print/src/semaphore.ts b/services/print/pod-print/src/semaphore.ts new file mode 100644 index 00000000000..0d39e774861 --- /dev/null +++ b/services/print/pod-print/src/semaphore.ts @@ -0,0 +1,35 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +export class Semaphore { + private active = 0 + private readonly queue: Array<() => void> = [] + + constructor (private readonly limit: number) {} + + async acquire (): Promise<() => void> { + if (this.active < this.limit) { + this.active++ + return () => { + this.release() + } + } + + await new Promise((resolve) => { + this.queue.push(resolve) + }) + return () => { + this.release() + } + } + + private release (): void { + const next = this.queue.shift() + if (next !== undefined) { + next() + return + } + this.active-- + } +} diff --git a/services/print/pod-print/src/server.ts b/services/print/pod-print/src/server.ts index b7bd2d65938..f77832bdc50 100644 --- a/services/print/pod-print/src/server.ts +++ b/services/print/pod-print/src/server.ts @@ -34,6 +34,7 @@ import { join } from 'path' import config from './config' import { convertToHtml } from './convert' import { ApiError } from './error' +import { PrintBrowserManager } from './browser' import { PrintOptions, print, validKinds, validPageOrientations } from './print' import { withMeasureContext } from './middleware' @@ -175,8 +176,9 @@ function parsePrintOptions (query: Request['query']): PrintOptions { export function createServer ( storageConfig: StorageConfiguration, allowedHostnames: string[] -): { app: Express, close: () => void } { +): { app: Express, close: () => Promise } { const storageAdapter = buildStorageFromConfig(storageConfig) + const browserManager = new PrintBrowserManager() const measureCtx = initStatisticsContext('print', { factory: () => createOpenTelemetryMetricsContext( @@ -197,6 +199,11 @@ export function createServer ( app.use(express.json()) app.use(withMeasureContext({ ctx: measureCtx })) + app.get('/health', (_req, res) => { + res.contentType('application/json') + res.send({ status: 'ok', browser: browserManager.getStatus() }) + }) + app.get( '/print', wrapRequest(async (req, res, wsIds, wsLoginInfo) => { @@ -219,7 +226,7 @@ export function createServer ( const printRes = await ctx.with( 'print', { kind: options.kind, orientation: options.orientation }, - (ctx) => print(ctx, link, options), + (ctx) => print(ctx, link, options, browserManager), { url, viewport: options.viewport @@ -303,7 +310,7 @@ export function createServer ( const printRes = await ctx.with( 'print', { kind: options.kind, orientation: options.orientation }, - (ctx) => print(ctx, link, options), + (ctx) => print(ctx, link, options, browserManager), { link, viewport: options.viewport @@ -337,8 +344,8 @@ export function createServer ( return { app, - close: () => { - void storageAdapter.close() + close: async () => { + await Promise.all([storageAdapter.close(), browserManager.close()]) } } }