import * as childProcess from 'child_process';
import { ChildProcess } from 'child_process';
import * as readline from 'readline';
import Log from '@secret-agent/commons/Logger';
import * as Fs from 'fs';
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
import IBrowserEngine from '@secret-agent/interfaces/IBrowserEngine';
import { bindFunctions } from '@secret-agent/commons/utils';
import { PipeTransport } from './PipeTransport';
const { log } = Log(module);
const logProcessExit = process.env.NODE_ENV !== 'test';
export default class BrowserProcess extends TypedEventEmitter<{ close: void }> {
public readonly transport: PipeTransport;
public hasLaunchError: Promise<Error>;
private processKilled = false;
private readonly launchedProcess: ChildProcess;
constructor(private browserEngine: IBrowserEngine, private env?: NodeJS.ProcessEnv) {
super();
bindFunctions(this);
this.launchedProcess = this.launch();
this.bindProcessEvents();
this.transport = new PipeTransport(this.launchedProcess);
this.bindCloseHandlers();
}
async close(): Promise<void> {
this.gracefulCloseBrowser();
await this.killChildProcess();
}
private bindCloseHandlers(): void {
process.once('exit', this.close);
process.once('uncaughtExceptionMonitor', this.close);
this.transport.onCloseFns.push(this.close);
}
private launch(): ChildProcess {
const { name, executablePath, launchArguments } = this.browserEngine;
log.info(`${name}.LaunchProcess`, { sessionId: null, executablePath, launchArguments });
return childProcess.spawn(executablePath, launchArguments, {
detached: process.platform !== 'win32',
env: this.env,
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
});
}
private bindProcessEvents(): void {
let error: Error;
this.launchedProcess.on('error', e => {
error = e;
});
if (!this.launchedProcess.pid) {
this.hasLaunchError = new Promise<Error>(resolve => {
if (error) return resolve(error);
this.launchedProcess.once('error', err => {
resolve(new Error(`Failed to launch browser: ${err}`));
});
});
}
const { stdout, stderr } = this.launchedProcess;
const name = this.browserEngine.name;
readline.createInterface({ input: stdout }).on('line', line => {
if (line) log.stats(`${name}.stdout`, { message: line, sessionId: null });
});
readline.createInterface({ input: stderr }).on('line', line => {
if (line) log.warn(`${name}.stderr`, { message: line, sessionId: null });
});
this.launchedProcess.once('exit', this.onChildProcessExit);
}
private gracefulCloseBrowser(): void {
try {
if (this.transport && !this.transport.isClosed) {
this.transport.send(JSON.stringify({ method: 'Browser.close', id: -1 }));
this.transport.close();
}
} catch (e) {
}
}
private async killChildProcess(): Promise<void> {
const launchedProcess = this.launchedProcess;
try {
if (!launchedProcess.killed && !this.processKilled) {
const closed = new Promise<void>(resolve => launchedProcess.once('exit', resolve));
if (process.platform === 'win32') {
childProcess.execSync(`taskkill /pid ${launchedProcess.pid} /T /F 2> nul`);
} else {
launchedProcess.kill('SIGKILL');
}
launchedProcess.emit('exit');
await closed;
}
} catch (e) {
}
}
private onChildProcessExit(exitCode: number, signal: NodeJS.Signals): void {
if (this.processKilled) return;
this.processKilled = true;
try {
this.transport?.close();
} catch (e) {
}
if (logProcessExit) {
const name = this.browserEngine.name;
log.stats(`${name}.ProcessExited`, { exitCode, signal, sessionId: null });
}
this.emit('close');
this.cleanDataDir();
}
private cleanDataDir(retries = 3): void {
const datadir = this.browserEngine.userDataDir;
if (!datadir) return;
try {
if (Fs.existsSync(datadir)) {
const fn = 'rmSync' in Fs ? 'rmSync' : 'rmdirSync';
Fs[fn](datadir, { recursive: true });
}
} catch (err) {
if (retries >= 0) {
this.cleanDataDir(retries - 1);
}
}
}
}