Files
sdk/pkg/dds/lib/devtools_server.dart
T
Ben Konyi 6eb85949fd [ VM / DDS ] Add --print-dtd-uri flag and launch DTD from the correct snapshot for AOT
This adds support for printing the DTD connection information to stdout
when --print-dtd-uri is passed.

This change also fixes an issue where DDS would fail to spawn an isolate
with the DTD snapshot when DDS was running in AOT mode. This means the
SDK must be shipped with both AppJIT and AOT DTD snapshots, at least
until dartdev is moved to run from AOT.

Fixes https://github.com/dart-lang/sdk/issues/55034

TEST=run_test.dart

Change-Id: I788ef9bfe76297a8d594992a2aac440ed9e2ecac
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/358541
Reviewed-by: Siva Annamalai <asiva@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
Reviewed-by: Kenzie Davisson <kenzieschmoll@google.com>
2024-03-21 19:50:46 +00:00

759 lines
23 KiB
Dart

// Copyright (c) 2022, 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 'package:args/args.dart';
import 'package:browser_launcher/browser_launcher.dart';
import 'package:devtools_shared/devtools_shared.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:path/path.dart' as path;
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf;
import 'src/devtools/client.dart';
import 'src/devtools/dtd.dart';
import 'src/devtools/handler.dart';
import 'src/devtools/machine_mode_command_handler.dart';
import 'src/devtools/memory_profile.dart';
import 'src/devtools/utils.dart';
import 'src/utils/console.dart';
class DevToolsServer {
static const protocolVersion = '1.2.0';
static const defaultTryPorts = 10;
static const defaultDdsHost = '127.0.0.1';
static const defaultDdsPort = 0;
static const commandDescription =
'Open DevTools (optionally connecting to an existing application).';
static const argHelp = 'help';
static const argVmUri = 'vm-uri';
static const argEnableNotifications = 'enable-notifications';
static const argAllowEmbedding = 'allow-embedding';
static const argAppSizeBase = 'app-size-base';
static const argAppSizeTest = 'app-size-test';
static const argHeadlessMode = 'headless';
static const argDdsHost = 'dds-host';
static const argDdsPort = 'dds-port';
static const argDebugMode = 'debug';
static const argDtdUri = 'dtd-uri';
static const argPrintDtd = 'print-dtd';
static const argLaunchBrowser = 'launch-browser';
static const argMachine = 'machine';
static const argHost = 'host';
static const argPort = 'port';
static const argProfileMemory = 'record-memory-profile';
static const argTryPorts = 'try-ports';
static const argVerbose = 'verbose';
static const argVersion = 'version';
static const launchDevToolsService = 'launchDevTools';
MachineModeCommandHandler? _machineModeCommandHandler;
late ClientManager clientManager;
final bool _isChromeOS = File('/dev/.cros_milestone').existsSync();
/// Builds an arg parser for the DevTools server.
///
/// [includeHelpOption] should be set to false if this arg parser will be used
/// in a Command subclass.
static ArgParser buildArgParser({
bool verbose = false,
bool includeHelpOption = true,
int? usageLineLength,
}) {
final argParser = ArgParser(
usageLineLength: usageLineLength,
);
if (includeHelpOption) {
argParser.addFlag(
argHelp,
negatable: false,
abbr: 'h',
help: 'Prints help output.',
);
}
argParser
..addFlag(
argVersion,
negatable: false,
help: 'Prints the DevTools version.',
)
..addFlag(
argVerbose,
negatable: false,
abbr: 'v',
help: 'Output more informational messages.',
)
..addOption(
argHost,
valueHelp: 'host',
help: 'Hostname to serve DevTools on (defaults to localhost).',
)
..addOption(
argPort,
defaultsTo: '9100',
valueHelp: 'port',
help: 'Port to serve DevTools on; specify 0 to automatically use any '
'available port.',
)
..addOption(
argDtdUri,
valueHelp: 'uri',
help: 'A URI pointing to a Dart Tooling Daemon that DevTools should '
'interface with.',
)
..addFlag(
argLaunchBrowser,
help:
'Launches DevTools in a browser immediately at start.\n(defaults to on unless in --machine mode)',
)
..addFlag(
argMachine,
negatable: false,
help: 'Sets output format to JSON for consumption in tools.',
)
..addSeparator('Memory profiling options:')
..addOption(
argProfileMemory,
valueHelp: 'file',
defaultsTo: 'memory_samples.json',
help:
'Start devtools headlessly and write memory profiling samples to the '
'indicated file.',
);
argParser.addSeparator('App size options:');
argParser
..addOption(
argAppSizeBase,
aliases: ['appSizeBase'],
valueHelp: 'file',
help: 'Path to the base app size file used for app size debugging.',
)
..addOption(
argAppSizeTest,
aliases: ['appSizeTest'],
valueHelp: 'file',
help:
'Path to the test app size file used for app size debugging.\nThis '
'file should only be specified if --$argAppSizeBase is also '
'specified.',
hide: !verbose,
);
if (verbose) {
argParser.addSeparator('Advanced options:');
}
// Args to show for verbose mode.
argParser
..addOption(
argTryPorts,
defaultsTo: DevToolsServer.defaultTryPorts.toString(),
valueHelp: 'count',
help: 'The number of ascending ports to try binding to before failing '
'with an error.',
hide: !verbose,
)
..addOption(
argDdsHost,
defaultsTo: DevToolsServer.defaultDdsHost,
valueHelp: 'bind-address',
help:
"The address the Dart Development Service (DDS) should attempt to "
"bind to if a DDS instance isn't active and a VM service URI is "
"provided.",
hide: !verbose,
)
..addOption(
argDdsPort,
defaultsTo: DevToolsServer.defaultDdsPort.toString(),
valueHelp: 'port',
help:
"The address the Dart Development Service (DDS) should attempt to "
"bind to if a DDS instance isn't active and a VM service URI is "
"provided.",
hide: !verbose,
)
..addFlag(
argEnableNotifications,
negatable: false,
help: 'Requests notification permissions immediately when a client '
'connects back to the server.',
hide: !verbose,
)
..addFlag(
argAllowEmbedding,
help: 'Allow embedding DevTools inside an iframe.',
hide: !verbose,
)
..addFlag(
argHeadlessMode,
negatable: false,
help: 'Causes the server to spawn Chrome in headless mode for use in '
'automated testing.',
hide: !verbose,
)
..addFlag(
argPrintDtd,
negatable: false,
help: 'Print the address of the Dart Tooling Daemon, if one is hosted '
'by the DevTools server.',
hide: !verbose,
);
// Deprecated and hidden args.
// TODO: Remove this - prefer that clients use the rest arg.
argParser
..addOption(
argVmUri,
defaultsTo: '',
help: 'VM Service protocol URI.',
hide: true,
)
// Development only args.
..addFlag(
argDebugMode,
negatable: false,
help: 'Run a debug build of the DevTools web frontend.',
hide: true,
);
return argParser;
}
/// Serves DevTools.
///
/// `handler` is the [shelf.Handler] that the server will use for all requests.
/// If null, [defaultHandler] will be used. Defaults to null.
///
/// `customDevToolsPath` is a path to a directory containing a pre-built
/// DevTools application.
///
// Note: this method is used by the Dart CLI and by package:dwds.
Future<HttpServer?> serveDevTools({
bool enableStdinCommands = true,
bool machineMode = false,
bool debugMode = false,
bool launchBrowser = false,
bool enableNotifications = false,
bool allowEmbedding = true,
bool headlessMode = false,
bool verboseMode = false,
bool printDtdUri = false,
String? hostname,
String? customDevToolsPath,
int port = 0,
int numPortsToTry = defaultTryPorts,
shelf.Handler? handler,
String? serviceProtocolUri,
String? profileFilename,
String? appSizeBase,
String? appSizeTest,
String? dtdUri,
}) async {
hostname ??= 'localhost';
// Collect profiling information.
if (profileFilename != null && serviceProtocolUri != null) {
final Uri? vmServiceUri = Uri.tryParse(serviceProtocolUri);
if (vmServiceUri != null) {
await _hookupMemoryProfiling(
vmServiceUri,
profileFilename,
verboseMode,
);
}
return null;
}
if (machineMode) {
assert(
enableStdinCommands,
'machineMode only works with enableStdinCommands.',
);
}
clientManager = ClientManager(
requestNotificationPermissions: enableNotifications,
);
String? dtdSecret;
if (dtdUri == null) {
final (:uri, :secret) = await startDtd(
machineMode: machineMode,
printDtdUri: printDtdUri,
);
dtdUri = uri;
dtdSecret = secret;
}
handler ??= await defaultHandler(
buildDir: customDevToolsPath!,
clientManager: clientManager,
analytics: DevToolsUtils.initializeAnalytics(),
dtd: (uri: dtdUri, secret: dtdSecret),
);
HttpServer? server;
SocketException? ex;
while (server == null && numPortsToTry >= 0) {
// If we have tried [numPortsToTry] ports and still have not been able to
// connect, try port 0 to find a random available port.
if (numPortsToTry == 0) port = 0;
try {
server = await HttpMultiServer.bind(hostname, port);
} on SocketException catch (e) {
ex = e;
numPortsToTry--;
port++;
}
}
// Re-throw the last exception if we failed to bind.
if (server == null && ex != null) {
throw ex;
}
// Type promote server.
server!;
if (allowEmbedding) {
server.defaultResponseHeaders.remove('x-frame-options', 'SAMEORIGIN');
// The origin-agent-cluster header is required to support the embedding of
// Dart DevTools in Chrome DevTools.
server.defaultResponseHeaders.add('origin-agent-cluster', '?1');
}
// Ensure browsers don't cache older versions of the app.
server.defaultResponseHeaders.add(
HttpHeaders.cacheControlHeader,
'no-store',
);
// Serve requests in an error zone to prevent failures
// when running from another error zone.
runZonedGuarded(
() => shelf.serveRequests(server!, handler!),
(e, _) => print('Error serving requests: $e'),
);
final devToolsUrl = 'http://${server.address.host}:${server.port}';
if (launchBrowser) {
if (serviceProtocolUri != null) {
serviceProtocolUri =
normalizeVmServiceUri(serviceProtocolUri).toString();
}
final queryParameters = {
if (serviceProtocolUri != null) 'uri': serviceProtocolUri,
if (appSizeBase != null) 'appSizeBase': appSizeBase,
if (appSizeTest != null) 'appSizeTest': appSizeTest,
};
String url = Uri.parse(devToolsUrl)
.replace(queryParameters: queryParameters)
.toString();
// If app size parameters are present, open to the standalone `app-size`
// page, regardless if there is a vm service uri specified. We only check
// for the presence of [appSizeBase] here because [appSizeTest] may or may
// not be specified (it should only be present for diffs). If [appSizeTest]
// is present without [appSizeBase], we will ignore the parameter.
if (appSizeBase != null) {
final startQueryParamIndex = url.indexOf('?');
if (startQueryParamIndex != -1) {
url = '${url.substring(0, startQueryParamIndex)}'
'/#/app-size'
'${url.substring(startQueryParamIndex)}';
}
}
try {
await Chrome.start([url]);
} catch (e) {
print('Unable to launch Chrome: $e\n');
}
}
if (enableStdinCommands) {
String message = '''Serving DevTools at $devToolsUrl.
Hit ctrl-c to terminate the server.''';
if (!machineMode && debugMode) {
// Add bold to help find the correct url to open.
message = ConsoleUtils.bold('$message\n');
}
DevToolsUtils.printOutput(
message,
{
'event': 'server.started',
// TODO(dantup): Remove this `method` field when we're sure VS Code
// users are all on a newer version that uses `event`. We incorrectly
// used `method` for the original releases.
'method': 'server.started',
'params': {
'host': server.address.host,
'port': server.port,
'pid': pid,
'protocolVersion': protocolVersion,
}
},
machineMode: machineMode,
);
if (machineMode) {
_machineModeCommandHandler = MachineModeCommandHandler(server: this);
await _machineModeCommandHandler!.initialize(
devToolsUrl: devToolsUrl,
headlessMode: headlessMode,
);
}
}
return server;
}
void _printUsage(ArgParser argParser) {
print(commandDescription);
print('\nUsage: devtools [arguments] [service protocol uri]');
print(argParser.usage);
}
/// Wraps [serveDevTools] `arguments` parsed, as from the command line.
///
/// For more information on `handler`, see [serveDevTools].
// Note: this method is used in google3 as well as by DevTools' main method.
Future<HttpServer?> serveDevToolsWithArgs(
List<String> arguments, {
shelf.Handler? handler,
String? customDevToolsPath,
}) async {
ArgResults args;
final verbose = arguments.contains('-v') || arguments.contains('--verbose');
final argParser = buildArgParser(verbose: verbose);
try {
args = argParser.parse(arguments);
} on FormatException catch (e) {
print(e.message);
print('');
_printUsage(argParser);
return null;
}
return await _serveDevToolsWithArgs(
args,
verbose,
handler: handler,
customDevToolsPath: customDevToolsPath,
);
}
Future<HttpServer?> _serveDevToolsWithArgs(
ArgResults args,
bool verbose, {
shelf.Handler? handler,
String? customDevToolsPath,
}) async {
final help = args[argHelp];
final bool version = args[argVersion];
final bool machineMode = args[argMachine];
// launchBrowser defaults based on machine-mode if not explicitly supplied.
final bool launchBrowser = args.wasParsed(argLaunchBrowser)
? args[argLaunchBrowser]
: !machineMode;
final bool enableNotifications = args[argEnableNotifications];
final bool allowEmbedding =
args.wasParsed(argAllowEmbedding) ? args[argAllowEmbedding] : true;
final port = args[argPort] != null ? int.tryParse(args[argPort]) ?? 0 : 0;
final bool headlessMode = args[argHeadlessMode];
final bool debugMode = args[argDebugMode];
final numPortsToTry = args[argTryPorts] != null
? int.tryParse(args[argTryPorts]) ?? 0
: defaultTryPorts;
final bool verboseMode = args[argVerbose];
final String? hostname = args[argHost];
String? dtdUri;
if (args.wasParsed(argDtdUri)) {
dtdUri = args[argDtdUri];
}
final printDtdUri = args.wasParsed(argPrintDtd);
if (help) {
print(
'Dart DevTools version ${await DevToolsUtils.getVersion(customDevToolsPath ?? "")}');
print('');
_printUsage(buildArgParser(verbose: verbose));
return null;
}
if (version) {
final versionStr =
await DevToolsUtils.getVersion(customDevToolsPath ?? '');
DevToolsUtils.printOutput(
'Dart DevTools version $versionStr',
{
'version': versionStr,
},
machineMode: machineMode,
);
return null;
}
// Prefer getting the VM URI from the rest args; fall back on the 'vm-url'
// option otherwise.
String? serviceProtocolUri;
if (args.rest.isNotEmpty) {
serviceProtocolUri = args.rest.first;
} else if (args.wasParsed(argVmUri)) {
serviceProtocolUri = args[argVmUri];
}
// Support collecting profile data.
String? profileFilename;
if (args.wasParsed(argProfileMemory)) {
profileFilename = args[argProfileMemory];
}
if (profileFilename != null && !path.isAbsolute(profileFilename)) {
profileFilename = path.absolute(profileFilename);
}
// App size info.
String? appSizeBase = args[argAppSizeBase];
if (appSizeBase != null && !path.isAbsolute(appSizeBase)) {
appSizeBase = path.absolute(appSizeBase);
}
String? appSizeTest = args[argAppSizeTest];
if (appSizeTest != null && !path.isAbsolute(appSizeTest)) {
appSizeTest = path.absolute(appSizeTest);
}
return serveDevTools(
machineMode: machineMode,
debugMode: debugMode,
launchBrowser: launchBrowser,
enableNotifications: enableNotifications,
allowEmbedding: allowEmbedding,
port: port,
headlessMode: headlessMode,
numPortsToTry: numPortsToTry,
handler: handler,
customDevToolsPath: customDevToolsPath,
serviceProtocolUri: serviceProtocolUri,
profileFilename: profileFilename,
verboseMode: verboseMode,
hostname: hostname,
appSizeBase: appSizeBase,
appSizeTest: appSizeTest,
dtdUri: dtdUri,
printDtdUri: printDtdUri,
);
}
Future<Map<String, dynamic>> launchDevTools(
Map<String, dynamic> params,
Uri vmServiceUri,
String devToolsUrl,
bool headlessMode,
bool machineMode,
) async {
// First see if we have an existing DevTools client open that we can
// reuse.
final canReuse =
params.containsKey('reuseWindows') && params['reuseWindows'] == true;
final shouldNotify =
params.containsKey('notify') && params['notify'] == true;
final page = params['page'];
if (canReuse &&
await _tryReuseExistingDevToolsInstance(
vmServiceUri,
page,
shouldNotify,
)) {
_emitLaunchEvent(
reused: true,
notified: shouldNotify,
pid: null,
machineMode: machineMode,
);
return {
'reused': true,
'notified': shouldNotify,
};
}
final uriParams = <String, dynamic>{};
// Copy over queryParams passed by the client
params['queryParams']?.forEach((key, value) => uriParams[key] = value);
// Add the URI to the VM service
uriParams['uri'] = vmServiceUri.toString();
final devToolsUri = Uri.parse(devToolsUrl);
final uriToLaunch = _buildUriToLaunch(uriParams, page, devToolsUri);
// TODO(dantup): When ChromeOS has support for tunneling all ports we can
// change this to always use the native browser for ChromeOS and may wish to
// handle this inside `browser_launcher`; https://crbug.com/848063.
final useNativeBrowser = _isChromeOS &&
_isAccessibleToChromeOSNativeBrowser(devToolsUri) &&
_isAccessibleToChromeOSNativeBrowser(vmServiceUri);
int? browserPid;
if (useNativeBrowser) {
await Process.start('x-www-browser', [uriToLaunch.toString()]);
} else {
final args = headlessMode
? [
'--headless',
// When running headless, Chrome will quit immediately after loading
// the page unless we have the debug port open.
'--remote-debugging-port=9223',
'--disable-gpu',
'--no-sandbox',
]
: <String>[];
final proc = await Chrome.start([uriToLaunch.toString()], args: args);
browserPid = proc.pid;
}
_emitLaunchEvent(
reused: false,
notified: false,
pid: browserPid!,
machineMode: machineMode);
return {
'reused': false,
'notified': false,
'pid': browserPid,
};
}
Future<void> _hookupMemoryProfiling(
Uri observatoryUri,
String profileFile, [
bool verboseMode = false,
]) async {
final service = await DevToolsUtils.connectToVmService(observatoryUri);
if (service == null) {
return;
}
final memoryProfiler = MemoryProfile(service, profileFile, verboseMode);
memoryProfiler.startPolling();
print('Writing memory profile samples to $profileFile...');
}
/// Tries to reuse an existing DevTools instance.
///
/// Because SSE connections have timeouts that prevent us knowing if a client
/// has really gone away for up to 30s, this method will attempt to ping
/// candidate first to see if it is still responsive.
Future<bool> _tryReuseExistingDevToolsInstance(
Uri vmServiceUri,
String? page,
bool notifyUser,
) async {
// First try to find a client that's already connected to this VM service,
// and just send the user a notification for that one.
final existingClient =
await clientManager.findExistingConnectedReusableClient(vmServiceUri);
if (existingClient != null) {
try {
if (page != null) {
existingClient.showPage(page);
}
if (notifyUser) {
existingClient.notify();
}
return true;
} catch (e) {
print('Failed to reuse existing connected DevTools client');
print(e);
}
}
final reusableClient = await clientManager.findReusableClient();
if (reusableClient != null) {
try {
reusableClient.connectToVmService(vmServiceUri, notifyUser);
return true;
} catch (e) {
print('Failed to reuse existing DevTools client');
print(e);
}
}
return false;
}
String _buildUriToLaunch(
Map<String, dynamic> uriParams,
String? page,
Uri devToolsUri,
) {
final queryStringNameValues = [];
uriParams.forEach((key, value) => queryStringNameValues.add(
'${Uri.encodeQueryComponent(key)}=${Uri.encodeQueryComponent(value)}'));
if (page != null) {
queryStringNameValues.add('page=${Uri.encodeQueryComponent(page)}');
}
return devToolsUri
.replace(
path: devToolsUri.path.isEmpty ? '/' : devToolsUri.path,
fragment: '?${queryStringNameValues.join('&')}')
.toString();
}
/// Prints a launch event to stdout so consumers of the DevTools server
/// can see when clients are being launched/reused.
void _emitLaunchEvent(
{required bool reused,
required bool notified,
required int? pid,
required bool machineMode}) {
DevToolsUtils.printOutput(
null,
{
'event': 'client.launch',
'params': {
'reused': reused,
'notified': notified,
'pid': pid,
},
},
machineMode: machineMode,
);
}
bool _isAccessibleToChromeOSNativeBrowser(Uri uri) {
const tunneledPorts = {
8000,
8008,
8080,
8085,
8888,
9005,
3000,
4200,
5000
};
return uri.hasPort && tunneledPorts.contains(uri.port);
}
}