Skip to content

Daemon command boilerplate #200

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

Merged
merged 2 commits into from
Mar 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions webdev/lib/src/command/daemon_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. 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 'dart:io';

import 'package:args/command_runner.dart';

import '../daemon/daemon.dart';

Stream<Map<String, dynamic>> get _stdinCommandStream => stdin
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.where((String line) => line.startsWith('[{') && line.endsWith('}]'))
.map<Map<String, dynamic>>((String line) {
line = line.substring(1, line.length - 1);
return json.decode(line) as Map<String, dynamic>;
});

void _stdoutCommandResponse(Map<String, dynamic> command) {
stdout.writeln('[${json.encode(command)}]');
}

/// A mode for running WebDev from command-line tools.
///
/// Communication happens over STDIO using JSON-RPC.
///
/// This supports a subset of:
/// https://github.com/flutter/flutter/blob/master/packages/flutter_tools/doc/daemon.md
class DaemonCommand extends Command<int> {
@override
final name = 'daemon';

@override
final hidden = true;

@override
String get description =>
'A mode for running WebDev from command-line tools.';

@override
Future<int> run() async {
var daemon = Daemon(_stdinCommandStream, _stdoutCommandResponse);
await daemon.onExit;
return 0;
}
}
93 changes: 93 additions & 0 deletions webdev/lib/src/daemon/daemon.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. 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:io';

import 'daemon_domain.dart';
import 'domain.dart';
import 'utilites.dart';

/// A collection of domains.
///
/// Listens for commands, routes them to the corresponding domain and provides
/// the result.
class Daemon {
Daemon(Stream<Map<String, dynamic>> commandStream, this._sendCommand) {
_registerDomain(DaemonDomain(this));

// TODO(grouma) - complete these other domains.
//_registerDomain(appDomain = AppDomain(this));
//_registerDomain(deviceDomain = DeviceDomain(this));
//_registerDomain(emulatorDomain = EmulatorDomain(this));

_commandSubscription = commandStream.listen(
_handleRequest,
onDone: () {
if (!_onExitCompleter.isCompleted) _onExitCompleter.complete(0);
},
);
}

StreamSubscription<Map<String, dynamic>> _commandSubscription;

final void Function(Map<String, dynamic>) _sendCommand;

final Completer<int> _onExitCompleter = Completer<int>();
final Map<String, Domain> _domainMap = <String, Domain>{};

void _registerDomain(Domain domain) {
_domainMap[domain.name] = domain;
}

Future<int> get onExit => _onExitCompleter.future;

void _handleRequest(Map<String, dynamic> request) {
// {id, method, params}

// [id] is an opaque type to us.
var id = request['id'];

if (id == null) {
stderr.writeln('no id for request: $request');
return;
}

try {
var method = request['method'] as String ?? '';
if (!method.contains('.')) {
throw ArgumentError('method not understood: $method');
}

var domain = method.substring(0, method.indexOf('.'));
var name = method.substring(method.indexOf('.') + 1);
if (_domainMap[domain] == null) {
throw ArgumentError('no domain for method: $method');
}

_domainMap[domain].handleCommand(
name, id, request['params'] as Map<String, dynamic> ?? {});
} catch (error, trace) {
send(<String, dynamic>{
'id': id,
'error': toJsonable(error),
'trace': '$trace',
});
}
}

void send(Map<String, dynamic> map) => _sendCommand(map);

void shutdown({dynamic error}) {
_commandSubscription?.cancel();
for (var domain in _domainMap.values) domain.dispose();
if (!_onExitCompleter.isCompleted) {
if (error == null) {
_onExitCompleter.complete(0);
} else {
_onExitCompleter.completeError(error);
}
}
}
}
37 changes: 37 additions & 0 deletions webdev/lib/src/daemon/daemon_domain.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. 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:io';

import 'daemon.dart';
import 'domain.dart';

const String protocolVersion = '0.4.2';

/// A collection of method and events relevant to the daemon command.
class DaemonDomain extends Domain {
DaemonDomain(Daemon daemon) : super(daemon, 'daemon') {
registerHandler('version', _version);
registerHandler('shutdown', _shutdown);

sendEvent(
'daemon.connected',
{
'version': protocolVersion,
'pid': pid,
},
);
}

Future<String> _version(Map<String, dynamic> args) {
return Future<String>.value(protocolVersion);
}

Future<void> _shutdown(Map<String, dynamic> args) {
// Schedule shutdown after we return the result.
Timer.run(daemon.shutdown);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see if this can run right synchronously... my guess is its cruft from the flutter tools impl?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does also seem a bit sketchy that this is the thing shutting down the daemon even though it isn't creating it... but that part is probably OK.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to run it after we return the value. I'll add a comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could chain this off the future then with a cascade? Timer.run is a weird way to handle this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do in a follow up PR.

return Future<void>.value();
}
}
55 changes: 55 additions & 0 deletions webdev/lib/src/daemon/domain.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';

