Unverified Commit d80999d6 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Support attach on fuchsia devices (#23436)

parent 47f62109
......@@ -530,7 +530,7 @@ List<String> _wrapTextAsLines(String text, {int start = 0, int columnWidth, bool
result.add(joinRun(splitLine, currentLineStart, index));
// Skip any intervening whitespace.
while (isWhitespace(splitLine[index]) && index < splitLine.length) {
while (index < splitLine.length && isWhitespace(splitLine[index])) {
index++;
}
......
......@@ -7,10 +7,12 @@ import 'dart:async';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/utils.dart';
import '../cache.dart';
import '../commands/daemon.dart';
import '../device.dart';
import '../fuchsia/fuchsia_device.dart';
import '../globals.dart';
import '../protocol_discovery.dart';
import '../resident_runner.dart';
......@@ -19,6 +21,8 @@ import '../runner/flutter_command.dart';
final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
final String ipv6Loopback = InternetAddress.loopbackIPv6.address;
/// A Flutter-command that attaches to applications that have been launched
/// without `flutter run`.
///
......@@ -35,6 +39,9 @@ final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
/// ```
/// As soon as a new observatory is detected the command attaches to it and
/// enables hot reloading.
///
/// To attach to a flutter mod running on a fuchsia device, `--module` must
/// also be provided.
class AttachCommand extends FlutterCommand {
AttachCommand({bool verboseHelp = false, this.hotRunnerFactory}) {
addBuildModeFlags(defaultToRelease: false);
......@@ -53,11 +60,17 @@ class AttachCommand extends FlutterCommand {
'project-root',
hide: !verboseHelp,
help: 'Normally used only in run target',
)..addOption(
'module',
abbr: 'm',
hide: !verboseHelp,
help: 'The name of the module (required if attaching to a fuchsia device)',
valueHelp: 'module-name',
)..addFlag('machine',
hide: !verboseHelp,
negatable: false,
help: 'Handle machine structured JSON command input and provide output '
'and progress in machine friendly format.',
hide: !verboseHelp,
negatable: false,
help: 'Handle machine structured JSON command input and provide output '
'and progress in machine friendly format.',
);
hotRunnerFactory ??= HotRunnerFactory();
}
......@@ -106,18 +119,50 @@ class AttachCommand extends FlutterCommand {
: null;
Uri observatoryUri;
bool ipv6 = false;
bool attachLogger = false;
if (devicePort == null) {
ProtocolDiscovery observatoryDiscovery;
try {
observatoryDiscovery = ProtocolDiscovery.observatory(
device.getLogReader(),
portForwarder: device.portForwarder,
if (device is FuchsiaDevice) {
attachLogger = true;
final String module = argResults['module'];
if (module == null) {
throwToolExit('\'--module\' is requried for attaching to a Fuchsia device');
}
ipv6 = _isIpv6(device.id);
final List<int> ports = await device.servicePorts();
if (ports.isEmpty) {
throwToolExit('No active service ports on ${device.name}');
}
final List<int> localPorts = <int>[];
for (int port in ports) {
localPorts.add(await device.portForwarder.forward(port));
}
final Status status = logger.startProgress(
'Waiting for a connection from Flutter on ${device.name}...',
expectSlowOperation: true,
);
printStatus('Waiting for a connection from Flutter on ${device.name}...');
observatoryUri = await observatoryDiscovery.uri;
printStatus('Done.');
} finally {
await observatoryDiscovery?.cancel();
final int localPort = await device.findIsolatePort(module, localPorts);
if (localPort == null) {
status.cancel();
throwToolExit('No active Observatory running module \'$module\' on ${device.name}');
}
status.stop();
observatoryUri = ipv6
? Uri.parse('http://[$ipv6Loopback]:$localPort/')
: Uri.parse('http://$ipv4Loopback:$localPort/');
} else {
ProtocolDiscovery observatoryDiscovery;
try {
observatoryDiscovery = ProtocolDiscovery.observatory(
device.getLogReader(),
portForwarder: device.portForwarder,
);
printStatus('Waiting for a connection from Flutter on ${device.name}...');
observatoryUri = await observatoryDiscovery.uri;
printStatus('Done.');
} finally {
await observatoryDiscovery?.cancel();
}
}
} else {
final int localPort = await device.portForwarder.forward(devicePort);
......@@ -141,7 +186,11 @@ class AttachCommand extends FlutterCommand {
usesTerminalUI: daemon == null,
projectRootPath: argResults['project-root'],
dillOutputPath: argResults['output-dill'],
ipv6: ipv6,
);
if (attachLogger) {
flutterDevice.startEchoingDeviceLog();
}
if (daemon != null) {
AppInstance app;
......@@ -165,6 +214,17 @@ class AttachCommand extends FlutterCommand {
}
Future<void> _validateArguments() async {}
bool _isIpv6(String address) {
// Workaround for https://github.com/dart-lang/sdk/issues/29456
final String fragment = address.split('%').first;
try {
Uri.parseIPv6Address(fragment);
return true;
} on FormatException {
return false;
}
}
}
class HotRunnerFactory {
......
......@@ -7,17 +7,29 @@ import 'dart:async';
import 'package:meta/meta.dart';
import '../application_package.dart';
import '../base/common.dart';
import '../base/io.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/process_manager.dart';
import '../build_info.dart';
import '../device.dart';
import '../globals.dart';
import '../vmservice.dart';
import 'fuchsia_sdk.dart';
import 'fuchsia_workflow.dart';
final String _ipv4Loopback = InternetAddress.loopbackIPv4.address;
final String _ipv6Loopback = InternetAddress.loopbackIPv6.address;
/// Read the log for a particular device.
class _FuchsiaLogReader extends DeviceLogReader {
_FuchsiaLogReader(this._device);
// TODO(jonahwilliams): handle filtering log output from different modules.
static final Pattern flutterLogOutput = RegExp(r'\[\d+\.\d+\]\[\d+\]\[\d+\]\[klog\] INFO: \w+\(flutter\): ');
FuchsiaDevice _device;
@override String get name => _device.name;
......@@ -25,7 +37,8 @@ class _FuchsiaLogReader extends DeviceLogReader {
Stream<String> _logLines;
@override
Stream<String> get logLines {
_logLines ??= const Stream<String>.empty();
_logLines ??= fuchsiaSdk.syslogs()
.where((String line) => flutterLogOutput.matchAsPrefix(line) != null);
return _logLines;
}
......@@ -48,21 +61,26 @@ class FuchsiaDevices extends PollingDeviceDiscovery {
return <Device>[];
}
final String text = await fuchsiaSdk.netls();
return parseFuchsiaDeviceOutput(text);
final List<FuchsiaDevice> devices = <FuchsiaDevice>[];
for (String name in parseFuchsiaDeviceOutput(text)) {
final String id = await fuchsiaSdk.netaddr();
devices.add(FuchsiaDevice(id, name: name));
}
return devices;
}
@override
Future<List<String>> getDiagnostics() async => const <String>[];
}
/// Parses output from the netls tool into fuchsia devices.
/// Parses output from the netls tool into fuchsia devices names.
///
/// Example output:
/// $ ./netls
/// > device liliac-shore-only-last (fe80::82e4:da4d:fe81:227d/3)
@visibleForTesting
List<FuchsiaDevice> parseFuchsiaDeviceOutput(String text) {
final List<FuchsiaDevice> devices = <FuchsiaDevice>[];
List<String> parseFuchsiaDeviceOutput(String text) {
final List<String> names = <String>[];
for (String rawLine in text.trim().split('\n')) {
final String line = rawLine.trim();
if (!line.startsWith('device'))
......@@ -70,10 +88,9 @@ List<FuchsiaDevice> parseFuchsiaDeviceOutput(String text) {
// ['device', 'device name', '(id)']
final List<String> words = line.split(' ');
final String name = words[1];
final String id = words[2].substring(1, words[2].length - 1);
devices.add(FuchsiaDevice(id, name: name));
names.add(name);
}
return devices;
return names;
}
class FuchsiaDevice extends Device {
......@@ -131,15 +148,13 @@ class FuchsiaDevice extends Device {
@override
Future<String> get sdkNameAndVersion async => 'Fuchsia';
_FuchsiaLogReader _logReader;
@override
DeviceLogReader getLogReader({ApplicationPackage app}) {
_logReader ??= _FuchsiaLogReader(this);
return _logReader;
}
DeviceLogReader getLogReader({ApplicationPackage app}) => _logReader ??= _FuchsiaLogReader(this);
_FuchsiaLogReader _logReader;
@override
DevicePortForwarder get portForwarder => null;
DevicePortForwarder get portForwarder => _portForwarder ??= _FuchsiaPortForwarder(this);
_FuchsiaPortForwarder _portForwarder;
@override
void clearLogs() {
......@@ -147,4 +162,141 @@ class FuchsiaDevice extends Device {
@override
bool get supportsScreenshot => false;
/// List the ports currently running a dart observatory.
Future<List<int>> servicePorts() async {
final String lsOutput = await shell('ls /tmp/dart.services');
return parseFuchsiaDartPortOutput(lsOutput);
}
/// Run `command` on the Fuchsia device shell.
Future<String> shell(String command) async {
final RunResult result = await runAsync(<String>[
'ssh', '-F', fuchsiaSdk.sshConfig.absolute.path, id, command]);
if (result.exitCode != 0) {
throwToolExit('Command failed: $command\nstdout: ${result.stdout}\nstderr: ${result.stderr}');
return null;
}
return result.stdout;
}
/// Finds the first port running a VM matching `isolateName` from the
/// provided set of `ports`.
///
/// Returns null if no isolate port can be found.
///
// TODO(jonahwilliams): replacing this with the hub will require an update
// to the flutter_runner.
Future<int> findIsolatePort(String isolateName, List<int> ports) async {
for (int port in ports) {
try {
// Note: The square-bracket enclosure for using the IPv6 loopback
// didn't appear to work, but when assigning to the IPv4 loopback device,
// netstat shows that the local port is actually being used on the IPv6
// loopback (::1).
final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$port');
final VMService vmService = await VMService.connect(uri);
await vmService.getVM();
await vmService.refreshViews();
for (FlutterView flutterView in vmService.vm.views) {
if (flutterView.uiIsolate == null) {
continue;
}
final Uri address = flutterView.owner.vmService.httpAddress;
if (flutterView.uiIsolate.name.contains(isolateName)) {
return address.port;
}
}
} on SocketException catch (err) {
printTrace('Failed to connect to $port: $err');
}
}
throwToolExit('No ports found running $isolateName');
return null;
}
}
class _FuchsiaPortForwarder extends DevicePortForwarder {
_FuchsiaPortForwarder(this.device);
final FuchsiaDevice device;
final Map<int, Process> _processes = <int, Process>{};
@override
Future<int> forward(int devicePort, {int hostPort}) async {
hostPort ??= await _findPort();
// Note: the provided command works around a bug in -N, see US-515
// for more explanation.
final List<String> command = <String>[
'ssh', '-6', '-F', fuchsiaSdk.sshConfig.absolute.path, '-nNT', '-vvv', '-f',
'-L', '$hostPort:$_ipv4Loopback:$devicePort', device.id, 'true'
];
final Process process = await processManager.start(command);
process.exitCode.then((int exitCode) { // ignore: unawaited_futures
if (exitCode != 0) {
throwToolExit('Failed to forward port:$devicePort');
}
});
_processes[hostPort] = process;
_forwardedPorts.add(ForwardedPort(hostPort, devicePort));
return hostPort;
}
@override
List<ForwardedPort> get forwardedPorts => _forwardedPorts;
final List<ForwardedPort> _forwardedPorts = <ForwardedPort>[];
@override
Future<void> unforward(ForwardedPort forwardedPort) async {
_forwardedPorts.remove(forwardedPort);
final Process process = _processes.remove(forwardedPort.hostPort);
process?.kill();
final List<String> command = <String>[
'ssh', '-F', fuchsiaSdk.sshConfig.absolute.path, '-O', 'cancel', '-vvv',
'-L', '${forwardedPort.hostPort}:$_ipv4Loopback:${forwardedPort.devicePort}', device.id];
final ProcessResult result = await processManager.run(command);
if (result.exitCode != 0) {
throwToolExit(result.stderr);
}
}
static Future<int> _findPort() async {
int port = 0;
ServerSocket serverSocket;
try {
serverSocket = await ServerSocket.bind(_ipv4Loopback, 0);
port = serverSocket.port;
} catch (e) {
// Failures are signaled by a return value of 0 from this function.
printTrace('_findPort failed: $e');
}
if (serverSocket != null)
await serverSocket.close();
return port;
}
}
/// Parses output from `dart.services` output on a fuchsia device.
///
/// Example output:
/// $ ls /tmp/dart.services
/// > d 2 0 .
/// > - 1 0 36780
@visibleForTesting
List<int> parseFuchsiaDartPortOutput(String text) {
final List<int> ports = <int>[];
if (text == null)
return ports;
for (String line in text.split('\n')) {
final String trimmed = line.trim();
final int lastSpace = trimmed.lastIndexOf(' ');
final String lastWord = trimmed.substring(lastSpace + 1);
if ((lastWord != '.') && (lastWord != '..')) {
final int value = int.tryParse(lastWord);
if (value != null) {
ports.add(value);
}
}
}
return ports;
}
......@@ -2,9 +2,17 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import '../base/common.dart';
import '../base/context.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/process_manager.dart';
import '../globals.dart';
/// The [FuchsiaSdk] instance.
FuchsiaSdk get fuchsiaSdk => context[FuchsiaSdk];
......@@ -14,6 +22,22 @@ FuchsiaSdk get fuchsiaSdk => context[FuchsiaSdk];
/// This workflow assumes development within the fuchsia source tree,
/// including a working fx command-line tool in the user's PATH.
class FuchsiaSdk {
static const List<String> _netaddrCommand = <String>['fx', 'netaddr', '--fuchsia', '--nowait'];
static const List<String> _netlsCommand = <String>['fx', 'netls', '--nowait'];
static const List<String> _syslogCommand = <String>['fx', 'syslog'];
/// The location of the SSH configuration file used to interact with a
/// fuchsia device.
///
/// Requires the env variable `BUILD_DIR` to be set.
File get sshConfig {
if (_sshConfig == null) {
final String buildDirectory = platform.environment['BUILD_DIR'];
_sshConfig = fs.file('$buildDirectory/ssh-keys/ssh_config');
}
return _sshConfig;
}
File _sshConfig;
/// Invokes the `netaddr` command.
///
......@@ -21,12 +45,37 @@ class FuchsiaSdk {
/// not currently support multiple attached devices.
///
/// Example output:
/// $ fx netaddr --fuchsia
/// $ fx netaddr --fuchsia --nowait
/// > fe80::9aaa:fcff:fe60:d3af%eth1
Future<String> netaddr() async {
try {
final RunResult process = await runAsync(<String>['fx', 'netaddr', '--fuchsia', '--nowait']);
return process.stdout;
final RunResult process = await runAsync(_netaddrCommand);
return process.stdout.trim();
} on ArgumentError catch (exception) {
throwToolExit('$exception');
}
return null;
}
/// Returns the fuchsia system logs for an attached device.
///
/// Does not currently support multiple attached devices.
Stream<String> syslogs() {
Process process;
try {
final StreamController<String> controller = StreamController<String>(onCancel: () {
process.kill();
});
processManager.start(_syslogCommand).then((Process newProcess) {
printTrace('Running logs');
if (controller.isClosed) {
return;
}
process = newProcess;
process.exitCode.then((_) => controller.close);
controller.addStream(process.stdout.transform(utf8.decoder).transform(const LineSplitter()));
});
return controller.stream;
} on ArgumentError catch (exception) {
throwToolExit('$exception');
}
......@@ -39,12 +88,11 @@ class FuchsiaSdk {
/// not currently support multiple attached devices.
///
/// Example output:
/// $ fx netls
/// $ fx netls --nowait
/// > device liliac-shore-only-last (fe80::82e4:da4d:fe81:227d/3)
Future<String> netls() async {
try {
final RunResult process = await runAsync(
<String>['fx', 'netls', '--nowait']);
final RunResult process = await runAsync(_netlsCommand);
return process.stdout;
} on ArgumentError catch (exception) {
throwToolExit('$exception');
......
......@@ -19,11 +19,20 @@ void main() {
test('parse netls log output', () {
const String example = 'device lilia-shore-only-last (fe80::0000:a00a:f00f:2002/3)';
final List<FuchsiaDevice> devices = parseFuchsiaDeviceOutput(example);
final List<String> names = parseFuchsiaDeviceOutput(example);
expect(devices.length, 1);
expect(devices.first.id, 'fe80::0000:a00a:f00f:2002/3');
expect(devices.first.name, 'lilia-shore-only-last');
expect(names.length, 1);
expect(names.first, 'lilia-shore-only-last');
});
test('parse ls tmp/dart.servies output', () {
const String example = '''
d 2 0 .
'- 1 0 36780
''';
final List<int> ports = parseFuchsiaDartPortOutput(example);
expect(ports.length, 1);
expect(ports.single, 36780);
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment