Commit ec751776 authored by Devon Carew's avatar Devon Carew

Flutter run restart (#4105)

* working on making a faster flutter run restart

* clean up todos; fire events on isolate changes

* use the Flutter.FrameworkInitialization event

* review comments
parent 646b5350
...@@ -6,6 +6,8 @@ import 'dart:async'; ...@@ -6,6 +6,8 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:path/path.dart' as path;
import '../android/android_sdk.dart'; import '../android/android_sdk.dart';
import '../application_package.dart'; import '../application_package.dart';
import '../base/os.dart'; import '../base/os.dart';
...@@ -14,6 +16,7 @@ import '../build_info.dart'; ...@@ -14,6 +16,7 @@ import '../build_info.dart';
import '../device.dart'; import '../device.dart';
import '../flx.dart' as flx; import '../flx.dart' as flx;
import '../globals.dart'; import '../globals.dart';
import '../observatory.dart';
import '../protocol_discovery.dart'; import '../protocol_discovery.dart';
import 'adb.dart'; import 'adb.dart';
import 'android.dart'; import 'android.dart';
...@@ -369,6 +372,39 @@ class AndroidDevice extends Device { ...@@ -369,6 +372,39 @@ class AndroidDevice extends Device {
); );
} }
@override
Future<bool> restartApp(
ApplicationPackage package,
LaunchResult result, {
String mainPath,
Observatory observatory
}) async {
Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools');
try {
String snapshotPath = path.join(tempDir.path, 'snapshot_blob.bin');
int result = await flx.createSnapshot(mainPath: mainPath, snapshotPath: snapshotPath);
if (result != 0) {
printError('Failed to run the Flutter compiler; exit code: $result');
return false;
}
AndroidApk apk = package;
String androidActivity = apk.launchActivity;
bool success = await refreshSnapshot(androidActivity, snapshotPath);
if (!success) {
printError('Error refreshing snapshot on $this.');
return false;
}
return true;
} finally {
tempDir.deleteSync(recursive: true);
}
}
@override @override
Future<bool> stopApp(ApplicationPackage app) { Future<bool> stopApp(ApplicationPackage app) {
List<String> command = adbCommandForDevice(<String>['shell', 'am', 'force-stop', app.id]); List<String> command = adbCommandForDevice(<String>['shell', 'am', 'force-stop', app.id]);
...@@ -416,7 +452,13 @@ class AndroidDevice extends Device { ...@@ -416,7 +452,13 @@ class AndroidDevice extends Device {
return false; return false;
} }
runCheckedSync(adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath])); RunResult result = await runAsync(
adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath])
);
if (result.exitCode != 0) {
printStatus(result.toString());
return false;
}
List<String> cmd = adbCommandForDevice(<String>[ List<String> cmd = adbCommandForDevice(<String>[
'shell', 'am', 'start', 'shell', 'am', 'start',
...@@ -426,9 +468,14 @@ class AndroidDevice extends Device { ...@@ -426,9 +468,14 @@ class AndroidDevice extends Device {
'--es', 'snapshot', _deviceSnapshotPath, '--es', 'snapshot', _deviceSnapshotPath,
activity, activity,
]); ]);
result = await runAsync(cmd);
if (result.exitCode != 0) {
printStatus(result.toString());
return false;
}
RegExp errorRegExp = new RegExp(r'^Error: .*$', multiLine: true); final RegExp errorRegExp = new RegExp(r'^Error: .*$', multiLine: true);
Match errorMatch = errorRegExp.firstMatch(runCheckedSync(cmd)); Match errorMatch = errorRegExp.firstMatch(result.processResult.stdout);
if (errorMatch != null) { if (errorMatch != null) {
printError(errorMatch.group(0)); printError(errorMatch.group(0));
return false; return false;
......
...@@ -32,8 +32,7 @@ Future<int> runCommandAndStreamOutput(List<String> cmd, { ...@@ -32,8 +32,7 @@ Future<int> runCommandAndStreamOutput(List<String> cmd, {
RegExp filter, RegExp filter,
StringConverter mapFunction StringConverter mapFunction
}) async { }) async {
Process process = await runCommand(cmd, Process process = await runCommand(cmd, workingDirectory: workingDirectory);
workingDirectory: workingDirectory);
process.stdout process.stdout
.transform(UTF8.decoder) .transform(UTF8.decoder)
.transform(const LineSplitter()) .transform(const LineSplitter())
...@@ -84,6 +83,18 @@ String runCheckedSync(List<String> cmd, { ...@@ -84,6 +83,18 @@ String runCheckedSync(List<String> cmd, {
); );
} }
Future<RunResult> runAsync(List<String> cmd, { String workingDirectory }) async {
printTrace(cmd.join(' '));
ProcessResult results = await Process.run(
cmd[0],
cmd.getRange(1, cmd.length).toList(),
workingDirectory: workingDirectory
);
RunResult runResults = new RunResult(results);
printTrace(runResults.toString());
return runResults;
}
/// Run cmd and return stdout. /// Run cmd and return stdout.
String runSync(List<String> cmd, { String workingDirectory }) { String runSync(List<String> cmd, { String workingDirectory }) {
return _runWithLoggingSync(cmd, workingDirectory: workingDirectory); return _runWithLoggingSync(cmd, workingDirectory: workingDirectory);
...@@ -146,3 +157,21 @@ class ProcessExit implements Exception { ...@@ -146,3 +157,21 @@ class ProcessExit implements Exception {
@override @override
String toString() => message; String toString() => message;
} }
class RunResult {
RunResult(this.processResult);
final ProcessResult processResult;
int get exitCode => processResult.exitCode;
@override
String toString() {
StringBuffer out = new StringBuffer();
if (processResult.stdout.isNotEmpty)
out.writeln(processResult.stdout);
if (processResult.stderr.isNotEmpty)
out.writeln(processResult.stderr);
return out.toString().trimRight();
}
}
...@@ -17,7 +17,6 @@ import '../dart/sdk.dart'; ...@@ -17,7 +17,6 @@ import '../dart/sdk.dart';
import '../globals.dart'; import '../globals.dart';
import '../runner/flutter_command.dart'; import '../runner/flutter_command.dart';
bool isDartFile(FileSystemEntity entry) => entry is File && entry.path.endsWith('.dart'); bool isDartFile(FileSystemEntity entry) => entry is File && entry.path.endsWith('.dart');
typedef bool FileFilter(FileSystemEntity entity); typedef bool FileFilter(FileSystemEntity entity);
......
...@@ -39,11 +39,8 @@ class RefreshCommand extends FlutterCommand { ...@@ -39,11 +39,8 @@ class RefreshCommand extends FlutterCommand {
Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools'); Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools');
try { try {
String snapshotPath = path.join(tempDir.path, 'snapshot_blob.bin'); String snapshotPath = path.join(tempDir.path, 'snapshot_blob.bin');
int result = await createSnapshot(mainPath: argResults['target'], snapshotPath: snapshotPath);
int result = await createSnapshot(
mainPath: argResults['target'],
snapshotPath: snapshotPath
);
if (result != 0) { if (result != 0) {
printError('Failed to run the Flutter compiler. Exit code: $result'); printError('Failed to run the Flutter compiler. Exit code: $result');
return result; return result;
......
...@@ -295,10 +295,18 @@ class _RunAndStayResident { ...@@ -295,10 +295,18 @@ class _RunAndStayResident {
StreamSubscription<String> _loggingSubscription; StreamSubscription<String> _loggingSubscription;
Observatory observatory; Observatory observatory;
String _isolateId;
/// Start the app and keep the process running during its lifetime. /// Start the app and keep the process running during its lifetime.
Future<int> run({ bool traceStartup: false, bool benchmark: false }) async { Future<int> run({ bool traceStartup: false, bool benchmark: false }) {
// Don't let uncaught errors kill the process.
return runZoned(() {
return _run(traceStartup: traceStartup, benchmark: benchmark);
}, onError: (dynamic error) {
printError('Exception from flutter run: $error');
});
}
Future<int> _run({ bool traceStartup: false, bool benchmark: false }) async {
String mainPath = findMainDartFile(target); String mainPath = findMainDartFile(target);
if (!FileSystemEntity.isFileSync(mainPath)) { if (!FileSystemEntity.isFileSync(mainPath)) {
String message = 'Tried to run $mainPath, but that file does not exist.'; String message = 'Tried to run $mainPath, but that file does not exist.';
...@@ -319,7 +327,7 @@ class _RunAndStayResident { ...@@ -319,7 +327,7 @@ class _RunAndStayResident {
return 1; return 1;
} }
Stopwatch stopwatch = new Stopwatch()..start(); Stopwatch startTime = new Stopwatch()..start();
// TODO(devoncarew): We shouldn't have to do type checks here. // TODO(devoncarew): We shouldn't have to do type checks here.
if (device is AndroidDevice) { if (device is AndroidDevice) {
...@@ -377,7 +385,7 @@ class _RunAndStayResident { ...@@ -377,7 +385,7 @@ class _RunAndStayResident {
return 2; return 2;
} }
stopwatch.stop(); startTime.stop();
_exitCompleter = new Completer<int>(); _exitCompleter = new Completer<int>();
...@@ -386,21 +394,21 @@ class _RunAndStayResident { ...@@ -386,21 +394,21 @@ class _RunAndStayResident {
observatory = await Observatory.connect(result.observatoryPort); observatory = await Observatory.connect(result.observatoryPort);
printTrace('Connected to observatory port: ${result.observatoryPort}.'); printTrace('Connected to observatory port: ${result.observatoryPort}.');
observatory.onExtensionEvent.listen((Event event) {
printTrace(event.toString());
});
observatory.onIsolateEvent.listen((Event event) { observatory.onIsolateEvent.listen((Event event) {
if (event['isolate'] != null) printTrace(event.toString());
_isolateId = event['isolate']['id'];
}); });
observatory.streamListen('Isolate');
if (benchmark)
await observatory.waitFirstIsolate;
// Listen for observatory connection close. // Listen for observatory connection close.
observatory.done.whenComplete(() { observatory.done.whenComplete(() {
_handleExit(); _handleExit();
}); });
observatory.getVM().then((VM vm) {
if (vm.isolates.isNotEmpty)
_isolateId = vm.isolates.first['id'];
});
} }
printStatus('Application running.'); printStatus('Application running.');
...@@ -425,7 +433,7 @@ class _RunAndStayResident { ...@@ -425,7 +433,7 @@ class _RunAndStayResident {
_printHelp(); _printHelp();
} else if (lower == 'r' || code == AnsiTerminal.KEY_F5) { } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
// F5, refresh // F5, refresh
_handleRefresh(); _handleRefresh(package, result, mainPath);
} else if (lower == 'q' || code == AnsiTerminal.KEY_F10) { } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
// F10, exit // F10, exit
_handleExit(); _handleExit();
...@@ -441,18 +449,31 @@ class _RunAndStayResident { ...@@ -441,18 +449,31 @@ class _RunAndStayResident {
} }
if (benchmark) { if (benchmark) {
_writeBenchmark(stopwatch); await new Future<Null>.delayed(new Duration(seconds: 4));
new Future<Null>.delayed(new Duration(seconds: 2)).then((_) {
_handleExit(); // Touch the file.
}); File mainFile = new File(mainPath);
mainFile.writeAsBytesSync(mainFile.readAsBytesSync());
Stopwatch restartTime = new Stopwatch()..start();
bool restarted = await _handleRefresh(package, result, mainPath);
restartTime.stop();
_writeBenchmark(startTime, restarted ? restartTime : null);
await new Future<Null>.delayed(new Duration(seconds: 2));
_handleExit();
} }
return _exitCompleter.future.then((int exitCode) async { return _exitCompleter.future.then((int exitCode) async {
if (observatory != null && !observatory.isClosed && _isolateId != null) { try {
observatory.flutterExit(_isolateId); if (observatory != null && !observatory.isClosed) {
if (observatory.isolates.isNotEmpty) {
// WebSockets do not have a flush() method. observatory.flutterExit(observatory.firstIsolateId);
await new Future<Null>.delayed(new Duration(milliseconds: 100)); // The Dart WebSockets API does not have a flush() method.
await new Future<Null>.delayed(new Duration(milliseconds: 100));
}
}
} catch (error) {
stderr.writeln(error.toString());
} }
return exitCode; return exitCode;
...@@ -463,15 +484,33 @@ class _RunAndStayResident { ...@@ -463,15 +484,33 @@ class _RunAndStayResident {
printStatus('Type "h" or F1 for help, "r" or F5 to restart the app, and "q", F10, or ctrl-c to quit.'); printStatus('Type "h" or F1 for help, "r" or F5 to restart the app, and "q", F10, or ctrl-c to quit.');
} }
void _handleRefresh() { Future<bool> _handleRefresh(ApplicationPackage package, LaunchResult result, String mainPath) async {
if (observatory == null) { if (observatory == null) {
printError('Debugging is not enabled.'); printError('Debugging is not enabled.');
return false;
} else { } else {
printStatus('Re-starting application...'); Status status = logger.startProgress('Re-starting application...');
observatory.isolateReload(_isolateId).catchError((dynamic error) { Future<Event> extensionAddedEvent = observatory.onExtensionEvent
printError('Error restarting app: $error'); .where((Event event) => event.extensionKind == 'Flutter.FrameworkInitialization')
}); .first;
bool restartResult = await device.restartApp(
package,
result,
mainPath: mainPath,
observatory: observatory
);
status.stop(showElapsedTime: true);
if (restartResult) {
// TODO(devoncarew): We should restore the route here.
await extensionAddedEvent;
}
return restartResult;
} }
} }
...@@ -533,11 +572,15 @@ Future<Null> _downloadStartupTrace(Observatory observatory) async { ...@@ -533,11 +572,15 @@ Future<Null> _downloadStartupTrace(Observatory observatory) async {
printStatus('Saved startup trace info in ${traceInfoFile.path}.'); printStatus('Saved startup trace info in ${traceInfoFile.path}.');
} }
void _writeBenchmark(Stopwatch stopwatch) { void _writeBenchmark(Stopwatch startTime, [Stopwatch restartTime]) {
final String benchmarkOut = 'refresh_benchmark.json'; final String benchmarkOut = 'refresh_benchmark.json';
Map<String, dynamic> data = <String, dynamic>{ Map<String, dynamic> data = <String, dynamic>{
'time': stopwatch.elapsedMilliseconds 'start': startTime.elapsedMilliseconds,
'time': (restartTime ?? startTime).elapsedMilliseconds // time and restart are the same
}; };
if (restartTime != null)
data['restart'] = restartTime.elapsedMilliseconds;
new File(benchmarkOut).writeAsStringSync(toPrettyJson(data)); new File(benchmarkOut).writeAsStringSync(toPrettyJson(data));
printStatus('Run benchmark written to $benchmarkOut ($data).'); printStatus('Run benchmark written to $benchmarkOut ($data).');
} }
...@@ -13,6 +13,7 @@ import 'base/os.dart'; ...@@ -13,6 +13,7 @@ import 'base/os.dart';
import 'base/utils.dart'; import 'base/utils.dart';
import 'build_info.dart'; import 'build_info.dart';
import 'globals.dart'; import 'globals.dart';
import 'observatory.dart';
import 'ios/devices.dart'; import 'ios/devices.dart';
import 'ios/simulators.dart'; import 'ios/simulators.dart';
...@@ -185,6 +186,15 @@ abstract class Device { ...@@ -185,6 +186,15 @@ abstract class Device {
Map<String, dynamic> platformArgs Map<String, dynamic> platformArgs
}); });
/// Restart the given app; the application will already have been launched with
/// [startApp].
Future<bool> restartApp(
ApplicationPackage package,
LaunchResult result, {
String mainPath,
Observatory observatory
});
/// Stop an app package on the current device. /// Stop an app package on the current device.
Future<bool> stopApp(ApplicationPackage app); Future<bool> stopApp(ApplicationPackage app);
......
...@@ -12,6 +12,7 @@ import '../base/process.dart'; ...@@ -12,6 +12,7 @@ import '../base/process.dart';
import '../build_info.dart'; import '../build_info.dart';
import '../device.dart'; import '../device.dart';
import '../globals.dart'; import '../globals.dart';
import '../observatory.dart';
import 'mac.dart'; import 'mac.dart';
const String _ideviceinstallerInstructions = const String _ideviceinstallerInstructions =
...@@ -198,6 +199,21 @@ class IOSDevice extends Device { ...@@ -198,6 +199,21 @@ class IOSDevice extends Device {
return new LaunchResult.succeeded(); return new LaunchResult.succeeded();
} }
@override
Future<bool> restartApp(
ApplicationPackage package,
LaunchResult result, {
String mainPath,
Observatory observatory
}) async {
return observatory.isolateReload(observatory.firstIsolateId).then((Response response) {
return true;
}).catchError((dynamic error) {
printError('Error restarting app: $error');
return false;
});
}
@override @override
Future<bool> stopApp(ApplicationPackage app) async { Future<bool> stopApp(ApplicationPackage app) async {
// Currently we don't have a way to stop an app running on iOS. // Currently we don't have a way to stop an app running on iOS.
......
...@@ -15,6 +15,7 @@ import '../build_info.dart'; ...@@ -15,6 +15,7 @@ import '../build_info.dart';
import '../device.dart'; import '../device.dart';
import '../flx.dart' as flx; import '../flx.dart' as flx;
import '../globals.dart'; import '../globals.dart';
import '../observatory.dart';
import '../protocol_discovery.dart'; import '../protocol_discovery.dart';
import 'mac.dart'; import 'mac.dart';
...@@ -560,6 +561,21 @@ class IOSSimulator extends Device { ...@@ -560,6 +561,21 @@ class IOSSimulator extends Device {
return (await flx.build(precompiledSnapshot: true)) == 0; return (await flx.build(precompiledSnapshot: true)) == 0;
} }
@override
Future<bool> restartApp(
ApplicationPackage package,
LaunchResult result, {
String mainPath,
Observatory observatory
}) {
return observatory.isolateReload(observatory.firstIsolateId).then((Response response) {
return true;
}).catchError((dynamic error) {
printError('Error restarting app: $error');
return false;
});
}
@override @override
Future<bool> stopApp(ApplicationPackage app) async { Future<bool> stopApp(ApplicationPackage app) async {
// Currently we don't have a way to stop an app running on iOS. // Currently we don't have a way to stop an app running on iOS.
......
...@@ -13,6 +13,15 @@ class Observatory { ...@@ -13,6 +13,15 @@ class Observatory {
peer.registerMethod('streamNotify', (rpc.Parameters event) { peer.registerMethod('streamNotify', (rpc.Parameters event) {
_handleStreamNotify(event.asMap); _handleStreamNotify(event.asMap);
}); });
onIsolateEvent.listen((Event event) {
if (event.kind == 'IsolateStart') {
_addIsolate(event.isolate);
} else if (event.kind == 'IsolateExit') {
String removedId = event.isolate.id;
isolates.removeWhere((IsolateRef ref) => ref.id == removedId);
}
});
} }
static Future<Observatory> connect(int port) async { static Future<Observatory> connect(int port) async {
...@@ -26,19 +35,30 @@ class Observatory { ...@@ -26,19 +35,30 @@ class Observatory {
final rpc.Peer peer; final rpc.Peer peer;
final int port; final int port;
List<IsolateRef> isolates = <IsolateRef>[];
Completer<IsolateRef> _waitFirstIsolateCompleter;
Map<String, StreamController<Event>> _eventControllers = <String, StreamController<Event>>{}; Map<String, StreamController<Event>> _eventControllers = <String, StreamController<Event>>{};
Set<String> _listeningFor = new Set<String>();
bool get isClosed => peer.isClosed; bool get isClosed => peer.isClosed;
Future<Null> get done => peer.done; Future<Null> get done => peer.done;
String get firstIsolateId => isolates.isEmpty ? null : isolates.first.id;
// Events // Events
Stream<Event> get onExtensionEvent => onEvent('Extension');
// IsolateStart, IsolateRunnable, IsolateExit, IsolateUpdate, ServiceExtensionAdded // IsolateStart, IsolateRunnable, IsolateExit, IsolateUpdate, ServiceExtensionAdded
Stream<Event> get onIsolateEvent => _getEventController('Isolate').stream; Stream<Event> get onIsolateEvent => onEvent('Isolate');
Stream<Event> get onTimelineEvent => _getEventController('Timeline').stream; Stream<Event> get onTimelineEvent => onEvent('Timeline');
// Listen for a specific event name. // Listen for a specific event name.
Stream<Event> onEvent(String streamName) => _getEventController(streamName).stream; Stream<Event> onEvent(String streamId) {
streamListen(streamId);
return _getEventController(streamId).stream;
}
StreamController<Event> _getEventController(String eventName) { StreamController<Event> _getEventController(String eventName) {
StreamController<Event> controller = _eventControllers[eventName]; StreamController<Event> controller = _eventControllers[eventName];
...@@ -54,16 +74,31 @@ class Observatory { ...@@ -54,16 +74,31 @@ class Observatory {
_getEventController(data['streamId']).add(event); _getEventController(data['streamId']).add(event);
} }
Future<IsolateRef> get waitFirstIsolate async {
if (isolates.isNotEmpty)
return isolates.first;
_waitFirstIsolateCompleter = new Completer<IsolateRef>();
getVM().then((VM vm) {
for (IsolateRef isolate in vm.isolates)
_addIsolate(isolate);
});
return _waitFirstIsolateCompleter.future;
}
// Requests // Requests
Future<Response> sendRequest(String method, [Map<String, dynamic> args]) { Future<Response> sendRequest(String method, [Map<String, dynamic> args]) {
return peer.sendRequest(method, args).then((dynamic result) => new Response(result)); return peer.sendRequest(method, args).then((dynamic result) => new Response(result));
} }
Future<Response> streamListen(String streamId) { Future<Null> streamListen(String streamId) async {
return sendRequest('streamListen', <String, dynamic>{ if (!_listeningFor.contains(streamId)) {
'streamId': streamId _listeningFor.add(streamId);
}); sendRequest('streamListen', <String, dynamic>{ 'streamId': streamId });
}
} }
Future<VM> getVM() { Future<VM> getVM() {
...@@ -97,6 +132,17 @@ class Observatory { ...@@ -97,6 +132,17 @@ class Observatory {
'isolateId': isolateId 'isolateId': isolateId
}).then((dynamic result) => new Response(result)); }).then((dynamic result) => new Response(result));
} }
void _addIsolate(IsolateRef isolate) {
if (!isolates.contains(isolate)) {
isolates.add(isolate);
if (_waitFirstIsolateCompleter != null) {
_waitFirstIsolateCompleter.complete(isolate);
_waitFirstIsolateCompleter = null;
}
}
}
} }
class Response { class Response {
...@@ -104,6 +150,8 @@ class Response { ...@@ -104,6 +150,8 @@ class Response {
final Map<String, dynamic> response; final Map<String, dynamic> response;
String get type => response['type'];
dynamic operator[](String key) => response[key]; dynamic operator[](String key) => response[key];
@override @override
...@@ -113,18 +161,35 @@ class Response { ...@@ -113,18 +161,35 @@ class Response {
class VM extends Response { class VM extends Response {
VM(Map<String, dynamic> response) : super(response); VM(Map<String, dynamic> response) : super(response);
List<dynamic> get isolates => response['isolates']; List<IsolateRef> get isolates => response['isolates'].map((dynamic ref) => new IsolateRef(ref)).toList();
} }
class Event { class Event extends Response {
Event(this.event); Event(Map<String, dynamic> response) : super(response);
String get kind => response['kind'];
IsolateRef get isolate => new IsolateRef.from(response['isolate']);
/// Only valid for [kind] == `Extension`.
String get extensionKind => response['extensionKind'];
}
final Map<String, dynamic> event; class IsolateRef extends Response {
IsolateRef(Map<String, dynamic> response) : super(response);
factory IsolateRef.from(dynamic ref) => ref == null ? null : new IsolateRef(ref);
String get kind => event['kind']; String get id => response['id'];
dynamic operator[](String key) => event[key]; @override
bool operator ==(dynamic other) {
if (identical(this, other))
return true;
if (other is! IsolateRef)
return false;
final IsolateRef typedOther = other;
return id == typedOther.id;
}
@override @override
String toString() => event.toString(); int get hashCode => id.hashCode;
} }
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