diff --git a/src/processLauncher.ts b/src/processLauncher.ts new file mode 100644 index 0000000000..02d75dd8fc --- /dev/null +++ b/src/processLauncher.ts @@ -0,0 +1,174 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * 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. + */ + +import * as childProcess from 'child_process'; +import * as stream from 'stream'; +import * as removeFolder from 'rimraf'; +import { helper } from './helper'; +import * as readline from 'readline'; +import { TimeoutError } from './Errors'; + +const removeFolderAsync = helper.promisify(removeFolder); + +export type LaunchProcessOptions = { + executablePath: string, + args: string[], + env?: {[key: string]: string}, + + handleSIGINT?: boolean, + handleSIGTERM?: boolean, + handleSIGHUP?: boolean, + dumpio?: boolean, + pipe?: boolean, + tempDir?: string, +}; + +export type LaunchProcessResult = { + process: childProcess.ChildProcess; + gracefullyClose: () => Promise; +}; + +export async function launchProcess(options: LaunchProcessOptions, attemptToGracefullyClose: () => Promise): Promise { + let stdio: ('ignore' | 'pipe')[] = ['pipe', 'pipe', 'pipe']; + if (options.pipe) { + if (options.dumpio) + stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + else + stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + const spawnedProcess = childProcess.spawn( + options.executablePath, + options.args, + { + // 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 + detached: process.platform !== 'win32', + env: options.env, + stdio + } + ); + + if (!spawnedProcess.pid) { + let reject: (e: Error) => void; + const result = new Promise((f, r) => reject = r); + spawnedProcess.once('error', error => { + reject(new Error('Failed to launch browser: ' + error)); + }); + return result; + } + + if (options.dumpio) { + spawnedProcess.stderr.pipe(process.stderr); + spawnedProcess.stdout.pipe(process.stdout); + } + + let processClosed = false; + const waitForProcessToClose = new Promise((fulfill, reject) => { + spawnedProcess.once('exit', () => { + processClosed = true; + // Cleanup as processes exit. + if (options.tempDir) { + removeFolderAsync(options.tempDir) + .then(() => fulfill()) + .catch((err: Error) => console.error(err)); + } else { + fulfill(); + } + }); + }); + + const listeners = [ helper.addEventListener(process, 'exit', killProcess) ]; + if (options.handleSIGINT) + listeners.push(helper.addEventListener(process, 'SIGINT', () => { killProcess(); process.exit(130); })); + if (options.handleSIGTERM) + listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyClose)); + if (options.handleSIGHUP) + listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyClose)); + return { process: spawnedProcess, gracefullyClose }; + + async function gracefullyClose(): Promise { + helper.removeEventListeners(listeners); + attemptToGracefullyClose().catch(() => killProcess()); + await waitForProcessToClose; + } + + // This method has to be sync to be used as 'exit' event handler. + function killProcess() { + helper.removeEventListeners(listeners); + if (spawnedProcess.pid && !spawnedProcess.killed && !processClosed) { + // Force kill chrome. + try { + if (process.platform === 'win32') + childProcess.execSync(`taskkill /pid ${spawnedProcess.pid} /T /F`); + else + process.kill(-spawnedProcess.pid, 'SIGKILL'); + } catch (e) { + // the process might have already stopped + } + } + // Attempt to remove temporary profile directory to avoid littering. + try { + removeFolder.sync(options.tempDir); + } catch (e) { } + } +} + +export function waitForLine(process: childProcess.ChildProcess, inputStream: stream.Readable, regex: RegExp, timeout: number, timeoutError: TimeoutError): Promise { + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: inputStream }); + let stderr = ''; + const listeners = [ + helper.addEventListener(rl, 'line', onLine), + helper.addEventListener(rl, 'close', () => onClose()), + helper.addEventListener(process, 'exit', () => onClose()), + helper.addEventListener(process, 'error', error => onClose(error)) + ]; + const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; + + function onClose(error?: Error) { + cleanup(); + reject(new Error([ + 'Failed to launch browser!' + (error ? ' ' + error.message : ''), + stderr, + '', + 'TROUBLESHOOTING: https://github.com/Microsoft/playwright/blob/master/docs/troubleshooting.md', + '', + ].join('\n'))); + } + + function onTimeout() { + cleanup(); + reject(timeoutError); + } + + function onLine(line: string) { + stderr += line + '\n'; + const match = line.match(regex); + if (!match) + return; + cleanup(); + resolve(match); + } + + function cleanup() { + if (timeoutId) + clearTimeout(timeoutId); + helper.removeEventListeners(listeners); + } + }); +}