blob: 9333ee7d60de9e12da4560d17809d696890f0bbd [file] [log] [blame] [edit]
// Copyright 2022 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as vscode from 'vscode';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import * as logger from './logger';
// @ts-ignore: no @types/jsonparse available.
import JsonParser from 'jsonparse';
import { DataStreamProcess } from './process';
/**
* FuchsiaDevice represents the object returned from `ffx target list`.
*
* Instances of this object are immutable.
*/
export class FuchsiaDevice {
private readonly _data: { [key: string]: any };
public readonly nodeName: string;
public readonly connected: boolean;
public readonly serial: string;
public readonly targetType: string;
public readonly targetState: string;
public readonly addresses: Array<string>;
public readonly isFfxDefault: boolean = false;
constructor(data: { [key: string]: any }) {
this._data = JSON.parse(JSON.stringify(data));
this.nodeName = data['nodename'] ?? '<not set>!';
this.connected = data['rcs_state'] === 'Y';
this.serial = data['serial'] ?? '';
this.targetType = data['target_type'] ?? '';
this.targetState = data['target_state'] ?? '';
this.isFfxDefault = !!data['is_default'];
this.addresses = new Array<string>();
data['addresses']?.forEach((addr: string) => {
this.addresses.push(addr);
});
}
public static fromNodename(nodename: string, connected: boolean = false): FuchsiaDevice {
// eslint-disable-next-line @typescript-eslint/naming-convention
return new FuchsiaDevice({nodename, 'rcs_state': connected ? 'Y' : 'N'});
}
disconnected(): FuchsiaDevice {
// eslint-disable-next-line @typescript-eslint/naming-convention
return new FuchsiaDevice({...this._data, 'rcs_state': 'N'});
}
}
interface SpawnOptions {
cwd?: string
}
export enum FfxEventType {
ffxPathSet,
ffxPathReset,
};
export interface FfxInvocationEvent {
args: string[]
}
// TODO: Should we make this value configuable? We already have the configuration key
// `fuchsia.connectionTimeout`, but that value is 1000ms, and it's not clear how to
// reconcile these different timeouts.
const defaultTimeoutMillis = 1 * 60 * 1000;
/**
* Ffx encapsulates running the ffx command line to interact with
* devices running Fuchsia.
*/
export class Ffx {
private spawnOptions: SpawnOptions;
private pathInternal: string | undefined;
private ffxPathChangedEvent: vscode.EventEmitter<FfxEventType>;
private targetChangedEvent = new vscode.EventEmitter<FuchsiaDevice | null>();
private defaultTarget: FuchsiaDevice | null = null;
onDidChangeConfiguration: vscode.Event<FfxEventType>;
onSetTarget: vscode.Event<FuchsiaDevice | null>;
private readonly _onFfxInvocation = new vscode.EventEmitter<FfxInvocationEvent>();
readonly onFfxInvocation = this._onFfxInvocation.event;
constructor(
cwd: string | undefined,
ffxPath?: string,
) {
this.spawnOptions = cwd ? { cwd: cwd } : {};
this.pathInternal = ffxPath;
this.ffxPathChangedEvent = new vscode.EventEmitter<FfxEventType>();
this.onDidChangeConfiguration = this.ffxPathChangedEvent.event;
this.onSetTarget = this.targetChangedEvent.event;
}
/**
* Sets/resets the ffx tool path. Subscribers to 'onDidChangeConfiguration' will get notified.
* @param ffxPath full path to the ffx binary.
*/
public set path(ffxPath: string | undefined) {
this.pathInternal = ffxPath;
if (ffxPath === undefined) {
this.ffxPathChangedEvent.fire(FfxEventType.ffxPathReset);
} else {
this.ffxPathChangedEvent.fire(FfxEventType.ffxPathSet);
// This causes the target list to be refreshed using the instance
// of ffx being set. Any errors are just logged since the developer
// is not really expecting any errors, and if this is a recurring error,
// it will be surfaced elsewhere.
// Increase timeout since this member is often set at extension
// initialization time, which may take longer to run ffx commands. This
// setter side-effect isn't blocking anyways, so there's no real harm in
// letting it run longer.
this.refreshTargets(5000).catch(err => {
logger.warn(`Unable to refresh target list: ${err}`);
});
}
}
/**
* Get the path to the ffx tool.
* @returns the full path to ffx binary.
*/
public get path() {
return this.pathInternal;
}
/**
* Checks if ffx path has been set.
* @returns true if the ffx path has been set.
*/
public hasValidFfxPath() {
return this.pathInternal !== undefined;
}
/**
* Set the default fuchsia target for the IDE. Subscribers to 'onSetTarget'
* will get notified if the default target has been changed.
* Note: This is different than and decoupled from the ffx default target.
*
* @param target
*/
public set targetDevice(newTarget: FuchsiaDevice | null) {
const oldTarget = this.defaultTarget;
this.defaultTarget = newTarget;
if (
newTarget?.nodeName === oldTarget?.nodeName && newTarget?.connected === oldTarget?.connected
) {
return;
}
if (newTarget) {
this.targetChangedEvent.fire(newTarget);
} else {
this.targetChangedEvent.fire(null);
}
}
/**
* Gets the default target device information.
* Guaranteed to be non-null if any devices are connected; see
* Ffx#findDefaultTarget to see default targets are resolved.
*
* Note: This is different than and decoupled from the ffx default target.
*
* @returns the target device used when running ffx commands.
* undefined if there is no default device configured.
*/
public get targetDevice(): FuchsiaDevice | null {
return this.defaultTarget;
}
/**
* Start the emulator and make it the default target.
*
* @param headless whether the emulator should be started headless.
* @param name the name of the emulator instance.
* @returns A promise to track the execution of the steps.
*/
public async startEmulator(headless: boolean, name: string = 'fuchsia-emulator'): Promise<string> {
const headlessArg = headless ? ['--headless'] : [];
const result = await this.runFfx(['emu', 'start', '--name', name, ...headlessArg], defaultTimeoutMillis, null);
const deviceMap = await this.refreshTargets();
if (name in deviceMap) {
this.targetDevice = deviceMap[name];
} else {
logger.error(`couldn't discover ${name} after emulator was started`, 'ffx emu');
}
return result;
}
/**
* Stop the emulator and unset from default targets.
*
* @param headless whether the emulator should be started headless.
* @param name the name of the emulator instance.
* @returns A promise to track the execution of the steps.
*/
public async stopEmulator(name: string = 'fuchsia-emulator'): Promise<string> {
try {
const result = await this.runFfx(['emu', 'stop', name], defaultTimeoutMillis, null);
// Switch to a different default target.
if (this.defaultTarget?.nodeName === name) {
this.defaultTarget = null;
}
return result;
} finally {
await this.refreshTargets();
}
}
public listComponents(device?: FuchsiaDevice): Promise<Object> {
return this.runFfxJson(['component', 'list'], null, device?.nodeName);
}
public listProcesses(device?: FuchsiaDevice): Promise<Object> {
return this.runFfxJson(['process', 'tree'], null, device?.nodeName);
}
/**
* Powers off the target device.
*
* @param device the device to turn off, otherwise turns off the default device.
* @returns stdout of the command, or error contains the stderr.
*/
public async poweroffTarget(device?: FuchsiaDevice): Promise<string> {
const result = await this.runFfx(['target', 'off'], defaultTimeoutMillis, device?.nodeName);
// Switch to a different default target.
if ((device || this.defaultTarget)?.nodeName === this.defaultTarget?.nodeName) {
this.defaultTarget = null;
}
await this.refreshTargets();
return result;
}
/**
* Reboot target device and wait for it to become reachable.
*
* @param device the device to reboot, otherwise reboots the default device.
* @param timeout the timeout in seconds to wait for the device to become available.
* @returns stdout of the command, or error contains the stderr.
*/
public async rebootTarget(device?: FuchsiaDevice, timeout?: number): Promise<string> {
const result = await this.runFfx(['target', 'reboot'], defaultTimeoutMillis, device?.nodeName);
await this.waitForTarget(device, timeout);
return result;
}
/**
* Waits for a target to become reachable.
*
* @param device name of the device to wait for.
* @param timeout the timeout in seconds to wait for the device to become available.
*/
public async waitForTarget(
device?: FuchsiaDevice,
timeout: number = 300,
): Promise<FuchsiaDevice> {
// TODO(http://fxbug.dev/406324580): Use `ffx target wait` once it's more reliable
// instead of repeatedly querying `ffx target list`.
const sleep = (millis: number) => new Promise(resolve => setTimeout(resolve, millis));
const timeLimit = Date.now() + timeout * 1000;
for (let curr = timeLimit; curr <= timeLimit; curr = Date.now()) {
const targets = await this.refreshTargets().catch((err): {[key: string]: FuchsiaDevice} => {
logger.warn('Failed to query list of targets', 'Ffx.waitForTarget', err);
return {};
});
const target = (device ?? this.targetDevice)?.nodeName;
if (target !== undefined && targets[target]?.connected) {
return targets[target];
}
await sleep(100);
}
throw new Error(
`Target "${(device ?? this.targetDevice)?.nodeName}" ` +
`failed to come online within ${timeout} seconds.`
);
}
/**
* Shows the unstructured data containing the list of properties
* returned by the target device.
*
* @param device the device to use, otherwise uses the default device.
* @returns stdout of the command, or error contains the stderr.
*/
public showTarget(device?: FuchsiaDevice): Promise<string> {
return this.runFfx(['target', 'show'], defaultTimeoutMillis, device && device.nodeName);
}
/**
* Exports the device's snapshot to the current working directory.
*
* @param device the device to use, otherwise uses the default device.
* @returns the path to the snapshot.
*/
public async exportSnapshotToCWD(device?: FuchsiaDevice): Promise<string> {
let cwd = this.spawnOptions.cwd ?? './';
await this.runFfx(['target', 'snapshot', '-d', cwd], defaultTimeoutMillis, device && device.nodeName);
return cwd;
}
/**
* Gets a dictionary of target device names to FuchsiaDevice objects.
*
* Has the side-effect of updating the current default target if it
* disconnects.
*
* @returns map of string to FuchsiaDevice where the key is the device.nodeName.
*/
public async refreshTargets(timeoutMillis?: number): Promise<{ [key: string]: FuchsiaDevice }> {
const deviceMap: { [key: string]: FuchsiaDevice } = {};
if (this.path) {
timeoutMillis = timeoutMillis ?? vscode.workspace.getConfiguration().get('fuchsia.connectionTimeout');
const devices = await this.runFfxJson(['target', 'list'], timeoutMillis, null);
for (const obj of devices) {
const device = new FuchsiaDevice(obj);
deviceMap[device.nodeName] = device;
}
logger.debug('Target list result:', 'ffx', deviceMap);
}
this.targetDevice = this.findDefaultTarget(deviceMap);
return deviceMap;
}
/**
* Determine the IDE default device and if one is available return the
* recommended IDE default device.
*
* The IDE default device is determined by:
* 1. Use the current IDE default device and refresh its connected state.
* 2. Use the default device reported by ffx.
* 3. Use the first connected device reported by ffx.
* 4. Use any device reported by ffx.
*
* May mutate `deviceMap` by inserting a disconnected device.
*
* @param deviceMap All of the devices discovered by ffx.
* @returns IDE default device.
*/
private findDefaultTarget(deviceMap: { [key: string]: FuchsiaDevice }): FuchsiaDevice | null {
const discoveredDevices = Object.values(deviceMap);
// Determine the default target in the following precedence:
// 1. Preserve the last selected device if ffx is still tracking it.
const sameDefaultDevice = () => discoveredDevices.find(
device => device.nodeName === this.defaultTarget?.nodeName
);
// 2. Preserve the last selected device and mark as disconnected.
const lostDefaultDevice = () => {
if (this.defaultTarget) {
const disconnectedDefaultTarget = this.defaultTarget.disconnected();
deviceMap[this.defaultTarget.nodeName] = disconnectedDefaultTarget;
return disconnectedDefaultTarget;
}
return null;
};
// 3. Use the ffx default device.
const ffxDefaultDevice = () => discoveredDevices.find(
device => device.isFfxDefault
);
// 4. Fallback to the first connected device.
const firstConnectedDevice = () => discoveredDevices.find(
device => device.connected
);
// 5. Fallback to a remaining disconnected device.
const anyDiscoveredDevice = () => discoveredDevices[0];
return sameDefaultDevice()
?? lostDefaultDevice()
?? ffxDefaultDevice()
?? firstConnectedDevice()
?? anyDiscoveredDevice()
?? null;
}
/**
* Builds the ffx commandline including the path to ffx and the appropriate
* configuration flags as needed. Please use this method if a command line
* for ffx is needed outside this class. This allows all ffx invocations from the extension
* to be consistently formed and correctly identified for analytics.
*
* The args array is unchanged.
*
* This can be executed with:
*
* const cmd = ffx.buildCommandLine(myargs);
* spawn(cmd.cmd, cmd.args);
*
* @param args: command line args to ffx
* @param device the name of the target device to use for ffx.
* Defaults to `Ffx.targetDevice?.nodeName` if undefined.
* Use null to omit the `--target` arg, otherwise will throw an exception if
* a device can't be found.
* @returns object with cmd: the path to ffx, and args, the array of arguments.
*/
private buildCommandLine(
args: string[],
device?: string | null
): { cmd: string, args: string[] } | undefined {
if (!this.path) {
return;
}
// Default `device` to the default device's nodeName, if available.
if (device === undefined) {
if (!this.targetDevice) {
throw new Error(`Unable to resolve the default target for ffx command: ${args}`);
}
device = this.targetDevice.nodeName;
}
const targetArg = device ? ['--target', device] : [];
return { cmd: this.path, args: ['--config', 'fuchsia.analytics.ffx_invoker=vscode-fuchsia', ...targetArg, ...args] };
}
/**
* Spawns a new process running ffx with the given arguments.
*
* @param args command line arguments to ffx.
* @param device the name of the target device to use for ffx.
* Defaults to `Ffx.targetDevice?.nodeName` if undefined.
* Use null to omit the `--target` arg, otherwise will throw an exception if
* a device can't be found.
* @returns The ChildProcess is returned or undefined if there was a problem.
*/
public runFfxStreaming(
args: string[],
device?: string | null
): ChildProcessWithoutNullStreams | undefined {
const fullargs = this.buildCommandLine(args, device);
if (!fullargs) {
return undefined;
}
this._onFfxInvocation.fire({ args: fullargs.args });
logger.debug(`Running: ${fullargs.cmd}`, 'ffx', fullargs);
const process = spawn(fullargs.cmd, fullargs.args, { detached: true, ...this.spawnOptions });
return process;
}
/**
* Spawns a new process running ffx with the given arguments.
*
* @param args command line arguments to ffx.
* @param device the name of the target device to use for ffx.
* Defaults to `Ffx.targetDevice?.nodeName` if undefined.
* Use null to omit the `--target` arg, otherwise will throw an exception if
* a device can't be found.
* @returns The ChildProcess is returned or undefined if there was a problem.
*/
public runFfxAsync(
args: string[],
onData: (data: Buffer) => void,
onError: (data: Buffer) => void,
device?: string | null,
): DataStreamProcess | undefined {
const process = this.runFfxStreaming(args, device);
if (!process) {
return;
}
return new DataStreamProcess(process, onData, onError);
}
/**
* Runs ffx --target [device] --machine json with the given arguments.
*
* @param args additional arguments for the `ffx log` process.
* @param timeoutMillis timeout in ms indicating max time to allow ffx to run.
* @param device the name of the target device to use for ffx.
* Defaults to `Ffx.targetDevice?.nodeName` if undefined.
* Use null to omit the `--target` arg, otherwise will throw an exception if
* a device can't be found.
* @returns the data returned by the command parsed as JSON
*/
async runFfxJson(
args: string[],
timeoutMillis?: number | null,
device?: string | null,
): Promise<any> {
const stdout = await this.runFfx(['--machine', 'json', ...args], timeoutMillis, device);
return JSON.parse(stdout);
}
/**
* Runs ffx returning the stdout of the command. There is an optional timeout
* in milliseconds to set the maximum running time of ffx. This is used to avoid
* waiting too long when there is a communication error.
*
* @param args command line arguments to ffx
* @param timeoutMillis timeout in ms indicating max time to allow ffx to run.
* @param device the name of the target device to use for ffx.
* Defaults to `Ffx.targetDevice?.nodeName` if undefined.
* Use null to omit the `--target` arg, otherwise will throw an exception if
* a device can't be found.
* @returns stdout of the command, or Error containing stderr.
*/
runFfx(args: string[], timeoutMillis?: number | null, device?: string | null): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (!this.path) {
logger.info('The ffx path was not found');
// Don't show a message to the user, let the caller do that.
return reject(new Error('ffx command not found'));
}
let cmd: ChildProcessWithoutNullStreams | undefined;
try {
cmd = this.runFfxStreaming(args, device);
} catch (e) {
return reject(e);
}
let hasTimedOut = false;
let timeout: NodeJS.Timeout | undefined = undefined;
if (timeoutMillis) {
logger.debug(`Waiting ${timeoutMillis} for result.`);
timeout = setTimeout(() => {
hasTimedOut = true;
cmd?.kill();
}, timeoutMillis);
}
let output = '';
cmd?.stdout.on('data', (data) => { output += data; });
let errorOutput = '';
cmd?.stderr.on('data', (data) => { errorOutput += data; });
// Keep track of the error state so that the 'close' event handler below won't handle the
// error again.
let hasError = false;
cmd?.on('error', err => {
logger.warn(`exit: ${err}`, 'ffx');
hasError = true;
return reject(err);
});
// Listen to 'close' instead of 'exit' event to correctly capture the output of ffx.
// The 'exit' event may be emitted when the stdio streams of the child process are still
// open, while the 'close' event is emitted after the stdio streams of a child process
// have been closed.
// See https://nodejs.org/api/child_process.html#class-childprocess for more details.
cmd?.on('close', (code, signal) => {
if (!hasError) {
if (hasTimedOut) {
logger.debug(`Timeout: args = ${args}`, 'ffx');
return reject(new Error('ffx command timeout'));
} else if (timeout) {
clearTimeout(timeout);
}
logger.debug(`exit: ${code}: ${signal}`, 'ffx');
if (code === 0) {
return resolve(output);
} else {
return reject(
new Error(`ffx returned with non-zero exit code ${code}: ${errorOutput}`));
}
}
});
});
}
}