blob: 928f94a167fca1e68ec9b5e73939087f36d40c0b [file] [log] [blame]
// Copyright (c) 2021, 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 'dart:math' as math;
import 'package:path/path.dart' as path;
import 'package:pedantic/pedantic.dart';
import 'package:vm_service/vm_service.dart' as vm;
import '../logging.dart';
import '../protocol_generated.dart';
import '../protocol_stream.dart';
import 'dart.dart';
import 'mixins.dart';
/// A DAP Debug Adapter for running and debugging Dart CLI scripts.
class DartCliDebugAdapter extends DartDebugAdapter<DartLaunchRequestArguments,
DartAttachRequestArguments>
with PidTracker, VmServiceInfoFileUtils, PackageConfigUtils {
Process? _process;
@override
final parseLaunchArgs = DartLaunchRequestArguments.fromJson;
@override
final parseAttachArgs = DartAttachRequestArguments.fromJson;
DartCliDebugAdapter(
ByteStreamServerChannel channel, {
bool ipv6 = false,
bool enableDds = true,
bool enableAuthCodes = true,
Logger? logger,
}) : super(
channel,
ipv6: ipv6,
enableDds: enableDds,
enableAuthCodes: enableAuthCodes,
logger: logger,
);
/// Whether the VM Service closing should be used as a signal to terminate the
/// debug session.
///
/// If we have a process, we will instead use its termination as a signal to
/// terminate the debug session. Otherwise, we will use the VM Service close.
bool get terminateOnVmServiceClose => _process == null;
Future<void> debuggerConnected(vm.VM vmInfo) async {
if (!isAttach) {
// Capture the PID from the VM Service so that we can terminate it when
// cleaning up. Terminating the process might not be enough as it could be
// just a shell script (e.g. pub on Windows) and may not pass the
// signal on correctly.
// See: https://github.com/Dart-Code/Dart-Code/issues/907
final pid = vmInfo.pid;
if (pid != null) {
pidsToTerminate.add(pid);
}
}
}
/// Called by [disconnectRequest] to request that we forcefully shut down the
/// app being run (or in the case of an attach, disconnect).
Future<void> disconnectImpl() async {
if (isAttach) {
await preventBreakingAndResume();
}
terminatePids(ProcessSignal.sigkill);
}
/// Called by [launchRequest] to request that we actually start the app to be
/// run/debugged.
///
/// For debugging, this should start paused, connect to the VM Service, set
/// breakpoints, and resume.
Future<void> launchImpl() async {
final args = this.args as DartLaunchRequestArguments;
File? vmServiceInfoFile;
final debug = !(args.noDebug ?? false);
if (debug) {
vmServiceInfoFile = generateVmServiceInfoFile();
unawaited(waitForVmServiceInfoFile(logger, vmServiceInfoFile)
.then((uri) => connectDebugger(uri, resumeIfStarting: true)));
}
final vmArgs = <String>[
...?args.vmAdditionalArgs,
if (debug) ...[
'--enable-vm-service=${args.vmServicePort ?? 0}${ipv6 ? '/::1' : ''}',
'--pause_isolates_on_start',
if (!enableAuthCodes) '--disable-service-auth-codes'
],
'--disable-dart-dev',
if (debug && vmServiceInfoFile != null) ...[
'-DSILENT_OBSERVATORY=true',
'--write-service-info=${Uri.file(vmServiceInfoFile.path)}'
],
];
// Handle customTool and deletion of any arguments for it.
final executable = args.customTool ?? Platform.resolvedExecutable;
final removeArgs = args.customToolReplacesArgs;
if (args.customTool != null && removeArgs != null) {
vmArgs.removeRange(0, math.min(removeArgs, vmArgs.length));
}
final processArgs = [
...vmArgs,
...?args.toolArgs,
args.program,
...?args.args,
];
// If the client supports runInTerminal and args.console is set to either
// 'terminal' or 'runInTerminal' we won't run the process ourselves, but
// instead call the client to run it for us (this allows it to run in a
// terminal where the user can interact with `stdin`).
final canRunInTerminal =
initializeArgs?.supportsRunInTerminalRequest ?? false;
// The terminal kinds used by DAP are 'integrated' and 'external'.
final terminalKind = canRunInTerminal
? args.console == 'terminal'
? 'integrated'
: args.console == 'externalTerminal'
? 'external'
: null
: null;
if (terminalKind != null) {
await launchInEditorTerminal(
debug,
terminalKind,
executable,
processArgs,
workingDirectory: args.cwd,
env: args.env,
);
} else {
await launchAsProcess(
executable,
processArgs,
workingDirectory: args.cwd,
env: args.env,
);
}
}
/// Called by [attachRequest] to request that we actually connect to the app
/// to be debugged.
Future<void> attachImpl() async {
final args = this.args as DartAttachRequestArguments;
final vmServiceUri = args.vmServiceUri;
final vmServiceInfoFile = args.vmServiceInfoFile;
if ((vmServiceUri == null) == (vmServiceInfoFile == null)) {
sendOutput(
'console',
'\nTo attach, provide exactly one of vmServiceUri/vmServiceInfoFile',
);
handleSessionTerminate();
return;
}
final uri = vmServiceUri != null
? Uri.parse(vmServiceUri)
: await waitForVmServiceInfoFile(logger, File(vmServiceInfoFile!));
unawaited(connectDebugger(uri, resumeIfStarting: false));
}
/// Calls the client (via a `runInTerminal` request) to spawn the process so
/// that it can run in a local terminal that the user can interact with.
Future<void> launchInEditorTerminal(
bool debug,
String terminalKind,
String executable,
List<String> processArgs, {
required String? workingDirectory,
required Map<String, String>? env,
}) async {
final args = this.args as DartLaunchRequestArguments;
logger?.call('Spawning $executable with $processArgs in $workingDirectory'
' via client ${terminalKind} terminal');
// runInTerminal is a DAP request that goes from server-to-client that
// allows the DA to ask the client editor to run the debugee for us. In this
// case we will have no access to the process (although we get the PID) so
// for debugging will rely on the process writing the service-info file that
// we can detect with the normal watching code.
final requestArgs = RunInTerminalRequestArguments(
args: [executable, ...processArgs],
cwd: workingDirectory ?? path.dirname(args.program),
env: env,
kind: terminalKind,
title: args.name ?? 'Dart',
);
try {
final response = await sendRequest(requestArgs);
final body =
RunInTerminalResponseBody.fromJson(response as Map<String, Object?>);
logger?.call(
'Client spawned process'
' (proc: ${body.processId}, shell: ${body.shellProcessId})',
);
} catch (e) {
logger?.call('Client failed to spawn process $e');
sendOutput('console', '\nFailed to spawn process: $e');
handleSessionTerminate();
}
// When using `runInTerminal` and `noDebug`, we will not connect to the VM
// Service so we will have no way of knowing when the process completes, so
// we just send the termination event right away.
if (!debug) {
handleSessionTerminate();
}
}
/// Launches the program as a process controlled by the debug adapter.
///
/// Output to `stdout`/`stderr` will be sent to the editor using
/// [OutputEvent]s.
Future<void> launchAsProcess(
String executable,
List<String> processArgs, {
required String? workingDirectory,
required Map<String, String>? env,
}) async {
logger?.call('Spawning $executable with $processArgs in $workingDirectory');
final process = await Process.start(
executable,
processArgs,
workingDirectory: workingDirectory,
environment: env,
);
_process = process;
pidsToTerminate.add(process.pid);
process.stdout.listen(_handleStdout);
process.stderr.listen(_handleStderr);
unawaited(process.exitCode.then(_handleExitCode));
}
/// Called by [terminateRequest] to request that we gracefully shut down the
/// app being run (or in the case of an attach, disconnect).
Future<void> terminateImpl() async {
if (isAttach) {
await preventBreakingAndResume();
}
terminatePids(ProcessSignal.sigterm);
await _process?.exitCode;
}
void _handleExitCode(int code) {
final codeSuffix = code == 0 ? '' : ' ($code)';
logger?.call('Process exited ($code)');
handleSessionTerminate(codeSuffix);
}
void _handleStderr(List<int> data) {
sendOutput('stderr', utf8.decode(data));
}
void _handleStdout(List<int> data) {
sendOutput('stdout', utf8.decode(data));
}
}