2020-05-12 15:30:13 +00:00
|
|
|
/**
|
|
|
|
* Copyright 2020 Google Inc. All rights reserved.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2020-07-13 09:22:26 +00:00
|
|
|
import { debug } from '../common/Debug.js';
|
2020-05-12 15:30:13 +00:00
|
|
|
|
|
|
|
import * as childProcess from 'child_process';
|
2021-11-10 12:31:15 +00:00
|
|
|
import * as fs from 'fs';
|
|
|
|
import * as path from 'path';
|
|
|
|
import * as readline from 'readline';
|
|
|
|
import removeFolder from 'rimraf';
|
|
|
|
import { promisify } from 'util';
|
|
|
|
|
2020-07-13 09:22:26 +00:00
|
|
|
import { assert } from '../common/assert.js';
|
|
|
|
import { helper, debugError } from '../common/helper.js';
|
|
|
|
import { LaunchOptions } from './LaunchOptions.js';
|
|
|
|
import { Connection } from '../common/Connection.js';
|
2020-10-19 09:32:41 +00:00
|
|
|
import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js';
|
2020-07-13 09:22:26 +00:00
|
|
|
import { PipeTransport } from './PipeTransport.js';
|
2021-03-05 09:00:56 +00:00
|
|
|
import { Product } from '../common/Product.js';
|
2020-07-13 09:22:26 +00:00
|
|
|
import { TimeoutError } from '../common/Errors.js';
|
2020-05-12 15:30:13 +00:00
|
|
|
|
2020-06-25 10:54:00 +00:00
|
|
|
const removeFolderAsync = promisify(removeFolder);
|
2021-11-10 12:31:15 +00:00
|
|
|
const renameAsync = promisify(fs.rename);
|
|
|
|
const unlinkAsync = promisify(fs.unlink);
|
|
|
|
|
2020-05-12 15:30:13 +00:00
|
|
|
const debugLauncher = debug('puppeteer:launcher');
|
2021-11-10 12:31:15 +00:00
|
|
|
|
2020-06-15 13:02:00 +00:00
|
|
|
const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary.
|
|
|
|
This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser.
|
|
|
|
Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed.
|
|
|
|
If you think this is a bug, please report it on the Puppeteer issue tracker.`;
|
2020-05-12 15:30:13 +00:00
|
|
|
|
|
|
|
export class BrowserRunner {
|
2021-03-05 09:00:56 +00:00
|
|
|
private _product: Product;
|
2020-05-12 15:30:13 +00:00
|
|
|
private _executablePath: string;
|
|
|
|
private _processArguments: string[];
|
2021-11-10 12:31:15 +00:00
|
|
|
private _userDataDir: string;
|
|
|
|
private _isTempUserDataDir?: boolean;
|
2020-05-12 15:30:13 +00:00
|
|
|
|
|
|
|
proc = null;
|
|
|
|
connection = null;
|
|
|
|
|
|
|
|
private _closed = true;
|
|
|
|
private _listeners = [];
|
|
|
|
private _processClosing: Promise<void>;
|
|
|
|
|
|
|
|
constructor(
|
2021-03-05 09:00:56 +00:00
|
|
|
product: Product,
|
2020-05-12 15:30:13 +00:00
|
|
|
executablePath: string,
|
|
|
|
processArguments: string[],
|
2021-11-10 12:31:15 +00:00
|
|
|
userDataDir: string,
|
|
|
|
isTempUserDataDir?: boolean
|
2020-05-12 15:30:13 +00:00
|
|
|
) {
|
2021-03-05 09:00:56 +00:00
|
|
|
this._product = product;
|
2020-05-12 15:30:13 +00:00
|
|
|
this._executablePath = executablePath;
|
|
|
|
this._processArguments = processArguments;
|
2021-11-10 12:31:15 +00:00
|
|
|
this._userDataDir = userDataDir;
|
|
|
|
this._isTempUserDataDir = isTempUserDataDir;
|
2020-05-12 15:30:13 +00:00
|
|
|
}
|
|
|
|
|
2020-06-15 13:02:00 +00:00
|
|
|
start(options: LaunchOptions): void {
|
2021-05-12 14:48:30 +00:00
|
|
|
const { handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe } =
|
|
|
|
options;
|
2021-10-07 21:43:57 +00:00
|
|
|
let stdio: Array<'ignore' | 'pipe'>;
|
2020-05-12 15:30:13 +00:00
|
|
|
if (pipe) {
|
|
|
|
if (dumpio) stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
|
|
|
|
else stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'];
|
2021-10-07 21:43:57 +00:00
|
|
|
} else {
|
|
|
|
if (dumpio) stdio = ['pipe', 'pipe', 'pipe'];
|
|
|
|
else stdio = ['pipe', 'ignore', 'pipe'];
|
2020-05-12 15:30:13 +00:00
|
|
|
}
|
|
|
|
assert(!this.proc, 'This process has previously been started.');
|
|
|
|
debugLauncher(
|
|
|
|
`Calling ${this._executablePath} ${this._processArguments.join(' ')}`
|
|
|
|
);
|
|
|
|
this.proc = childProcess.spawn(
|
|
|
|
this._executablePath,
|
|
|
|
this._processArguments,
|
|
|
|
{
|
2020-06-19 14:39:03 +00:00
|
|
|
// On non-windows platforms, `detached: true` makes child process a
|
|
|
|
// leader of a new process group, making it possible to kill child
|
|
|
|
// process tree with `.kill(-pid)` command. @see
|
|
|
|
// https://nodejs.org/api/child_process.html#child_process_options_detached
|
2020-05-12 15:30:13 +00:00
|
|
|
detached: process.platform !== 'win32',
|
|
|
|
env,
|
|
|
|
stdio,
|
|
|
|
}
|
|
|
|
);
|
|
|
|
if (dumpio) {
|
|
|
|
this.proc.stderr.pipe(process.stderr);
|
|
|
|
this.proc.stdout.pipe(process.stdout);
|
|
|
|
}
|
|
|
|
this._closed = false;
|
2021-07-30 07:32:53 +00:00
|
|
|
this._processClosing = new Promise((fulfill, reject) => {
|
2021-11-10 12:31:15 +00:00
|
|
|
this.proc.once('exit', async () => {
|
2020-05-12 15:30:13 +00:00
|
|
|
this._closed = true;
|
|
|
|
// Cleanup as processes exit.
|
2021-11-10 12:31:15 +00:00
|
|
|
if (this._isTempUserDataDir) {
|
|
|
|
try {
|
|
|
|
await removeFolderAsync(this._userDataDir);
|
|
|
|
fulfill();
|
|
|
|
} catch (error) {
|
2022-02-18 08:47:14 +00:00
|
|
|
debugError(error);
|
2021-11-10 12:31:15 +00:00
|
|
|
reject(error);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (this._product === 'firefox') {
|
|
|
|
try {
|
|
|
|
// When an existing user profile has been used remove the user
|
|
|
|
// preferences file and restore possibly backuped preferences.
|
|
|
|
await unlinkAsync(path.join(this._userDataDir, 'user.js'));
|
|
|
|
|
|
|
|
const prefsBackupPath = path.join(
|
|
|
|
this._userDataDir,
|
|
|
|
'prefs.js.puppeteer'
|
|
|
|
);
|
|
|
|
if (fs.existsSync(prefsBackupPath)) {
|
|
|
|
const prefsPath = path.join(this._userDataDir, 'prefs.js');
|
|
|
|
await unlinkAsync(prefsPath);
|
|
|
|
await renameAsync(prefsBackupPath, prefsPath);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
2022-02-18 08:47:14 +00:00
|
|
|
debugError(error);
|
2021-07-30 07:32:53 +00:00
|
|
|
reject(error);
|
2021-11-10 12:31:15 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-12 15:30:13 +00:00
|
|
|
fulfill();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
this._listeners = [
|
|
|
|
helper.addEventListener(process, 'exit', this.kill.bind(this)),
|
|
|
|
];
|
|
|
|
if (handleSIGINT)
|
|
|
|
this._listeners.push(
|
|
|
|
helper.addEventListener(process, 'SIGINT', () => {
|
|
|
|
this.kill();
|
|
|
|
process.exit(130);
|
|
|
|
})
|
|
|
|
);
|
|
|
|
if (handleSIGTERM)
|
|
|
|
this._listeners.push(
|
|
|
|
helper.addEventListener(process, 'SIGTERM', this.close.bind(this))
|
|
|
|
);
|
|
|
|
if (handleSIGHUP)
|
|
|
|
this._listeners.push(
|
|
|
|
helper.addEventListener(process, 'SIGHUP', this.close.bind(this))
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
close(): Promise<void> {
|
|
|
|
if (this._closed) return Promise.resolve();
|
2022-04-19 12:09:23 +00:00
|
|
|
if (this._isTempUserDataDir) {
|
2020-05-12 15:30:13 +00:00
|
|
|
this.kill();
|
|
|
|
} else if (this.connection) {
|
|
|
|
// Attempt to close the browser gracefully
|
|
|
|
this.connection.send('Browser.close').catch((error) => {
|
|
|
|
debugError(error);
|
|
|
|
this.kill();
|
|
|
|
});
|
|
|
|
}
|
2020-06-15 13:02:00 +00:00
|
|
|
// Cleanup this listener last, as that makes sure the full callback runs. If we
|
|
|
|
// perform this earlier, then the previous function calls would not happen.
|
|
|
|
helper.removeEventListeners(this._listeners);
|
2020-05-12 15:30:13 +00:00
|
|
|
return this._processClosing;
|
|
|
|
}
|
|
|
|
|
|
|
|
kill(): void {
|
2020-06-15 13:02:00 +00:00
|
|
|
// If the process failed to launch (for example if the browser executable path
|
|
|
|
// is invalid), then the process does not get a pid assigned. A call to
|
|
|
|
// `proc.kill` would error, as the `pid` to-be-killed can not be found.
|
2022-02-17 12:17:33 +00:00
|
|
|
if (this.proc && this.proc.pid && pidExists(this.proc.pid)) {
|
2020-06-15 13:02:00 +00:00
|
|
|
try {
|
2022-02-17 12:17:33 +00:00
|
|
|
if (process.platform === 'win32') {
|
2022-05-16 13:32:29 +00:00
|
|
|
childProcess.exec(`taskkill /pid ${this.proc.pid} /T /F`, (error) => {
|
|
|
|
if (error) {
|
|
|
|
// taskkill can fail to kill the process e.g. due to missing permissions.
|
|
|
|
// Let's kill the process via Node API. This delays killing of all child
|
|
|
|
// proccesses of `this.proc` until the main Node.js process dies.
|
|
|
|
this.proc.kill();
|
|
|
|
}
|
|
|
|
});
|
2022-02-17 12:17:33 +00:00
|
|
|
} else {
|
|
|
|
// on linux the process group can be killed with the group id prefixed with
|
|
|
|
// a minus sign. The process group id is the group leader's pid.
|
|
|
|
const processGroupId = -this.proc.pid;
|
|
|
|
process.kill(processGroupId, 'SIGKILL');
|
|
|
|
}
|
2020-06-15 13:02:00 +00:00
|
|
|
} catch (error) {
|
|
|
|
throw new Error(
|
|
|
|
`${PROCESS_ERROR_EXPLANATION}\nError cause: ${error.stack}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
2021-11-15 11:52:09 +00:00
|
|
|
|
|
|
|
// Attempt to remove temporary profile directory to avoid littering.
|
|
|
|
try {
|
|
|
|
if (this._isTempUserDataDir) {
|
|
|
|
removeFolder.sync(this._userDataDir);
|
|
|
|
}
|
|
|
|
} catch (error) {}
|
|
|
|
|
2020-06-15 13:02:00 +00:00
|
|
|
// Cleanup this listener last, as that makes sure the full callback runs. If we
|
|
|
|
// perform this earlier, then the previous function calls would not happen.
|
|
|
|
helper.removeEventListeners(this._listeners);
|
2020-05-12 15:30:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async setupConnection(options: {
|
|
|
|
usePipe?: boolean;
|
|
|
|
timeout: number;
|
|
|
|
slowMo: number;
|
|
|
|
preferredRevision: string;
|
|
|
|
}): Promise<Connection> {
|
|
|
|
const { usePipe, timeout, slowMo, preferredRevision } = options;
|
|
|
|
if (!usePipe) {
|
|
|
|
const browserWSEndpoint = await waitForWSEndpoint(
|
|
|
|
this.proc,
|
|
|
|
timeout,
|
|
|
|
preferredRevision
|
|
|
|
);
|
|
|
|
const transport = await WebSocketTransport.create(browserWSEndpoint);
|
|
|
|
this.connection = new Connection(browserWSEndpoint, transport, slowMo);
|
|
|
|
} else {
|
2020-06-19 14:39:03 +00:00
|
|
|
// stdio was assigned during start(), and the 'pipe' option there adds the
|
|
|
|
// 4th and 5th items to stdio array
|
2020-05-12 15:30:13 +00:00
|
|
|
const { 3: pipeWrite, 4: pipeRead } = this.proc.stdio;
|
|
|
|
const transport = new PipeTransport(
|
|
|
|
pipeWrite as NodeJS.WritableStream,
|
|
|
|
pipeRead as NodeJS.ReadableStream
|
|
|
|
);
|
|
|
|
this.connection = new Connection('', transport, slowMo);
|
|
|
|
}
|
|
|
|
return this.connection;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function waitForWSEndpoint(
|
|
|
|
browserProcess: childProcess.ChildProcess,
|
|
|
|
timeout: number,
|
|
|
|
preferredRevision: string
|
|
|
|
): Promise<string> {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const rl = readline.createInterface({ input: browserProcess.stderr });
|
|
|
|
let stderr = '';
|
|
|
|
const listeners = [
|
|
|
|
helper.addEventListener(rl, 'line', onLine),
|
|
|
|
helper.addEventListener(rl, 'close', () => onClose()),
|
|
|
|
helper.addEventListener(browserProcess, 'exit', () => onClose()),
|
|
|
|
helper.addEventListener(browserProcess, 'error', (error) =>
|
|
|
|
onClose(error)
|
|
|
|
),
|
|
|
|
];
|
|
|
|
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {!Error=} error
|
|
|
|
*/
|
|
|
|
function onClose(error?: Error): void {
|
|
|
|
cleanup();
|
|
|
|
reject(
|
|
|
|
new Error(
|
|
|
|
[
|
|
|
|
'Failed to launch the browser process!' +
|
|
|
|
(error ? ' ' + error.message : ''),
|
|
|
|
stderr,
|
|
|
|
'',
|
2020-06-15 15:34:16 +00:00
|
|
|
'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md',
|
2020-05-12 15:30:13 +00:00
|
|
|
'',
|
|
|
|
].join('\n')
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function onTimeout(): void {
|
|
|
|
cleanup();
|
|
|
|
reject(
|
|
|
|
new TimeoutError(
|
|
|
|
`Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.`
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function onLine(line: string): void {
|
|
|
|
stderr += line + '\n';
|
|
|
|
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
|
|
|
|
if (!match) return;
|
|
|
|
cleanup();
|
|
|
|
resolve(match[1]);
|
|
|
|
}
|
|
|
|
|
|
|
|
function cleanup(): void {
|
|
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
|
|
helper.removeEventListeners(listeners);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2022-02-17 12:17:33 +00:00
|
|
|
|
|
|
|
function pidExists(pid: number): boolean {
|
|
|
|
try {
|
|
|
|
return process.kill(pid, 0);
|
|
|
|
} catch (error) {
|
|
|
|
if (error && error.code && error.code === 'ESRCH') {
|
|
|
|
return false;
|
|
|
|
} else {
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|