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(); private readonly onDaemonStoppedEmitter = new Emitter(); private _running = false; private _port = new Deferred(); private _execPath: string | undefined; // Backend application lifecycle. onStart(): void { this.start(); // no await } // Daemon API async getPort(): Promise { return this._port.promise; } async tryGetPort(): Promise { if (this._running) { return this._port.promise; } return undefined; } async start(): Promise { 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 { this.toDispose.dispose(); } async restart(): Promise { return this.start(); } // Backend only daemon API get onDaemonStarted(): Event { return this.onDaemonStartedEmitter.event; } get onDaemonStopped(): Event { return this.onDaemonStoppedEmitter.event; } async getExecPath(): Promise { 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 { 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 { // 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(); 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; } }