[Screenshotting] Fix potential race condition when screenshotting (#123820)

* extract message from error objects

* only warn for 400 and up status codes

* naively wait for vis ready after resizing the browser viewport

* use a single default viewport size, enable layout to set default page viewport for every page that is created

* refactor viewport -> windowSize in chromium args

* allow overriding defaults and use new windowSize arg for chromium args

* always round page dimension numbers. note: this will break if we ever have a "undefined" set as a key value

* added comment

* update snapshot to new width value

* make defaultViewport a required field on createPage

* added comment

* style: use async-await rather than .then chaining. also added a comment

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2022-02-09 11:42:53 +01:00 committed by GitHub
parent 558bcec4c1
commit b2b60ff061
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 97 additions and 59 deletions

View file

@ -7,7 +7,7 @@
import type { ConfigType } from '../../../config'; import type { ConfigType } from '../../../config';
interface Viewport { interface WindowSize {
height: number; height: number;
width: number; width: number;
} }
@ -16,12 +16,17 @@ type Proxy = ConfigType['browser']['chromium']['proxy'];
interface LaunchArgs { interface LaunchArgs {
userDataDir: string; userDataDir: string;
viewport?: Viewport; windowSize?: WindowSize;
disableSandbox?: boolean; disableSandbox?: boolean;
proxy: Proxy; proxy: Proxy;
} }
export const args = ({ userDataDir, disableSandbox, viewport, proxy: proxyConfig }: LaunchArgs) => { export const args = ({
userDataDir,
disableSandbox,
windowSize,
proxy: proxyConfig,
}: LaunchArgs) => {
const flags = [ const flags = [
// Disable built-in Google Translate service // Disable built-in Google Translate service
'--disable-translate', '--disable-translate',
@ -50,11 +55,11 @@ export const args = ({ userDataDir, disableSandbox, viewport, proxy: proxyConfig
`--mainFrameClipsContent=false`, `--mainFrameClipsContent=false`,
]; ];
if (viewport) { if (windowSize) {
// NOTE: setting the window size does NOT set the viewport size: viewport and window size are different. // NOTE: setting the window size does NOT set the viewport size: viewport and window size are different.
// The viewport may later need to be resized depending on the position of the clip area. // The viewport may later need to be resized depending on the position of the clip area.
// These numbers come from the job parameters, so this is a close guess. // These numbers come from the job parameters, so this is a close guess.
flags.push(`--window-size=${Math.floor(viewport.width)},${Math.floor(viewport.height)}`); flags.push(`--window-size=${Math.floor(windowSize.width)},${Math.floor(windowSize.height)}`);
} }
if (proxyConfig.enabled) { if (proxyConfig.enabled) {

View file

@ -11,7 +11,7 @@ import { mergeMap, take } from 'rxjs/operators';
import type { Logger } from 'src/core/server'; import type { Logger } from 'src/core/server';
import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server';
import { ConfigType } from '../../../config'; import { ConfigType } from '../../../config';
import { HeadlessChromiumDriverFactory } from '.'; import { HeadlessChromiumDriverFactory, DEFAULT_VIEWPORT } from '.';
jest.mock('puppeteer'); jest.mock('puppeteer');
@ -70,7 +70,10 @@ describe('HeadlessChromiumDriverFactory', () => {
describe('createPage', () => { describe('createPage', () => {
it('returns browser driver, unexpected process exit observable, and close callback', async () => { it('returns browser driver, unexpected process exit observable, and close callback', async () => {
await expect( await expect(
factory.createPage({ openUrlTimeout: 0 }).pipe(take(1)).toPromise() factory
.createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT })
.pipe(take(1))
.toPromise()
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
driver: expect.anything(), driver: expect.anything(),
@ -85,7 +88,10 @@ describe('HeadlessChromiumDriverFactory', () => {
`Puppeteer Launch mock fail.` `Puppeteer Launch mock fail.`
); );
expect(() => expect(() =>
factory.createPage({ openUrlTimeout: 0 }).pipe(take(1)).toPromise() factory
.createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT })
.pipe(take(1))
.toPromise()
).rejects.toThrowErrorMatchingInlineSnapshot( ).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error spawning Chromium browser! Puppeteer Launch mock fail."` `"Error spawning Chromium browser! Puppeteer Launch mock fail."`
); );
@ -94,7 +100,7 @@ describe('HeadlessChromiumDriverFactory', () => {
describe('close behaviour', () => { describe('close behaviour', () => {
it('does not allow close to be called on the browse more than once', async () => { it('does not allow close to be called on the browse more than once', async () => {
await factory await factory
.createPage({ openUrlTimeout: 0 }) .createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT })
.pipe( .pipe(
take(1), take(1),
mergeMap(async ({ close }) => { mergeMap(async ({ close }) => {

View file

@ -7,6 +7,7 @@
import { getDataPath } from '@kbn/utils'; import { getDataPath } from '@kbn/utils';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import _ from 'lodash';
import del from 'del'; import del from 'del';
import fs from 'fs'; import fs from 'fs';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
@ -36,6 +37,12 @@ import { getMetrics, PerformanceMetrics } from './metrics';
interface CreatePageOptions { interface CreatePageOptions {
browserTimezone?: string; browserTimezone?: string;
defaultViewport: {
/** Size in pixels */
width?: number;
/** Size in pixels */
height?: number;
};
openUrlTimeout: number; openUrlTimeout: number;
} }
@ -110,7 +117,7 @@ export class HeadlessChromiumDriverFactory {
userDataDir: this.userDataDir, userDataDir: this.userDataDir,
disableSandbox: this.config.browser.chromium.disableSandbox, disableSandbox: this.config.browser.chromium.disableSandbox,
proxy: this.config.browser.chromium.proxy, proxy: this.config.browser.chromium.proxy,
viewport: DEFAULT_VIEWPORT, windowSize: DEFAULT_VIEWPORT, // Approximate the default viewport size
}); });
} }
@ -118,7 +125,7 @@ export class HeadlessChromiumDriverFactory {
* Return an observable to objects which will drive screenshot capture for a page * Return an observable to objects which will drive screenshot capture for a page
*/ */
createPage( createPage(
{ browserTimezone, openUrlTimeout }: CreatePageOptions, { browserTimezone, openUrlTimeout, defaultViewport }: CreatePageOptions,
pLogger = this.logger pLogger = this.logger
): Rx.Observable<CreatePageResult> { ): Rx.Observable<CreatePageResult> {
// FIXME: 'create' is deprecated // FIXME: 'create' is deprecated
@ -139,6 +146,13 @@ export class HeadlessChromiumDriverFactory {
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
handleSIGHUP: false, handleSIGHUP: false,
args: chromiumArgs, args: chromiumArgs,
// We optionally set this at page creation to reduce the chances of
// browser reflow. In most cases only the height needs to be adjusted
// before taking a screenshot.
// NOTE: _.defaults assigns to the target object, so we copy it.
// NOTE NOTE: _.defaults is not the same as { ...DEFAULT_VIEWPORT, ...defaultViewport }
defaultViewport: _.defaults({ ...defaultViewport }, DEFAULT_VIEWPORT),
env: { env: {
TZ: browserTimezone, TZ: browserTimezone,
}, },

View file

@ -5,6 +5,7 @@
* 2.0. * 2.0.
*/ */
import { map as mapRecord } from 'fp-ts/lib/Record';
import type { LayoutParams } from '../../common/layout'; import type { LayoutParams } from '../../common/layout';
import { LayoutTypes } from '../../common'; import { LayoutTypes } from '../../common';
import type { Layout } from '.'; import type { Layout } from '.';
@ -12,13 +13,20 @@ import { CanvasLayout } from './canvas_layout';
import { PreserveLayout } from './preserve_layout'; import { PreserveLayout } from './preserve_layout';
import { PrintLayout } from './print_layout'; import { PrintLayout } from './print_layout';
/**
* We naively round all numeric values in the object, this will break screenshotting
* if ever a have a non-number set as a value, but this points to an issue
* in the code responsible for creating the dimensions object.
*/
const roundNumbers = mapRecord(Math.round);
export function createLayout({ id, dimensions, selectors, ...config }: LayoutParams): Layout { export function createLayout({ id, dimensions, selectors, ...config }: LayoutParams): Layout {
if (dimensions && id === LayoutTypes.PRESERVE_LAYOUT) { if (dimensions && id === LayoutTypes.PRESERVE_LAYOUT) {
return new PreserveLayout(dimensions, selectors); return new PreserveLayout(roundNumbers(dimensions), selectors);
} }
if (dimensions && id === LayoutTypes.CANVAS) { if (dimensions && id === LayoutTypes.CANVAS) {
return new CanvasLayout(dimensions); return new CanvasLayout(roundNumbers(dimensions));
} }
// layoutParams is optional as PrintLayout doesn't use it // layoutParams is optional as PrintLayout doesn't use it

View file

@ -376,7 +376,7 @@ describe('Screenshot Observable Pipeline', () => {
"height": 1200, "height": 1200,
"left": 0, "left": 0,
"top": 0, "top": 0,
"width": 1800, "width": 1950,
}, },
"scroll": Object { "scroll": Object {
"x": 0, "x": 0,

View file

@ -68,38 +68,47 @@ export function getScreenshots(
timeouts: { openUrl: openUrlTimeout }, timeouts: { openUrl: openUrlTimeout },
} = options; } = options;
return browserDriverFactory.createPage({ browserTimezone, openUrlTimeout }, logger).pipe( return browserDriverFactory
mergeMap(({ driver, unexpectedExit$, metrics$, close }) => { .createPage(
apmCreatePage?.end(); {
metrics$.subscribe(({ cpu, memory }) => { browserTimezone,
apmTrans?.setLabel('cpu', cpu, false); openUrlTimeout,
apmTrans?.setLabel('memory', memory, false); defaultViewport: { height: layout.height, width: layout.width },
}); },
unexpectedExit$.subscribe({ error: () => apmTrans?.end() }); logger
)
.pipe(
mergeMap(({ driver, unexpectedExit$, metrics$, close }) => {
apmCreatePage?.end();
metrics$.subscribe(({ cpu, memory }) => {
apmTrans?.setLabel('cpu', cpu, false);
apmTrans?.setLabel('memory', memory, false);
});
unexpectedExit$.subscribe({ error: () => apmTrans?.end() });
const screen = new ScreenshotObservableHandler(driver, logger, layout, options); const screen = new ScreenshotObservableHandler(driver, logger, layout, options);
return from(options.urls).pipe( return from(options.urls).pipe(
concatMap((url, index) => concatMap((url, index) =>
screen.setupPage(index, url, apmTrans).pipe( screen.setupPage(index, url, apmTrans).pipe(
catchError((error) => { catchError((error) => {
screen.checkPageIsOpen(); // this fails the job if the browser has closed screen.checkPageIsOpen(); // this fails the job if the browser has closed
logger.error(error); logger.error(error);
return of({ ...DEFAULT_SETUP_RESULT, error }); // allow failover screenshot capture return of({ ...DEFAULT_SETUP_RESULT, error }); // allow failover screenshot capture
}), }),
takeUntil(unexpectedExit$), takeUntil(unexpectedExit$),
screen.getScreenshots() screen.getScreenshots()
) )
), ),
take(options.urls.length), take(options.urls.length),
toArray(), toArray(),
mergeMap((results) => { mergeMap((results) => {
// At this point we no longer need the page, close it. // At this point we no longer need the page, close it.
return close().pipe(mapTo({ layout, metrics$, results })); return close().pipe(mapTo({ layout, metrics$, results }));
}) })
); );
}), }),
first() first()
); );
} }

View file

@ -11,7 +11,7 @@ import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators';
import type { Logger } from 'src/core/server'; import type { Logger } from 'src/core/server';
import type { Layout as ScreenshotModeLayout } from 'src/plugins/screenshot_mode/common'; import type { Layout as ScreenshotModeLayout } from 'src/plugins/screenshot_mode/common';
import type { ConditionalHeaders, HeadlessChromiumDriver } from '../browsers'; import type { ConditionalHeaders, HeadlessChromiumDriver } from '../browsers';
import { getChromiumDisconnectedError } from '../browsers'; import { getChromiumDisconnectedError, DEFAULT_VIEWPORT } from '../browsers';
import type { Layout } from '../layouts'; import type { Layout } from '../layouts';
import type { ElementsPositionAndAttribute } from './get_element_position_data'; import type { ElementsPositionAndAttribute } from './get_element_position_data';
import { getElementPositionAndAttributes } from './get_element_position_data'; import { getElementPositionAndAttributes } from './get_element_position_data';
@ -107,12 +107,9 @@ interface PageSetupResults {
error?: Error; error?: Error;
} }
const DEFAULT_SCREENSHOT_CLIP_HEIGHT = 1200;
const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800;
const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => {
const height = dimensions?.height || DEFAULT_SCREENSHOT_CLIP_HEIGHT; const height = dimensions?.height || DEFAULT_VIEWPORT.height;
const width = dimensions?.width || DEFAULT_SCREENSHOT_CLIP_WIDTH; const width = dimensions?.width || DEFAULT_VIEWPORT.width;
return [ return [
{ {
@ -130,8 +127,7 @@ const getDefaultElementPosition = (dimensions: { height?: number; width?: number
* provided by the browser. * provided by the browser.
*/ */
const getDefaultViewPort = () => ({ const getDefaultViewPort = () => ({
height: DEFAULT_SCREENSHOT_CLIP_HEIGHT, ...DEFAULT_VIEWPORT,
width: DEFAULT_SCREENSHOT_CLIP_WIDTH,
zoom: 1, zoom: 1,
}); });
@ -180,14 +176,14 @@ export class ScreenshotObservableHandler {
const waitTimeout = this.options.timeouts.waitForElements; const waitTimeout = this.options.timeouts.waitForElements;
return defer(() => getNumberOfItems(driver, this.logger, waitTimeout, this.layout)).pipe( return defer(() => getNumberOfItems(driver, this.logger, waitTimeout, this.layout)).pipe(
mergeMap((itemsCount) => { mergeMap(async (itemsCount) => {
// set the viewport to the dimentions from the job, to allow elements to flow into the expected layout // set the viewport to the dimensions from the job, to allow elements to flow into the expected layout
const viewport = this.layout.getViewport(itemsCount) || getDefaultViewPort(); const viewport = this.layout.getViewport(itemsCount) || getDefaultViewPort();
return forkJoin([ // Set the viewport allowing time for the browser to handle reflow and redraw
driver.setViewport(viewport, this.logger), // before checking for readiness of visualizations.
waitForVisualizations(driver, this.logger, waitTimeout, itemsCount, this.layout), await driver.setViewport(viewport, this.logger);
]); await waitForVisualizations(driver, this.logger, waitTimeout, itemsCount, this.layout);
}), }),
this.waitUntil(waitTimeout, 'wait for elements') this.waitUntil(waitTimeout, 'wait for elements')
); );