Skip to content

Commit

Permalink
refactor(electron): ♻️ use async model for ChildProcessHelper
Browse files Browse the repository at this point in the history
  • Loading branch information
jyyi1 committed Oct 26, 2022
1 parent 0d32b1e commit 69926a8
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 101 deletions.
86 changes: 42 additions & 44 deletions src/electron/go_vpn_tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {execFile} from 'child_process';
import {powerMonitor} from 'electron';
import {platform} from 'os';
import {promisify} from 'util';

import {pathToEmbeddedBinary} from '../infrastructure/electron/app_paths';
import {ShadowsocksSessionConfig} from '../www/app/tunnel';
Expand Down Expand Up @@ -214,9 +212,9 @@ export class GoVpnTunnel implements VpnTunnel {
// outline-go-tun2socks is a Go program that processes IP traffic from a TUN/TAP device
// and relays it to a Shadowsocks proxy server.
class GoTun2socks {
private process: ChildProcessHelper;
private readonly process: ChildProcessHelper;

constructor(private config: ShadowsocksSessionConfig) {
constructor(private readonly config: ShadowsocksSessionConfig) {
this.process = new ChildProcessHelper(pathToEmbeddedBinary('outline-go-tun2socks', 'tun2socks'));
}

Expand All @@ -241,39 +239,48 @@ class GoTun2socks {
args.push('-dnsFallback');
}

return new Promise<void>((resolve, reject) => {
this.process.onExit = (code?: number) => {
reject(errors.fromErrorCode(code ?? errors.ErrorCode.UNEXPECTED));
};
let needRestart = false;
do {
this.process.onStdErr = (data?: string | Buffer) => {
if (!data?.toString().includes('tun2socks running')) {
return;
}
console.debug('tun2socks started');
this.process.onExit = async (code?: number, signal?: string) => {
// The process exited unexpectedly, restart it.
console.warn(`tun2socks exited unexpectedly with signal: ${signal}, code: ${code}. Restarting...`);
await this.start(isUdpEnabled);
};
needRestart = true;
console.warn('unexpected errors occured in tun2socks, we will restart it later...');
this.process.onStdErr = null;
resolve();
};
this.process.launch(args);
});
const exitCode = await this.process.launch(args);
if (exitCode !== errors.ErrorCode.NO_ERROR) {
throw errors.fromErrorCode(typeof exitCode === 'number' ? exitCode : errors.ErrorCode.UNEXPECTED);
}
} while (needRestart);
}

async stop() {
return new Promise<void>(resolve => {
this.process.onExit = (code?: number, signal?: string) => {
console.log(`tun2socks stopped with signal: ${signal}, code: ${code}.`);
resolve();
};
this.process.stop();
});
stop() {
return this.process.stop();
}

/**
* Checks connectivity and exits with an error code as defined in `errors.ErrorCode`
* -tun* and -dnsFallback options have no effect on this mode.
*/
checkConnectivity() {
return this.process.launch([
'-proxyHost',
this.config.host || '',
'-proxyPort',
`${this.config.port}`,
'-proxyPassword',
this.config.password || '',
'-proxyCipher',
this.config.method || '',
'-checkConnectivity',
]);
}

enableDebugMode() {
this.process.enableDebugMode();
this.process.isDebugModeEnabled = true;
}
}

Expand All @@ -282,27 +289,18 @@ class GoTun2socks {
// forwarding and validates the proxy credentials. Resolves with a boolean indicating whether UDP
// forwarding is supported. Throws if the checks fail or if the process fails to start.
async function checkConnectivity(config: ShadowsocksSessionConfig) {
const args = [];
args.push('-proxyHost', config.host || '');
args.push('-proxyPort', `${config.port}`);
args.push('-proxyPassword', config.password || '');
args.push('-proxyCipher', config.method || '');
// Checks connectivity and exits with an error code as defined in `errors.ErrorCode`
// -tun* and -dnsFallback options have no effect on this mode.
args.push('-checkConnectivity');

const exec = promisify(execFile);
let exitCode: number | string = errors.ErrorCode.UNEXPECTED;
try {
await exec(pathToEmbeddedBinary('outline-go-tun2socks', 'tun2socks'), args);
exitCode = await new GoTun2socks(config).checkConnectivity();
} catch (e) {
console.error(`connectivity check failed: ${e}`);
const code = e.status;
if (code === errors.ErrorCode.UDP_RELAY_NOT_ENABLED) {
// Don't treat lack of UDP support as an error, relay to the caller.
return false;
}
// Treat the absence of a code as an unexpected error.
throw errors.fromErrorCode(code ?? errors.ErrorCode.UNEXPECTED);
throw new errors.UnexpectedPluginError();
}
if (exitCode === errors.ErrorCode.NO_ERROR) {
return true;
} else if (exitCode === errors.ErrorCode.UDP_RELAY_NOT_ENABLED) {
// Don't treat lack of UDP support as an error, relay to the caller.
return false;
}
return true;
throw errors.fromErrorCode(typeof exitCode === 'number' ? exitCode : errors.ErrorCode.UNEXPECTED);
}
126 changes: 69 additions & 57 deletions src/electron/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {ChildProcess, spawn} from 'child_process';
import * as path from 'path';
import {ChildProcess, spawn} from 'node:child_process';
import {basename} from 'node:path';
import process from 'node:process';

// Simple "one shot" child process launcher.
//
Expand All @@ -22,89 +23,100 @@ import * as path from 'path';
// (which may be immediately after calling #startInternal if, e.g. the binary cannot be
// found).
export class ChildProcessHelper {
private process?: ChildProcess;
protected isInDebugMode = false;
private readonly processName: string;
private subProcess?: ChildProcess = null;
private exitCodePromise?: Promise<number | string> = null;
private isDebug = false;

private exitListener?: (code?: number, signal?: string) => void;
private stdErrListener?: (data?: string | Buffer) => void;

constructor(private path: string) {}
constructor(private readonly path: string) {
this.processName = basename(this.path);
}

/**
* Starts the process with the given args. If enableDebug() has been called, then the process is
* started in verbose mode if supported.
* @param args The args for the process
* @returns Either an exit code or a signal string (if the process is ended by a signal).
*/
launch(args: string[]) {
this.process = spawn(this.path, args);
const processName = path.basename(this.path);

const onExit = (code?: number, signal?: string) => {
if (this.process) {
this.process.removeAllListeners();
}
if (this.exitListener) {
this.exitListener(code, signal);
}

logExit(processName, code, signal);
};
const onStdErr = (data?: string | Buffer) => {
if (this.isInDebugMode) {
console.error(`[STDERR - ${processName}]: ${data}`);
}
if (this.stdErrListener) {
this.stdErrListener(data);
}
};
this.process.stderr.on('data', onStdErr.bind(this));

if (this.isInDebugMode) {
// Redirect subprocess output while bypassing the Node console. This makes sure we don't
// send web traffic information to Sentry.
this.process.stdout.pipe(process.stdout);
this.process.stderr.pipe(process.stderr);
launch(args: string[]): Promise<number | string> {
if (this.exitCodePromise) {
throw new Error(`subprocess ${this.processName} has already been launched`);
}
return (this.exitCodePromise = new Promise(resolve => {
this.subProcess = spawn(this.path, args);

const onExit = (code?: number, signal?: string) => {
if (this.subProcess) {
this.subProcess.removeAllListeners();
this.subProcess = null;
} else {
// When listening to both the 'exit' and 'error' events, guard against accidentally
// invoking handler functions multiple times.
return;
}

logExit(this.processName, code, signal);
resolve(code ?? signal);
};

const onStdErr = (data?: string | Buffer) => {
if (this.isDebugModeEnabled) {
console.error(`[STDERR - ${this.processName}]: ${data}`);
}
if (this.stdErrListener) {
this.stdErrListener(data);
}
};
this.subProcess.stderr.on('data', onStdErr.bind(this));

if (this.isDebugModeEnabled) {
// Redirect subprocess output while bypassing the Node console. This makes sure we don't
// send web traffic information to Sentry.
this.subProcess.stdout.pipe(process.stdout);
this.subProcess.stderr.pipe(process.stderr);
}

// We have to listen for both events: error means the process could not be launched and in that
// case exit will not be invoked.
this.process.on('error', onExit.bind(this));
this.process.on('exit', onExit.bind(this));
// We have to listen for both events: error means the process could not be launched and in that
// case exit will not be invoked.
this.subProcess.on('error', onExit.bind(this));
this.subProcess.on('exit', onExit.bind(this));
}));
}

// Use #onExit to be notified when the process exits.
stop() {
if (!this.process) {
/**
* Try to kill the process and wait for the exit code.
* @returns Either an exit code or a signal string (if the process is ended by a signal).
*/
stop(): Promise<number | string> {
if (!this.subProcess) {
// Never started.
if (this.exitListener) {
this.exitListener(null, null);
}
return;
}

this.process.kill();
}

set onExit(newListener: ((code?: number, signal?: string) => void) | undefined) {
this.exitListener = newListener;
this.subProcess.kill();
return this.exitCodePromise;
}

set onStdErr(listener: ((data?: string | Buffer) => void) | undefined) {
this.stdErrListener = listener;
if (!this.stdErrListener && !this.isDebugModeEnabled) {
this.process.stderr.removeAllListeners();
this.subProcess?.stderr.removeAllListeners();
}
}

/**
* Enables verbose logging for the process. Must be called before launch().
* Whether to enable verbose logging for the process. Must be called before launch().
*/
enableDebugMode() {
this.isInDebugMode = true;
set isDebugModeEnabled(value: boolean) {
this.isDebug = value;
}

get isDebugModeEnabled() {
return this.isInDebugMode;
/**
* Get a value indicates whether verbose logging for the process is enabled.
*/
get isDebugModeEnabled(): boolean {
return this.isDebug;
}
}

Expand Down

0 comments on commit 69926a8

Please sign in to comment.