Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(electron): ♻️ use async model for ChildProcessHelper #1455

Merged
merged 2 commits into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
112 changes: 57 additions & 55 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 @@ -102,8 +100,7 @@ export class GoVpnTunnel implements VpnTunnel {
this.isUdpEnabled = await checkConnectivity(this.config);
}
console.log(`UDP support: ${this.isUdpEnabled}`);
await this.tun2socks.start(this.isUdpEnabled);

this.tun2socks.start(this.isUdpEnabled);
await this.routing.start();
}

Expand Down Expand Up @@ -161,7 +158,7 @@ export class GoVpnTunnel implements VpnTunnel {

// Restart tun2socks.
await this.tun2socks.stop();
await this.tun2socks.start(this.isUdpEnabled);
this.tun2socks.start(this.isUdpEnabled);
}

// Use #onceDisconnected to be notified when the tunnel terminates.
Expand Down Expand Up @@ -214,13 +211,14 @@ 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 autoRestart = false;
private readonly process: ChildProcessHelper;

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

async start(isUdpEnabled: boolean) {
start(isUdpEnabled: boolean): void {
// ./tun2socks.exe \
// -tunName outline-tap0 -tunDNS 1.1.1.1,9.9.9.9 \
// -tunAddr 10.0.85.2 -tunGw 10.0.85.1 -tunMask 255.255.255.0 \
Expand All @@ -241,39 +239,52 @@ class GoTun2socks {
args.push('-dnsFallback');
}

return new Promise<void>((resolve, reject) => {
this.process.onExit = (code?: number) => {
reject(errors.fromErrorCode(code ?? errors.ErrorCode.UNEXPECTED));
};
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);
};
this.process.onStdErr = null;
resolve();
};
this.process.launch(args);
});
this.autoRestart = true;
this.process.onStdErr = async (data?: string | Buffer) => {
if (!data?.toString().includes('tun2socks running')) {
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
return;
}
console.debug('tun2socks started');
this.process.onStdErr = null;
// try to auto restart tun2socks
const exitCode = await this.process.waitForEnd();
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
if (this.autoRestart) {
console.warn(`tun2socks exited unexpectedly with code/signal: ${exitCode}. Restarting...`);
this.start(isUdpEnabled);
} else {
console.info(`tun2socks exited with ${exitCode} as expected`);
}
};
this.process.launch(args);
}

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() {
this.autoRestart = false;
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() {
console.debug('using tun2socks to check connectivity');
this.process.launch([
'-proxyHost',
this.config.host || '',
'-proxyPort',
`${this.config.port}`,
'-proxyPassword',
this.config.password || '',
'-proxyCipher',
this.config.method || '',
'-checkConnectivity',
]);
return this.process.waitForEnd();
}

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

Expand All @@ -282,27 +293,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();
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
} 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);
}
131 changes: 75 additions & 56 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,107 @@ 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;
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
private exitCodePromise?: Promise<number | string> = Promise.resolve('not started');
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.
* started in verbose mode if supported. This method will only start the process, it will not
* wait for it to be ended. Please use stop() or waitForExit() instead.
* @param args The args for the process
*/
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[]): void {
if (this.subProcess) {
throw new Error(`subprocess ${this.processName} has already been launched`);
}
this.subProcess = spawn(this.path, args);
this.exitCodePromise = new Promise(resolve => {
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);
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
};

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();
this.subProcess.kill();
return this.exitCodePromise;
}

set onExit(newListener: ((code?: number, signal?: string) => void) | undefined) {
this.exitListener = newListener;
/**
* Wait for the process to end, and get out the exit code.
* @returns Either an exit code or a signal string (if the process is ended by a signal).
*/
waitForEnd(): Promise<number | string> {
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