arduino-ide/arduino-ide-extension/src/node/arduino-daemon-impl.ts
Akos Kitta a36524e02a Update package index on 3rd party URLs change.
Closes #637
Closes #906

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-07-08 09:04:10 +02:00

372 lines
10 KiB
TypeScript

import { join } from 'path';
import { promises as fs } from 'fs';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { spawn, ChildProcess } from 'child_process';
import { FileUri } from '@theia/core/lib/node/file-uri';
import { ILogger } from '@theia/core/lib/common/logger';
import { Deferred, retry } from '@theia/core/lib/common/promise-util';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { environment } from '@theia/application-package/lib/environment';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import { ArduinoDaemon, NotificationServiceServer } from '../common/protocol';
import { CLI_CONFIG } from './cli-config';
import { getExecPath, spawnCommand } from './exec-util';
@injectable()
export class ArduinoDaemonImpl
implements ArduinoDaemon, BackendApplicationContribution
{
@inject(ILogger)
@named('daemon')
private readonly logger: ILogger;
@inject(EnvVariablesServer)
private readonly envVariablesServer: EnvVariablesServer;
@inject(NotificationServiceServer)
private readonly notificationService: NotificationServiceServer;
private readonly toDispose = new DisposableCollection();
private readonly onDaemonStartedEmitter = new Emitter<string>();
private readonly onDaemonStoppedEmitter = new Emitter<void>();
private _running = false;
private _port = new Deferred<string>();
private _execPath: string | undefined;
// Backend application lifecycle.
onStart(): void {
this.start(); // no await
}
// Daemon API
async getPort(): Promise<string> {
return this._port.promise;
}
async tryGetPort(): Promise<string | undefined> {
if (this._running) {
return this._port.promise;
}
return undefined;
}
async start(): Promise<string> {
try {
this.toDispose.dispose(); // This will `kill` the previously started daemon process, if any.
const cliPath = await this.getExecPath();
this.onData(`Starting daemon from ${cliPath}...`);
const { daemon, port } = await this.spawnDaemonProcess();
// Watchdog process for terminating the daemon process when the backend app terminates.
spawn(
process.execPath,
[
join(__dirname, 'daemon-watcher.js'),
String(process.pid),
String(daemon.pid),
],
{
env: environment.electron.runAsNodeEnv(),
detached: true,
stdio: 'ignore',
windowsHide: true,
}
).unref();
this.toDispose.pushAll([
Disposable.create(() => daemon.kill()),
Disposable.create(() => this.fireDaemonStopped()),
]);
this.fireDaemonStarted(port);
this.onData('Daemon is running.');
return port;
} catch (err) {
return retry(
() => {
this.onError(err);
return this.start();
},
1_000,
5
);
}
}
async stop(): Promise<void> {
this.toDispose.dispose();
}
async restart(): Promise<string> {
return this.start();
}
// Backend only daemon API
get onDaemonStarted(): Event<string> {
return this.onDaemonStartedEmitter.event;
}
get onDaemonStopped(): Event<void> {
return this.onDaemonStoppedEmitter.event;
}
async getExecPath(): Promise<string> {
if (this._execPath) {
return this._execPath;
}
this._execPath = await getExecPath('arduino-cli', this.onError.bind(this));
return this._execPath;
}
async getVersion(): Promise<
Readonly<{ version: string; commit: string; status?: string }>
> {
const execPath = await this.getExecPath();
const raw = await spawnCommand(
`"${execPath}"`,
['version', '--format', 'json'],
this.onError.bind(this)
);
try {
// The CLI `Info` object: https://github.com/arduino/arduino-cli/blob/17d24eb901b1fdaa5a4ec7da3417e9e460f84007/version/version.go#L31-L34
const { VersionString, Commit, Status } = JSON.parse(raw);
return {
version: VersionString,
commit: Commit,
status: Status,
};
} catch {
return { version: raw, commit: raw };
}
}
protected async getSpawnArgs(): Promise<string[]> {
const [configDirUri, debug] = await Promise.all([
this.envVariablesServer.getConfigDirUri(),
this.debugDaemon(),
]);
const cliConfigPath = join(FileUri.fsPath(configDirUri), CLI_CONFIG);
const args = [
'daemon',
'--format',
'jsonmini',
'--port',
'0',
'--config-file',
`"${cliConfigPath}"`,
'-v',
'--log-format',
'json',
];
if (debug) {
args.push('--debug');
}
return args;
}
private async debugDaemon(): Promise<boolean> {
// Poor man's preferences on the backend. (https://github.com/arduino/arduino-ide/issues/1056#issuecomment-1153975064)
const configDirUri = await this.envVariablesServer.getConfigDirUri();
const configDirPath = FileUri.fsPath(configDirUri);
try {
const raw = await fs.readFile(join(configDirPath, 'settings.json'), {
encoding: 'utf8',
});
const json = this.tryParse(raw);
if (json) {
const value = json['arduino.cli.daemon.debug'];
return typeof value === 'boolean' && !!value;
}
return false;
} catch (error) {
if ('code' in error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private tryParse(raw: string): any | undefined {
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
protected async spawnDaemonProcess(): Promise<{
daemon: ChildProcess;
port: string;
}> {
const [cliPath, args] = await Promise.all([
this.getExecPath(),
this.getSpawnArgs(),
]);
const ready = new Deferred<{ daemon: ChildProcess; port: string }>();
const options = { shell: true };
const daemon = spawn(`"${cliPath}"`, args, options);
// If the process exists right after the daemon gRPC server has started (due to an invalid port, unknown address, TCP port in use, etc.)
// we have no idea about the root cause unless we sniff into the first data package and dispatch the logic on that. Note, we get a exit code 1.
let grpcServerIsReady = false;
daemon.stdout.on('data', (data) => {
const message = data.toString();
let port = '';
let address = '';
message
.split('\n')
.filter((line: string) => line.length)
.forEach((line: string) => {
try {
const parsedLine = JSON.parse(line);
if ('Port' in parsedLine) {
port = parsedLine.Port;
}
if ('IP' in parsedLine) {
address = parsedLine.IP;
}
} catch (err) {
// ignore
}
});
this.onData(message);
if (!grpcServerIsReady) {
const error = DaemonError.parse(message);
if (error) {
ready.reject(error);
return;
}
if (port.length && address.length) {
grpcServerIsReady = true;
ready.resolve({ daemon, port });
}
}
});
daemon.stderr.on('data', (data) => {
const message = data.toString();
this.onData(data.toString());
const error = DaemonError.parse(message);
ready.reject(error ? error : new Error(data.toString().trim()));
});
daemon.on('exit', (code, signal) => {
if (code === 0 || signal === 'SIGINT' || signal === 'SIGKILL') {
this.onData('Daemon has stopped.');
} else {
this.onData(
`Daemon exited with ${
typeof code === 'undefined'
? `signal '${signal}'`
: `exit code: ${code}`
}.`
);
}
});
daemon.on('error', (error) => {
this.onError(error);
ready.reject(error);
});
return ready.promise;
}
private fireDaemonStarted(port: string): void {
this._running = true;
this._port.resolve(port);
this.onDaemonStartedEmitter.fire(port);
this.notificationService.notifyDaemonDidStart(port);
}
private fireDaemonStopped(): void {
if (!this._running) {
return;
}
this._running = false;
this._port.reject(); // Reject all pending.
this._port = new Deferred<string>();
this.onDaemonStoppedEmitter.fire();
this.notificationService.notifyDaemonDidStop();
}
protected onData(message: string): void {
this.logger.info(message);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private onError(error: any): void {
this.logger.error(error);
}
}
export class DaemonError extends Error {
constructor(
message: string,
public readonly code: number,
public readonly details?: string
) {
super(message);
Object.setPrototypeOf(this, DaemonError.prototype);
}
}
export namespace DaemonError {
export const ADDRESS_IN_USE = 0;
export const UNKNOWN_ADDRESS = 2;
export const INVALID_PORT = 4;
export const UNKNOWN = 8;
export function parse(log: string): DaemonError | undefined {
const raw = log.toLocaleLowerCase();
if (raw.includes('failed to listen')) {
if (
raw.includes('address already in use') ||
(raw.includes('bind') &&
raw.includes('only one usage of each socket address'))
) {
return new DaemonError(
'Failed to listen on TCP port. Address already in use.',
DaemonError.ADDRESS_IN_USE
);
}
if (
raw.includes('is unknown name') ||
(raw.includes('tcp/') && raw.includes('is an invalid port'))
) {
return new DaemonError(
'Failed to listen on TCP port. Unknown address.',
DaemonError.UNKNOWN_ADDRESS
);
}
if (raw.includes('is an invalid port')) {
return new DaemonError(
'Failed to listen on TCP port. Invalid port.',
DaemonError.INVALID_PORT
);
}
}
// Based on the CLI logging: `failed to serve`, and any other FATAL errors.
// https://github.com/arduino/arduino-cli/blob/11abbee8a9f027d087d4230f266a87217677d423/cli/daemon/daemon.go#L89-L94
if (
raw.includes('failed to serve') &&
(raw.includes('"fatal"') || raw.includes('fata'))
) {
return new DaemonError(
'Unexpected CLI start error.',
DaemonError.UNKNOWN,
log
);
}
return undefined;
}
}