tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

launch.ts (16264B)


      1 /**
      2 * @license
      3 * Copyright 2023 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 
      7 import childProcess from 'node:child_process';
      8 import {accessSync} from 'node:fs';
      9 import os from 'node:os';
     10 import readline from 'node:readline';
     11 
     12 import {
     13  type Browser,
     14  type BrowserPlatform,
     15  resolveSystemExecutablePath,
     16  type ChromeReleaseChannel,
     17  executablePathByBrowser,
     18 } from './browser-data/browser-data.js';
     19 import {Cache} from './Cache.js';
     20 import {debug} from './debug.js';
     21 import {detectBrowserPlatform} from './detectPlatform.js';
     22 
     23 const debugLaunch = debug('puppeteer:browsers:launcher');
     24 
     25 /**
     26 * @public
     27 */
     28 export interface ComputeExecutablePathOptions {
     29  /**
     30   * Root path to the storage directory.
     31   *
     32   * Can be set to `null` if the executable path should be relative
     33   * to the extracted download location. E.g. `./chrome-linux64/chrome`.
     34   */
     35  cacheDir: string | null;
     36  /**
     37   * Determines which platform the browser will be suited for.
     38   *
     39   * @defaultValue **Auto-detected.**
     40   */
     41  platform?: BrowserPlatform;
     42  /**
     43   * Determines which browser to launch.
     44   */
     45  browser: Browser;
     46  /**
     47   * Determines which buildId to download. BuildId should uniquely identify
     48   * binaries and they are used for caching.
     49   */
     50  buildId: string;
     51 }
     52 
     53 /**
     54 * @public
     55 */
     56 export function computeExecutablePath(
     57  options: ComputeExecutablePathOptions,
     58 ): string {
     59  if (options.cacheDir === null) {
     60    options.platform ??= detectBrowserPlatform();
     61    if (options.platform === undefined) {
     62      throw new Error(
     63        `No platform specified. Couldn't auto-detect browser platform.`,
     64      );
     65    }
     66    return executablePathByBrowser[options.browser](
     67      options.platform,
     68      options.buildId,
     69    );
     70  }
     71 
     72  return new Cache(options.cacheDir).computeExecutablePath(options);
     73 }
     74 
     75 /**
     76 * @public
     77 */
     78 export interface SystemOptions {
     79  /**
     80   * Determines which platform the browser will be suited for.
     81   *
     82   * @defaultValue **Auto-detected.**
     83   */
     84  platform?: BrowserPlatform;
     85  /**
     86   * Determines which browser to launch.
     87   */
     88  browser: Browser;
     89  /**
     90   * Release channel to look for on the system.
     91   */
     92  channel: ChromeReleaseChannel;
     93 }
     94 
     95 /**
     96 * Returns a path to a system-wide Chrome installation given a release channel
     97 * name by checking known installation locations (using
     98 * https://pptr.dev/browsers-api/browsers.computesystemexecutablepath/). If
     99 * Chrome instance is not found at the expected path, an error is thrown.
    100 *
    101 * @public
    102 */
    103 export function computeSystemExecutablePath(options: SystemOptions): string {
    104  options.platform ??= detectBrowserPlatform();
    105  if (!options.platform) {
    106    throw new Error(
    107      `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`,
    108    );
    109  }
    110  const path = resolveSystemExecutablePath(
    111    options.browser,
    112    options.platform,
    113    options.channel,
    114  );
    115  try {
    116    accessSync(path);
    117  } catch {
    118    throw new Error(
    119      `Could not find Google Chrome executable for channel '${options.channel}' at '${path}'.`,
    120    );
    121  }
    122  return path;
    123 }
    124 
    125 /**
    126 * @public
    127 */
    128 export interface LaunchOptions {
    129  /**
    130   * Absolute path to the browser's executable.
    131   */
    132  executablePath: string;
    133  /**
    134   * Configures stdio streams to open two additional streams for automation over
    135   * those streams instead of WebSocket.
    136   *
    137   * @defaultValue `false`.
    138   */
    139  pipe?: boolean;
    140  /**
    141   * If true, forwards the browser's process stdout and stderr to the Node's
    142   * process stdout and stderr.
    143   *
    144   * @defaultValue `false`.
    145   */
    146  dumpio?: boolean;
    147  /**
    148   * Additional arguments to pass to the executable when launching.
    149   */
    150  args?: string[];
    151  /**
    152   * Environment variables to set for the browser process.
    153   */
    154  env?: Record<string, string | undefined>;
    155  /**
    156   * Handles SIGINT in the Node process and tries to kill the browser process.
    157   *
    158   * @defaultValue `true`.
    159   */
    160  handleSIGINT?: boolean;
    161  /**
    162   * Handles SIGTERM in the Node process and tries to gracefully close the browser
    163   * process.
    164   *
    165   * @defaultValue `true`.
    166   */
    167  handleSIGTERM?: boolean;
    168  /**
    169   * Handles SIGHUP in the Node process and tries to gracefully close the browser process.
    170   *
    171   * @defaultValue `true`.
    172   */
    173  handleSIGHUP?: boolean;
    174  /**
    175   * Whether to spawn process in the {@link https://nodejs.org/api/child_process.html#optionsdetached | detached}
    176   * mode.
    177   *
    178   * @defaultValue `true` except on Windows.
    179   */
    180  detached?: boolean;
    181  /**
    182   * A callback to run after the browser process exits or before the process
    183   * will be closed via the {@link Process.close} call (including when handling
    184   * signals). The callback is only run once.
    185   */
    186  onExit?: () => Promise<void>;
    187 }
    188 
    189 /**
    190 * Launches a browser process according to {@link LaunchOptions}.
    191 *
    192 * @public
    193 */
    194 export function launch(opts: LaunchOptions): Process {
    195  return new Process(opts);
    196 }
    197 
    198 /**
    199 * @public
    200 */
    201 export const CDP_WEBSOCKET_ENDPOINT_REGEX =
    202  /^DevTools listening on (ws:\/\/.*)$/;
    203 
    204 /**
    205 * @public
    206 */
    207 export const WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX =
    208  /^WebDriver BiDi listening on (ws:\/\/.*)$/;
    209 
    210 type EventHandler = (...args: any[]) => void;
    211 const processListeners = new Map<string, EventHandler[]>();
    212 const dispatchers = {
    213  exit: (...args: any[]) => {
    214    processListeners.get('exit')?.forEach(handler => {
    215      return handler(...args);
    216    });
    217  },
    218  SIGINT: (...args: any[]) => {
    219    processListeners.get('SIGINT')?.forEach(handler => {
    220      return handler(...args);
    221    });
    222  },
    223  SIGHUP: (...args: any[]) => {
    224    processListeners.get('SIGHUP')?.forEach(handler => {
    225      return handler(...args);
    226    });
    227  },
    228  SIGTERM: (...args: any[]) => {
    229    processListeners.get('SIGTERM')?.forEach(handler => {
    230      return handler(...args);
    231    });
    232  },
    233 };
    234 
    235 function subscribeToProcessEvent(
    236  event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM',
    237  handler: EventHandler,
    238 ): void {
    239  const listeners = processListeners.get(event) || [];
    240  if (listeners.length === 0) {
    241    process.on(event, dispatchers[event]);
    242  }
    243  listeners.push(handler);
    244  processListeners.set(event, listeners);
    245 }
    246 
    247 function unsubscribeFromProcessEvent(
    248  event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM',
    249  handler: EventHandler,
    250 ): void {
    251  const listeners = processListeners.get(event) || [];
    252  const existingListenerIdx = listeners.indexOf(handler);
    253  if (existingListenerIdx === -1) {
    254    return;
    255  }
    256  listeners.splice(existingListenerIdx, 1);
    257  processListeners.set(event, listeners);
    258  if (listeners.length === 0) {
    259    process.off(event, dispatchers[event]);
    260  }
    261 }
    262 
    263 /**
    264 * @public
    265 */
    266 export class Process {
    267  #executablePath;
    268  #args: string[];
    269  #browserProcess: childProcess.ChildProcess;
    270  #exited = false;
    271  // The browser process can be closed externally or from the driver process. We
    272  // need to invoke the hooks only once though but we don't know how many times
    273  // we will be invoked.
    274  #hooksRan = false;
    275  #onExitHook = async () => {};
    276  #browserProcessExiting: Promise<void>;
    277 
    278  constructor(opts: LaunchOptions) {
    279    this.#executablePath = opts.executablePath;
    280    this.#args = opts.args ?? [];
    281 
    282    opts.pipe ??= false;
    283    opts.dumpio ??= false;
    284    opts.handleSIGINT ??= true;
    285    opts.handleSIGTERM ??= true;
    286    opts.handleSIGHUP ??= true;
    287    // On non-windows platforms, `detached: true` makes child process a
    288    // leader of a new process group, making it possible to kill child
    289    // process tree with `.kill(-pid)` command. @see
    290    // https://nodejs.org/api/child_process.html#child_process_options_detached
    291    opts.detached ??= process.platform !== 'win32';
    292 
    293    const stdio = this.#configureStdio({
    294      pipe: opts.pipe,
    295      dumpio: opts.dumpio,
    296    });
    297 
    298    const env = opts.env || {};
    299 
    300    debugLaunch(`Launching ${this.#executablePath} ${this.#args.join(' ')}`, {
    301      detached: opts.detached,
    302      env: Object.keys(env).reduce<Record<string, string | undefined>>(
    303        (res, key) => {
    304          if (key.toLowerCase().startsWith('puppeteer_')) {
    305            res[key] = env[key];
    306          }
    307          return res;
    308        },
    309        {},
    310      ),
    311      stdio,
    312    });
    313 
    314    this.#browserProcess = childProcess.spawn(
    315      this.#executablePath,
    316      this.#args,
    317      {
    318        detached: opts.detached,
    319        env,
    320        stdio,
    321      },
    322    );
    323 
    324    debugLaunch(`Launched ${this.#browserProcess.pid}`);
    325    if (opts.dumpio) {
    326      this.#browserProcess.stderr?.pipe(process.stderr);
    327      this.#browserProcess.stdout?.pipe(process.stdout);
    328    }
    329    subscribeToProcessEvent('exit', this.#onDriverProcessExit);
    330    if (opts.handleSIGINT) {
    331      subscribeToProcessEvent('SIGINT', this.#onDriverProcessSignal);
    332    }
    333    if (opts.handleSIGTERM) {
    334      subscribeToProcessEvent('SIGTERM', this.#onDriverProcessSignal);
    335    }
    336    if (opts.handleSIGHUP) {
    337      subscribeToProcessEvent('SIGHUP', this.#onDriverProcessSignal);
    338    }
    339    if (opts.onExit) {
    340      this.#onExitHook = opts.onExit;
    341    }
    342    this.#browserProcessExiting = new Promise((resolve, reject) => {
    343      this.#browserProcess.once('exit', async () => {
    344        debugLaunch(`Browser process ${this.#browserProcess.pid} onExit`);
    345        this.#clearListeners();
    346        this.#exited = true;
    347        try {
    348          await this.#runHooks();
    349        } catch (err) {
    350          reject(err);
    351          return;
    352        }
    353        resolve();
    354      });
    355    });
    356  }
    357 
    358  async #runHooks() {
    359    if (this.#hooksRan) {
    360      return;
    361    }
    362    this.#hooksRan = true;
    363    await this.#onExitHook();
    364  }
    365 
    366  get nodeProcess(): childProcess.ChildProcess {
    367    return this.#browserProcess;
    368  }
    369 
    370  #configureStdio(opts: {
    371    pipe: boolean;
    372    dumpio: boolean;
    373  }): Array<'ignore' | 'pipe'> {
    374    if (opts.pipe) {
    375      if (opts.dumpio) {
    376        return ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
    377      } else {
    378        return ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'];
    379      }
    380    } else {
    381      if (opts.dumpio) {
    382        return ['pipe', 'pipe', 'pipe'];
    383      } else {
    384        return ['pipe', 'ignore', 'pipe'];
    385      }
    386    }
    387  }
    388 
    389  #clearListeners(): void {
    390    unsubscribeFromProcessEvent('exit', this.#onDriverProcessExit);
    391    unsubscribeFromProcessEvent('SIGINT', this.#onDriverProcessSignal);
    392    unsubscribeFromProcessEvent('SIGTERM', this.#onDriverProcessSignal);
    393    unsubscribeFromProcessEvent('SIGHUP', this.#onDriverProcessSignal);
    394  }
    395 
    396  #onDriverProcessExit = (_code: number) => {
    397    this.kill();
    398  };
    399 
    400  #onDriverProcessSignal = (signal: string): void => {
    401    switch (signal) {
    402      case 'SIGINT':
    403        this.kill();
    404        process.exit(130);
    405      case 'SIGTERM':
    406      case 'SIGHUP':
    407        void this.close();
    408        break;
    409    }
    410  };
    411 
    412  async close(): Promise<void> {
    413    await this.#runHooks();
    414    if (!this.#exited) {
    415      this.kill();
    416    }
    417    return await this.#browserProcessExiting;
    418  }
    419 
    420  hasClosed(): Promise<void> {
    421    return this.#browserProcessExiting;
    422  }
    423 
    424  kill(): void {
    425    debugLaunch(`Trying to kill ${this.#browserProcess.pid}`);
    426    // If the process failed to launch (for example if the browser executable path
    427    // is invalid), then the process does not get a pid assigned. A call to
    428    // `proc.kill` would error, as the `pid` to-be-killed can not be found.
    429    if (
    430      this.#browserProcess &&
    431      this.#browserProcess.pid &&
    432      pidExists(this.#browserProcess.pid)
    433    ) {
    434      try {
    435        debugLaunch(`Browser process ${this.#browserProcess.pid} exists`);
    436        if (process.platform === 'win32') {
    437          try {
    438            childProcess.execSync(
    439              `taskkill /pid ${this.#browserProcess.pid} /T /F`,
    440            );
    441          } catch (error) {
    442            debugLaunch(
    443              `Killing ${this.#browserProcess.pid} using taskkill failed`,
    444              error,
    445            );
    446            // taskkill can fail to kill the process e.g. due to missing permissions.
    447            // Let's kill the process via Node API. This delays killing of all child
    448            // processes of `this.proc` until the main Node.js process dies.
    449            this.#browserProcess.kill();
    450          }
    451        } else {
    452          // on linux the process group can be killed with the group id prefixed with
    453          // a minus sign. The process group id is the group leader's pid.
    454          const processGroupId = -this.#browserProcess.pid;
    455 
    456          try {
    457            process.kill(processGroupId, 'SIGKILL');
    458          } catch (error) {
    459            debugLaunch(
    460              `Killing ${this.#browserProcess.pid} using process.kill failed`,
    461              error,
    462            );
    463            // Killing the process group can fail due e.g. to missing permissions.
    464            // Let's kill the process via Node API. This delays killing of all child
    465            // processes of `this.proc` until the main Node.js process dies.
    466            this.#browserProcess.kill('SIGKILL');
    467          }
    468        }
    469      } catch (error) {
    470        throw new Error(
    471          `${PROCESS_ERROR_EXPLANATION}\nError cause: ${
    472            isErrorLike(error) ? error.stack : error
    473          }`,
    474        );
    475      }
    476    }
    477    this.#clearListeners();
    478  }
    479 
    480  waitForLineOutput(regex: RegExp, timeout = 0): Promise<string> {
    481    if (!this.#browserProcess.stderr) {
    482      throw new Error('`browserProcess` does not have stderr.');
    483    }
    484    const rl = readline.createInterface(this.#browserProcess.stderr);
    485    let stderr = '';
    486 
    487    return new Promise((resolve, reject) => {
    488      rl.on('line', onLine);
    489      rl.on('close', onClose);
    490      this.#browserProcess.on('exit', onClose);
    491      this.#browserProcess.on('error', onClose);
    492      const timeoutId =
    493        timeout > 0 ? setTimeout(onTimeout, timeout) : undefined;
    494 
    495      const cleanup = (): void => {
    496        clearTimeout(timeoutId);
    497        rl.off('line', onLine);
    498        rl.off('close', onClose);
    499        rl.close();
    500        this.#browserProcess.off('exit', onClose);
    501        this.#browserProcess.off('error', onClose);
    502      };
    503 
    504      function onClose(error?: Error): void {
    505        cleanup();
    506        reject(
    507          new Error(
    508            [
    509              `Failed to launch the browser process!${
    510                error ? ' ' + error.message : ''
    511              }`,
    512              stderr,
    513              '',
    514              'TROUBLESHOOTING: https://pptr.dev/troubleshooting',
    515              '',
    516            ].join('\n'),
    517          ),
    518        );
    519      }
    520 
    521      function onTimeout(): void {
    522        cleanup();
    523        reject(
    524          new TimeoutError(
    525            `Timed out after ${timeout} ms while waiting for the WS endpoint URL to appear in stdout!`,
    526          ),
    527        );
    528      }
    529 
    530      function onLine(line: string): void {
    531        stderr += line + '\n';
    532        const match = line.match(regex);
    533        if (!match) {
    534          return;
    535        }
    536        cleanup();
    537        // The RegExp matches, so this will obviously exist.
    538        resolve(match[1]!);
    539      }
    540    });
    541  }
    542 }
    543 
    544 const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary.
    545 This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser.
    546 Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed.
    547 If you think this is a bug, please report it on the Puppeteer issue tracker.`;
    548 
    549 /**
    550 * @internal
    551 */
    552 function pidExists(pid: number): boolean {
    553  try {
    554    return process.kill(pid, 0);
    555  } catch (error) {
    556    if (isErrnoException(error)) {
    557      if (error.code && error.code === 'ESRCH') {
    558        return false;
    559      }
    560    }
    561    throw error;
    562  }
    563 }
    564 
    565 /**
    566 * @internal
    567 */
    568 export interface ErrorLike extends Error {
    569  name: string;
    570  message: string;
    571 }
    572 
    573 /**
    574 * @internal
    575 */
    576 export function isErrorLike(obj: unknown): obj is ErrorLike {
    577  return (
    578    typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj
    579  );
    580 }
    581 /**
    582 * @internal
    583 */
    584 export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException {
    585  return (
    586    isErrorLike(obj) &&
    587    ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj)
    588  );
    589 }
    590 
    591 /**
    592 * @public
    593 */
    594 export class TimeoutError extends Error {
    595  /**
    596   * @internal
    597   */
    598  constructor(message?: string) {
    599    super(message);
    600    this.name = this.constructor.name;
    601    Error.captureStackTrace(this, this.constructor);
    602  }
    603 }