1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
// Copyright 2014 The Flutter Authors. 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:math' as math;
import 'package:dds/dap.dart' hide PidTracker;
import 'package:meta/meta.dart';
import 'package:vm_service/vm_service.dart' as vm;
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/platform.dart';
import '../cache.dart';
import '../convert.dart';
import 'flutter_adapter_args.dart';
import 'mixins.dart';
/// A DAP Debug Adapter for running and debugging Flutter applications.
class FlutterDebugAdapter extends DartDebugAdapter<FlutterLaunchRequestArguments, FlutterAttachRequestArguments>
with PidTracker {
FlutterDebugAdapter(
super.channel, {
required this.fileSystem,
required this.platform,
super.ipv6,
bool enableDds = true,
super.enableAuthCodes,
super.logger,
super.onError,
}) : _enableDds = enableDds,
// Always disable in the DAP layer as it's handled in the spawned
// 'flutter' process.
super(enableDds: false);
FileSystem fileSystem;
Platform platform;
Process? _process;
/// Whether DDS should be enabled in the Flutter process.
///
/// We never enable DDS in the DAP process for Flutter, so this value is not
/// the same as what is passed to the base class, which is always provided 'false'.
final bool _enableDds;
@override
final FlutterLaunchRequestArguments Function(Map<String, Object?> obj)
parseLaunchArgs = FlutterLaunchRequestArguments.fromJson;
@override
final FlutterAttachRequestArguments Function(Map<String, Object?> obj)
parseAttachArgs = FlutterAttachRequestArguments.fromJson;
/// A completer that completes when the app.started event has been received.
@visibleForTesting
final Completer<void> appStartedCompleter = Completer<void>();
/// Whether or not the app.started event has been received.
bool get _receivedAppStarted => appStartedCompleter.isCompleted;
/// The appId of the current running Flutter app.
@visibleForTesting
String? appId;
/// The ID to use for the next request sent to the Flutter run daemon.
int _flutterRequestId = 1;
/// Outstanding requests that have been sent to the Flutter run daemon and
/// their handlers.
final Map<int, Completer<Object?>> _flutterRequestCompleters = <int, Completer<Object?>>{};
/// Whether or not this adapter can handle the restartRequest.
///
/// For Flutter apps we can handle this with a Hot Restart rather than having
/// the whole debug session stopped and restarted.
@override
bool get supportsRestartRequest => true;
/// Whether the VM Service closing should be used as a signal to terminate the debug session.
///
/// Since we always have a process for Flutter (whether run or attach) we'll
/// always use its termination instead, so this is always false.
@override
bool get terminateOnVmServiceClose => false;
/// Whether or not the user requested debugging be enabled.
///
/// For debugging to be enabled, the user must have chosen "Debug" (and not
/// "Run") in the editor (which maps to the DAP `noDebug` field) _and_ must
/// not have requested to run in Profile or Release mode. Profile/Release
/// modes will always disable debugging.
///
/// This is always `true` for attach requests.
///
/// When not debugging, we will not connect to the VM Service so some
/// functionality (breakpoints, evaluation, etc.) will not be available.
/// Functionality provided via the daemon (hot reload/restart) will still be
/// available.
bool get enableDebugger {
final DartCommonLaunchAttachRequestArguments args = this.args;
if (args is FlutterLaunchRequestArguments) {
// Invert DAP's noDebug flag, treating it as false (so _do_ debug) if not
// provided.
return !(args.noDebug ?? false) && !profileMode && !releaseMode;
}
// Otherwise (attach), always debug.
return true;
}
/// Whether the launch configuration arguments specify `--profile`.
///
/// Always `false` for attach requests.
bool get profileMode {
final DartCommonLaunchAttachRequestArguments args = this.args;
if (args is FlutterLaunchRequestArguments) {
return args.toolArgs?.contains('--profile') ?? false;
}
// Otherwise (attach), always false.
return false;
}
/// Whether the launch configuration arguments specify `--release`.
///
/// Always `false` for attach requests.
bool get releaseMode {
final DartCommonLaunchAttachRequestArguments args = this.args;
if (args is FlutterLaunchRequestArguments) {
return args.toolArgs?.contains('--release') ?? false;
}
// Otherwise (attach), always false.
return false;
}
/// Called by [attachRequest] to request that we actually connect to the app to be debugged.
@override
Future<void> attachImpl() async {
final FlutterAttachRequestArguments args = this.args as FlutterAttachRequestArguments;
final String? vmServiceUri = args.vmServiceUri;
final List<String> toolArgs = <String>[
'attach',
'--machine',
if (!_enableDds) '--no-dds',
if (vmServiceUri != null)
...<String>['--debug-uri', vmServiceUri],
];
await _startProcess(
toolArgs: toolArgs,
customTool: args.customTool,
customToolReplacesArgs: args.customToolReplacesArgs,
userToolArgs: args.toolArgs,
);
}
/// [customRequest] handles any messages that do not match standard messages in the spec.
///
/// This is used to allow a client/DA to have custom methods outside of the
/// spec. It is up to the client/DA to negotiate which custom messages are
/// allowed.
///
/// [sendResponse] must be called when handling a message, even if it is with
/// a null response. Otherwise the client will never be informed that the
/// request has completed.
///
/// Any requests not handled must call super which will respond with an error
/// that the message was not supported.
///
/// Unless they start with _ to indicate they are private, custom messages
/// should not change in breaking ways if client IDEs/editors may be calling
/// them.
@override
Future<void> customRequest(
Request request,
RawRequestArguments? args,
void Function(Object?) sendResponse,
) async {
switch (request.command) {
case 'hotRestart':
case 'hotReload':
final bool isFullRestart = request.command == 'hotRestart';
await _performRestart(isFullRestart, args?.args['reason'] as String?);
sendResponse(null);
break;
default:
await super.customRequest(request, args, sendResponse);
}
}
@override
Future<void> debuggerConnected(vm.VM vmInfo) async {
// Usually we'd capture the pid from the VM here and record it for
// terminating, however for Flutter apps it may be running on a remote
// device so it's not valid to terminate a process with that pid locally.
// For attach, pids should never be collected as terminateRequest() should
// not terminate the debugee.
}
/// Called by [disconnectRequest] to request that we forcefully shut down the app being run (or in the case of an attach, disconnect).
///
/// Client IDEs/editors should send a terminateRequest before a
/// disconnectRequest to allow a graceful shutdown. This method must terminate
/// quickly and therefore may leave orphaned processes.
@override
Future<void> disconnectImpl() async {
if (isAttach) {
await preventBreakingAndResume();
}
terminatePids(ProcessSignal.sigkill);
}
@override
Future<void> handleExtensionEvent(vm.Event event) async {
await super.handleExtensionEvent(event);
switch (event.kind) {
case vm.EventKind.kExtension:
switch (event.extensionKind) {
case 'Flutter.ServiceExtensionStateChanged':
_sendServiceExtensionStateChanged(event.extensionData);
break;
case 'Flutter.Error':
_handleFlutterErrorEvent(event.extensionData);
break;
}
break;
}
}
/// Sends OutputEvents to the client for a Flutter.Error event.
void _handleFlutterErrorEvent(vm.ExtensionData? data) {
final Map<String, dynamic>? errorData = data?.data;
if (errorData == null) {
return;
}
final String errorText = (errorData['renderedErrorText'] as String?)
?? (errorData['description'] as String?)
// We should never not error text, but if we do at least send something
// so it's not just completely silent.
?? 'Unknown error in Flutter.Error event';
sendOutput('stderr', '$errorText\n');
}
/// 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.
@override
Future<void> launchImpl() async {
final FlutterLaunchRequestArguments args = this.args as FlutterLaunchRequestArguments;
final List<String> toolArgs = <String>[
'run',
'--machine',
if (!_enableDds) '--no-dds',
if (enableDebugger) '--start-paused',
// Structured errors are enabled by default, but since we don't connect
// the VM Service for noDebug, we need to disable them so that error text
// is sent to stderr. Otherwise the user will not see any exception text
// (because nobody is listening for Flutter.Error events).
if (!enableDebugger)
'--dart-define=flutter.inspector.structuredErrors=false',
];
await _startProcess(
toolArgs: toolArgs,
customTool: args.customTool,
customToolReplacesArgs: args.customToolReplacesArgs,
targetProgram: args.program,
userToolArgs: args.toolArgs,
userArgs: args.args,
);
}
/// Starts the `flutter` process to run/attach to the required app.
Future<void> _startProcess({
required String? customTool,
required int? customToolReplacesArgs,
required List<String> toolArgs,
required List<String>? userToolArgs,
String? targetProgram,
List<String>? userArgs,
}) async {
// Handle customTool and deletion of any arguments for it.
final String executable = customTool ?? fileSystem.path.join(Cache.flutterRoot!, 'bin', platform.isWindows ? 'flutter.bat' : 'flutter');
final int? removeArgs = customToolReplacesArgs;
if (customTool != null && removeArgs != null) {
toolArgs.removeRange(0, math.min(removeArgs, toolArgs.length));
}
final List<String> processArgs = <String>[
...toolArgs,
...?userToolArgs,
if (targetProgram != null) ...<String>[
'--target',
targetProgram,
],
...?userArgs,
];
await launchAsProcess(
executable: executable,
processArgs: processArgs,
env: args.env,
);
}
@visibleForOverriding
Future<void> launchAsProcess({
required String executable,
required List<String> processArgs,
required Map<String, String>? env,
}) async {
logger?.call('Spawning $executable with $processArgs in ${args.cwd}');
final Process process = await Process.start(
executable,
processArgs,
workingDirectory: args.cwd,
environment: env,
);
_process = process;
pidsToTerminate.add(process.pid);
process.stdout.transform(ByteToLineTransformer()).listen(_handleStdout);
process.stderr.listen(_handleStderr);
unawaited(process.exitCode.then(_handleExitCode));
}
/// restart is called by the client when the user invokes a restart (for example with the button on the debug toolbar).
///
/// For Flutter, we handle this ourselves be sending a Hot Restart request
/// to the running app.
@override
Future<void> restartRequest(
Request request,
RestartArguments? args,
void Function() sendResponse,
) async {
await _performRestart(true);
sendResponse();
}
/// Sends a request to the Flutter daemon that is running/attaching to the app and waits for a response.
///
/// If [failSilently] is `true` (the default) and there is no process, the
/// message will be silently ignored (this is common during the application
/// being stopped, where async messages may be processed). Setting it to
/// `false` will cause a [DebugAdapterException] to be thrown in that case.
Future<Object?> sendFlutterRequest(
String method,
Map<String, Object?>? params, {
bool failSilently = true,
}) async {
final Process? process = _process;
if (process == null) {
if (failSilently) {
return null;
} else {
throw DebugAdapterException(
'Unable to Restart because Flutter process is not available',
);
}
}
final Completer<Object?> completer = Completer<Object?>();
final int id = _flutterRequestId++;
_flutterRequestCompleters[id] = completer;
// Flutter requests are always wrapped in brackets as an array.
final String messageString = jsonEncode(
<String, Object?>{'id': id, 'method': method, 'params': params},
);
final String payload = '[$messageString]\n';
process.stdin.writeln(payload);
return completer.future;
}
/// Called by [terminateRequest] to request that we gracefully shut down the app being run (or in the case of an attach, disconnect).
@override
Future<void> terminateImpl() async {
if (isAttach) {
await preventBreakingAndResume();
}
// Send a request to stop/detach to give Flutter chance to do some cleanup.
// It's possible the Flutter process will terminate before we process the
// response, so accept either a response or the process exiting.
if (appId != null) {
final String method = isAttach ? 'app.detach' : 'app.stop';
await Future.any<void>(<Future<void>>[
sendFlutterRequest(method, <String, Object?>{'appId': appId}),
_process?.exitCode ?? Future<void>.value(),
]);
}
terminatePids(ProcessSignal.sigterm);
await _process?.exitCode;
}
/// Connects to the VM Service if the app.started event has fired, and a VM Service URI is available.
Future<void> _connectDebugger(Uri vmServiceUri) async {
if (enableDebugger) {
await connectDebugger(vmServiceUri);
} else {
// Usually, `connectDebugger` (in the base Dart adapter) will send this
// event when it connects a debugger. Since we're not connecting a
// debugger we send this ourselves, to allow clients to connect to the
// VM Service for things like starting DevTools, even if debugging is
// not available.
// TODO(dantup): Switch this to call `sendDebuggerUris()` on the base
// adapter once rolled into Flutter.
sendEvent(
RawEventBody(<String, Object?>{
'vmServiceUri': vmServiceUri.toString(),
}),
eventType: 'dart.debuggerUris',
);
}
}
/// Handles the app.start event from Flutter.
void _handleAppStart(Map<String, Object?> params) {
appId = params['appId'] as String?;
if(appId == null) {
throw DebugAdapterException('Unexpected null `appId` in app.start event');
}
}
/// Handles the app.started event from Flutter.
Future<void> _handleAppStarted() async {
appStartedCompleter.complete();
// Send a custom event so the editor knows the app has started.
//
// This may be useful when there's no VM Service (for example Profile mode)
// but the editor still wants to know that startup has finished.
await debuggerInitialized; // Ensure we're fully initialized before sending.
sendEvent(
RawEventBody(<String, Object?>{}),
eventType: 'flutter.appStarted',
);
}
/// Handles the daemon.connected event, recording the pid of the flutter_tools process.
void _handleDaemonConnected(Map<String, Object?> params) {
// On Windows, the pid from the process we spawn is the shell running
// flutter.bat and terminating it may not be reliable, so we also take the
// pid provided from the VM running flutter_tools.
final int? pid = params['pid'] as int?;
if (pid != null) {
pidsToTerminate.add(pid);
}
}
/// Handles the app.debugPort event from Flutter, connecting to the VM Service if everything else is ready.
Future<void> _handleDebugPort(Map<String, Object?> params) async {
// Capture the VM Service URL which we'll connect to when we get app.started.
final String? wsUri = params['wsUri'] as String?;
if (wsUri != null) {
final Uri vmServiceUri = Uri.parse(wsUri);
// Also wait for app.started before we connect, to ensure Flutter's
// initialization is all complete.
await appStartedCompleter.future;
await _connectDebugger(vmServiceUri);
}
}
/// Handles the Flutter process exiting, terminating the debug session if it has not already begun terminating.
void _handleExitCode(int code) {
final String codeSuffix = code == 0 ? '' : ' ($code)';
logger?.call('Process exited ($code)');
handleSessionTerminate(codeSuffix);
}
/// Handles incoming JSON events from `flutter run --machine`.
void _handleJsonEvent(String event, Map<String, Object?>? params) {
params ??= <String, Object?>{};
switch (event) {
case 'daemon.connected':
_handleDaemonConnected(params);
break;
case 'app.debugPort':
_handleDebugPort(params);
break;
case 'app.start':
_handleAppStart(params);
break;
case 'app.started':
_handleAppStarted();
break;
}
}
/// Handles incoming JSON messages from `flutter run --machine` that are responses to requests that we sent.
void _handleJsonResponse(int id, Map<String, Object?> response) {
final Completer<Object?>? handler = _flutterRequestCompleters.remove(id);
if (handler == null) {
logger?.call(
'Received response from Flutter run daemon with ID $id '
'but had not matching handler',
);
return;
}
final Object? error = response['error'];
final Object? result = response['result'];
if (error != null) {
handler.completeError(DebugAdapterException('$error'));
} else {
handler.complete(result);
}
}
void _handleStderr(List<int> data) {
logger?.call('stderr: $data');
sendOutput('stderr', utf8.decode(data));
}
/// Handles stdout from the `flutter run --machine` process, decoding the JSON and calling the appropriate handlers.
void _handleStdout(String data) {
// Output intended for us to parse is JSON wrapped in brackets:
// [{"event":"app.foo","params":{"bar":"baz"}}]
// However, it's also possible a user printed things that look a little like
// this so try to detect only things we're interested in:
// - parses as JSON
// - is a List of only a single item that is a Map<String, Object?>
// - the item has an "event" field that is a String
// - the item has a "params" field that is a Map<String, Object?>?
logger?.call('stdout: $data');
// Output is sent as console (eg. output from tooling) until the app has
// started, then stdout (users output). This is so info like
// "Launching lib/main.dart on Device foo" is formatted differently to
// general output printed by the user.
final String outputCategory = _receivedAppStarted ? 'stdout' : 'console';
// Output in stdout can include both user output (eg. print) and Flutter
// daemon output. Since it's not uncommon for users to print JSON while
// debugging, we must try to detect which messages are likely Flutter
// messages as reliably as possible, as trying to process users output
// as a Flutter message may result in an unhandled error that will
// terminate the debug adater in a way that does not provide feedback
// because the standard crash violates the DAP protocol.
Object? jsonData;
try {
jsonData = jsonDecode(data);
} on FormatException {
// If the output wasn't valid JSON, it was standard stdout that should
// be passed through to the user.
sendOutput(outputCategory, data);
return;
}
final Map<String, Object?>? payload = jsonData is List &&
jsonData.length == 1 &&
jsonData.first is Map<String, Object?>
? jsonData.first as Map<String, Object?>
: null;
if (payload == null) {
// JSON didn't match expected format for Flutter responses, so treat as
// standard user output.
sendOutput(outputCategory, data);
return;
}
final Object? event = payload['event'];
final Object? params = payload['params'];
final Object? id = payload['id'];
if (event is String && params is Map<String, Object?>?) {
_handleJsonEvent(event, params);
} else if (id is int && _flutterRequestCompleters.containsKey(id)) {
_handleJsonResponse(id, payload);
} else {
// If it wasn't processed above,
sendOutput(outputCategory, data);
}
}
/// Performs a restart/reload by sending the `app.restart` message to the `flutter run --machine` process.
Future<void> _performRestart(
bool fullRestart, [
String? reason,
]) async {
try {
await sendFlutterRequest('app.restart', <String, Object?>{
'appId': appId,
'fullRestart': fullRestart,
'pause': enableDebugger,
'reason': reason,
'debounce': true,
});
} on DebugAdapterException catch (error) {
final String action = fullRestart ? 'Hot Restart' : 'Hot Reload';
sendOutput('console', 'Failed to $action: $error');
}
}
void _sendServiceExtensionStateChanged(vm.ExtensionData? extensionData) {
final Map<String, dynamic>? data = extensionData?.data;
if (data != null) {
sendEvent(
RawEventBody(data),
eventType: 'flutter.serviceExtensionStateChanged',
);
}
}
}