import 'daemon.dart';
import 'utilites.dart';

/// Hosts a set of commands and events to be used with the Daemon.
abstract class Domain {
Domain(this.daemon, this.name);

final Daemon daemon;
final String name;
final Map<String, CommandHandler> _handlers = <String, CommandHandler>{};

void registerHandler(String name, CommandHandler handler) {
_handlers[name] = handler;
}

@override
String toString() => name;

void handleCommand(String command, dynamic id, Map<String, dynamic> args) {
Future<dynamic>.sync(() {
if (_handlers.containsKey(command)) return _handlers[command](args);
throw ArgumentError('command not understood: $name.$command');
}).then<dynamic>((dynamic result) {
if (result == null) {
_send(<String, dynamic>{'id': id});
} else {
_send(<String, dynamic>{'id': id, 'result': toJsonable(result)});
}
}).catchError((dynamic error, dynamic trace) {
_send(<String, dynamic>{
'id': id,
'error': toJsonable(error),
'trace': '$trace',
});
});
}

void sendEvent(String name, [dynamic args]) {
var map = <String, dynamic>{'event': name};
if (args != null) map['params'] = toJsonable(args);
_send(map);
}

void _send(Map<String, dynamic> map) => daemon.send(map);

void dispose() {}
}

typedef CommandHandler = Future<dynamic> Function(Map<String, dynamic> args);
42 changes: 42 additions & 0 deletions webdev/lib/src/daemon/utilites.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

dynamic toJsonable(dynamic obj) {
if (obj is String ||
obj is int ||
obj is bool ||
obj is Map<dynamic, dynamic> ||
obj is List<dynamic> ||
obj == null) return obj;
return '$obj';
}

String getStringArg(Map<String, dynamic> args, String name,
{bool required = false}) {
if (required && !args.containsKey(name))
throw ArgumentError('$name is required');
var val = args[name];
if (val != null && val is! String) {
throw ArgumentError('$name is not a String');
}
return val as String;
}

bool getBoolArg(Map<String, dynamic> args, String name,
{bool required = false}) {
if (required && !args.containsKey(name)) {
throw ArgumentError('$name is required');
}
var val = args[name];
if (val != null && val is! bool) throw ArgumentError('$name is not a bool');
return val as bool;
}

int getIntArg(Map<String, dynamic> args, String name, {bool required = false}) {
if (required && !args.containsKey(name))
throw ArgumentError('$name is required');
var val = args[name];
if (val != null && val is! int) throw ArgumentError('$name is not an int');
return val as int;
}
2 changes: 2 additions & 0 deletions webdev/lib/src/webdev_command_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:args/args.dart';
import 'package:args/command_runner.dart';

import 'command/build_command.dart';
import 'command/daemon_command.dart';
import 'command/serve_command.dart';
import 'util.dart';
import 'version.dart';
Expand All @@ -23,6 +24,7 @@ class _CommandRunner extends CommandRunner<int> {
negatable: false, help: 'Prints the version of webdev.');
addCommand(BuildCommand());
addCommand(ServeCommand());
addCommand(DaemonCommand());
}

@override
Expand Down
54 changes: 54 additions & 0 deletions webdev/test/daemon/daemon_domain_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:convert';

import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:test_process/test_process.dart';
import 'package:webdev/src/util.dart';

import '../test_utils.dart';

void main() {
String exampleDirectory;

setUpAll(() async {
exampleDirectory = p.absolute(p.join(p.current, '..', 'example'));

var process = await TestProcess.start(pubPath, ['upgrade'],
workingDirectory: exampleDirectory, environment: getPubEnvironment());

await process.shouldExit(0);
});

group('Daemon', () {
group('Events', () {
test('.connected', () async {
var webdev =
await runWebDev(['daemon'], workingDirectory: exampleDirectory);
await expectLater(
webdev.stdout, emits(startsWith('[{"event":"daemon.connected"')));
await webdev.kill();
});
});

test('.version', () async {
var webdev =
await runWebDev(['daemon'], workingDirectory: exampleDirectory);
webdev.stdin.add(utf8.encode('[{"method":"daemon.version","id":0}]\n'));
await expectLater(
webdev.stdout, emitsThrough(equals('[{"id":0,"result":"0.4.2"}]')));
await webdev.kill();
});

test('.shutdown', () async {
var webdev =
await runWebDev(['daemon'], workingDirectory: exampleDirectory);
webdev.stdin.add(utf8.encode('[{"method":"daemon.shutdown","id":0}]\n'));
await expectLater(webdev.stdout, emitsThrough(equals('[{"id":0}]')));
expect(await webdev.exitCode, equals(0));
});
});
}