From 9e02b33a36c772e25607a9069af90c77aaf7f381 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 28 Apr 2026 11:02:36 -0500 Subject: [PATCH 1/6] fix(core): profile short Dart file runs --- README.md | 20 +++- packages/devtools_profiler_cli/CHANGELOG.md | 7 ++ packages/devtools_profiler_cli/README.md | 14 ++- .../src/cli/commands/capture_commands.dart | 8 +- packages/devtools_profiler_cli/pubspec.yaml | 4 +- .../devtools_profiler_cli/test/cli_test.dart | 5 +- packages/devtools_profiler_core/CHANGELOG.md | 7 ++ packages/devtools_profiler_core/README.md | 17 ++- .../lib/src/capture/profile_run_request.dart | 5 +- .../lib/src/capture/profile_runner.dart | 113 ++++++++++++++++-- .../src/capture/runner/process_launch.dart | 30 ++++- .../runner/profile_session_controller.dart | 18 +++ .../runner/profile_session_vm_hookup.dart | 107 +++++++++++++++++ packages/devtools_profiler_core/pubspec.yaml | 2 +- .../fixtures/profiled_app/bin/quick_exit.dart | 12 ++ .../test/profile_runner_test.dart | 29 +++++ 16 files changed, 368 insertions(+), 30 deletions(-) create mode 100644 packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart diff --git a/README.md b/README.md index 3fceba8..64ca105 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,15 @@ devtools-profiler run \ -- dart run bin/profiled_app.dart ``` -Profile your own Dart command: +Profile your own Dart file: + +```bash +devtools-profiler run \ + --cwd /path/to/your/dart/app \ + bin/main.dart +``` + +Profile a full Dart command: ```bash devtools-profiler run \ @@ -55,8 +63,11 @@ devtools-profiler run \ -- dart run bin/main.dart ``` -Everything after `--` is the command being profiled. The first token must be -`dart` or `flutter`. +Bare Dart files are expanded to `dart run `. For full Dart or Flutter +commands, put profiler options before the target command and use `--` when the +target command has its own options. Dart launches are held at isolate exit long +enough for final CPU and memory snapshots, so short scripts can still produce a +whole-session profile. Profile a Flutter test run: @@ -542,7 +553,8 @@ devtools-profiler help Commands: -- `run -- ` launches and profiles a Dart or Flutter command. +- `run [--] ` launches and profiles a Dart file, Dart + command, or Flutter command. - `attach ` profiles an already-running VM service for a fixed `--duration`. - `summarize ` summarizes a session directory or profile artifact. diff --git a/packages/devtools_profiler_cli/CHANGELOG.md b/packages/devtools_profiler_cli/CHANGELOG.md index 672700f..5d01fae 100644 --- a/packages/devtools_profiler_cli/CHANGELOG.md +++ b/packages/devtools_profiler_cli/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.1-wip + +- Added `devtools-profiler run .dart` shorthand for profiling Dart files + without spelling out `dart run`. +- Improved Dart run behavior for short-lived scripts by using the backend's + exit-pause capture path instead of reporting a disposed VM service error. + ## 0.2.0 - Added `inspect-classes` and the `profile_inspect_classes` MCP tool for diff --git a/packages/devtools_profiler_cli/README.md b/packages/devtools_profiler_cli/README.md index b6ac9f9..df2156a 100644 --- a/packages/devtools_profiler_cli/README.md +++ b/packages/devtools_profiler_cli/README.md @@ -40,6 +40,15 @@ devtools-profiler run \ Profile your own app: +```bash +devtools-profiler run \ + --cwd /path/to/app \ + bin/main.dart +``` + +For full Dart or Flutter commands, put profiler options before the target +command. Use `--` when the target command has its own options: + ```bash devtools-profiler run \ --json \ @@ -53,8 +62,9 @@ devtools-profiler run \ -- dart run bin/main.dart ``` -Everything after `--` is the command being profiled. The command must start -with `dart` or `flutter`. +Bare Dart files are expanded to `dart run `. Dart launches are held at +isolate exit long enough for final CPU and memory snapshots, so short scripts +can still produce a whole-session profile. Profile a Flutter test: diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/capture_commands.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/capture_commands.dart index 7a4fced..404b430 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/capture_commands.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/capture_commands.dart @@ -51,12 +51,13 @@ class RunCommand extends ProfilerCommand { @override String get invocation => - '${runner!.executableName} run [options] -- '; + '${runner!.executableName} run [options] [--] '; @override String formatUsage({bool includeDescription = true}) => usageWithExamples( super.formatUsage(includeDescription: includeDescription), const [ + 'devtools-profiler run bin/main.dart', 'devtools-profiler run -- dart run bin/main.dart', 'devtools-profiler run --cwd path/to/app -- dart run bin/main.dart', 'devtools-profiler run --duration 15s --cwd path/to/flutter_app -- flutter run -d linux -t lib/main.dart', @@ -68,8 +69,9 @@ class RunCommand extends ProfilerCommand { final commandArguments = argResults!.rest; if (commandArguments.isEmpty) { usageException( - 'A profiled Dart or Flutter command is required after "--". ' - 'Put profiler options before "--" and the target command after it.', + 'A profiled Dart file, Dart command, or Flutter command is required. ' + 'Put profiler options before the target command, and use "--" when ' + 'the target command has options that could be parsed as profiler options.', ); } diff --git a/packages/devtools_profiler_cli/pubspec.yaml b/packages/devtools_profiler_cli/pubspec.yaml index 8010e83..b006d60 100644 --- a/packages/devtools_profiler_cli/pubspec.yaml +++ b/packages/devtools_profiler_cli/pubspec.yaml @@ -2,7 +2,7 @@ name: devtools_profiler_cli description: CLI and local stdio MCP server for automated Dart and Flutter CPU profiling. -version: 0.2.0 +version: 0.2.1-wip environment: sdk: '>=3.10.0 <4.0.0' @@ -17,7 +17,7 @@ executables: dependencies: artisanal: ^0.3.0 dart_mcp: ^0.5.0 - devtools_profiler_core: ^0.2.0 + devtools_profiler_core: ^0.2.1-wip path: ^1.9.0 stream_channel: ^2.1.4 vm_service: ^15.0.2 diff --git a/packages/devtools_profiler_cli/test/cli_test.dart b/packages/devtools_profiler_cli/test/cli_test.dart index f1a23ec..8758d59 100644 --- a/packages/devtools_profiler_cli/test/cli_test.dart +++ b/packages/devtools_profiler_cli/test/cli_test.dart @@ -28,9 +28,12 @@ void main() { expect(exitCode, 0); expect( stdoutCapture.text, - contains('devtools-profiler run [options] -- '), + contains( + 'devtools-profiler run [options] [--] ', + ), ); expect(stdoutCapture.text, contains('Examples:')); + expect(stdoutCapture.text, contains('devtools-profiler run bin/main.dart')); expect( stdoutCapture.text, contains('devtools-profiler run -- dart run bin/main.dart'), diff --git a/packages/devtools_profiler_core/CHANGELOG.md b/packages/devtools_profiler_core/CHANGELOG.md index 8ee1dbc..a04797a 100644 --- a/packages/devtools_profiler_core/CHANGELOG.md +++ b/packages/devtools_profiler_core/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.1-wip + +- Added bare Dart file launch support by expanding file paths to + `dart run `. +- Improved short-lived Dart process profiling by holding isolates at exit long + enough to capture final whole-session CPU and memory snapshots. + ## 0.2.0 - Added memory class artifact inspection helpers for stored session, region, and diff --git a/packages/devtools_profiler_core/README.md b/packages/devtools_profiler_core/README.md index f63ffb1..ef37f92 100644 --- a/packages/devtools_profiler_core/README.md +++ b/packages/devtools_profiler_core/README.md @@ -58,8 +58,21 @@ Future main() async { } ``` -The launched command must start with `dart` or `flutter`. Compile targets, -Flutter release mode, and AOT targets are not supported. +The launched command must start with `dart` or `flutter`, or with a Dart file +path. Bare Dart files are expanded to `dart run `: + +```dart +await runner.run( + const ProfileRunRequest( + command: ['bin/main.dart'], + workingDirectory: '/path/to/app', + ), +); +``` + +Dart launches are held at isolate exit long enough for final CPU and memory +snapshots, so short scripts can still produce a whole-session profile. Compile +targets, Flutter release mode, and AOT targets are not supported. Flutter examples: diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart b/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart index 3f2c04c..e0eacab 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart @@ -1,7 +1,8 @@ /// A request to launch and profile a Dart or Flutter command. /// /// Use this with [ProfileRunner.run] when the profiler should own the target -/// process lifecycle. The command must start with `dart` or `flutter`. +/// process lifecycle. The command must start with `dart` or `flutter`, or with +/// a Dart file path that will be expanded to `dart run `. /// Session artifacts are written under [artifactDirectory] when provided, or /// under a generated `.dart_tool/devtools_profiler/sessions/...` directory /// inside [workingDirectory] otherwise. @@ -19,7 +20,7 @@ class ProfileRunRequest { /// The command to launch. /// - /// The first argument must be `dart` or `flutter`. + /// The first argument must be `dart`, `flutter`, or a Dart file path. final List command; /// The working directory to use for the launched process. diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart index 738d4e9..a3790ba 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -33,11 +33,13 @@ class ProfileRunner { /// markers, and writes artifacts before returning the final /// [ProfileRunResult]. /// - /// The command in [request] must start with `dart` or `flutter`. Unsupported - /// launch shapes such as Flutter release mode, browser targets, or AOT-style - /// runs are rejected before the process starts. + /// The command in [request] must start with `dart`, `flutter`, or a Dart + /// file path. Unsupported launch shapes such as Flutter release mode, browser + /// targets, or AOT-style runs are rejected before the process starts. Future run(ProfileRunRequest request) async { - validateProfileCommand(request.command); + final command = normalizeProfileCommand(request.command); + validateProfileCommand(command); + final commandKind = profileCommandKind(command); final sessionId = generateProfileSessionId(); final workingDirectory = _resolveWorkingDirectory(request.workingDirectory); @@ -71,6 +73,7 @@ class ProfileRunner { final launchedProcess = await launchProfiledProcess( request: request, + command: command, sessionId: sessionId, dtdUri: dtdSession.info.localUri.toString(), workingDirectory: workingDirectory, @@ -82,7 +85,7 @@ class ProfileRunner { final vmServiceTimeout = request.vmServiceTimeout ?? - defaultVmServiceTimeoutForCommand(request.command); + defaultVmServiceTimeoutForCommand(command); final serviceUri = await launchedProcess.serviceUri.future.timeout( vmServiceTimeout, onTimeout: () { @@ -92,7 +95,10 @@ class ProfileRunner { ); }, ); - await sessionController.attachToVmService(serviceUri); + await sessionController.attachToVmService( + serviceUri, + monitorExitPause: commandKind == ProfileCommandKind.dart, + ); final runDuration = request.runDuration; if (runDuration != null) { @@ -109,14 +115,35 @@ class ProfileRunner { }); } - final exitCode = await process.exitCode; - processExited = true; + final exitCodeFuture = process.exitCode; + final completion = commandKind == ProfileCommandKind.dart + ? await _waitForDartProcessCompletion( + exitCodeFuture, + sessionController, + ) + : ( + kind: _ProfiledProcessCompletionKind.exited, + exitCode: await exitCodeFuture, + ); runDurationTimer?.cancel(); - await sessionController.handleProcessExit(); + int exitCode; + if (completion.kind == _ProfiledProcessCompletionKind.pausedAtExit) { + await sessionController.handleProcessExit(); + exitCode = await _resumeExitPausedProcess( + exitCodeFuture: exitCodeFuture, + process: process, + sessionController: sessionController, + ); + } else { + exitCode = completion.exitCode!; + processExited = true; + await sessionController.handleProcessExit(); + } + processExited = true; final result = sessionController.buildResult( artifactDirectory: artifactDirectory.path, - command: request.command, + command: command, exitCode: exitCode, terminatedByProfiler: terminatedByProfiler, workingDirectory: workingDirectory, @@ -258,6 +285,72 @@ class ProfileRunner { } } +enum _ProfiledProcessCompletionKind { exited, pausedAtExit } + +Future<({int? exitCode, _ProfiledProcessCompletionKind kind})> +_waitForDartProcessCompletion( + Future exitCodeFuture, + ProfileSessionController sessionController, +) async { + final exitCompletion = exitCodeFuture.then( + (exitCode) => + (kind: _ProfiledProcessCompletionKind.exited, exitCode: exitCode), + ); + final exitPause = sessionController.exitPauseReached.then( + (_) => (kind: _ProfiledProcessCompletionKind.pausedAtExit, exitCode: null), + ); + + while (true) { + final completion = + await Future.any< + ({int? exitCode, _ProfiledProcessCompletionKind? kind}) + >([ + exitCompletion, + exitPause, + Future.delayed( + const Duration(milliseconds: 50), + () => (kind: null, exitCode: null), + ), + ]); + + final kind = completion.kind; + if (kind != null) { + return (kind: kind, exitCode: completion.exitCode); + } + + await sessionController.recordCurrentlyPausedExitIsolates(); + if (sessionController.hasExitPauseReached) { + return ( + kind: _ProfiledProcessCompletionKind.pausedAtExit, + exitCode: null, + ); + } + } +} + +Future _resumeExitPausedProcess({ + required Future exitCodeFuture, + required Process process, + required ProfileSessionController sessionController, +}) async { + for (var attempt = 0; attempt < 5; attempt++) { + await sessionController.resumePausedExitIsolates(); + try { + return await exitCodeFuture.timeout(const Duration(seconds: 2)); + } on TimeoutException { + // Another isolate may have reached its exit pause after the previous + // resume call. Loop and resume any newly observed exit pauses. + } + } + + sessionController.addWarning( + 'The target process remained paused after final profile capture; ' + 'terminating it.', + ); + process.kill(); + return exitCodeFuture; +} + String _resolveWorkingDirectory(String? workingDirectory) { return path.normalize( path.absolute(workingDirectory ?? Directory.current.path), diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart b/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart index a22308f..2e3b27d 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart @@ -33,12 +33,13 @@ final class CommandLaunchPlan { /// Launches the target command with profiler session wiring applied. Future launchProfiledProcess({ required ProfileRunRequest request, + required List command, required String sessionId, required String dtdUri, required String workingDirectory, }) async { final launchPlan = instrumentedCommandLaunchPlan( - request.command, + command, dtdUri: dtdUri, sessionId: sessionId, ); @@ -103,6 +104,29 @@ Future launchProfiledProcess({ ); } +/// Returns the Dart or Flutter command shape used by the profiler. +/// +/// A bare Dart file path is treated as shorthand for `dart run `. +List normalizeProfileCommand(List command) { + if (command.isEmpty) { + return command; + } + + if (isBareDartFileCommand(command)) { + return ['dart', 'run', ...command]; + } + + return command; +} + +/// Returns whether [command] starts with a Dart file path. +bool isBareDartFileCommand(List command) { + if (command.isEmpty) { + return false; + } + return path.extension(command.first).toLowerCase() == '.dart'; +} + /// Validates that [command] is a supported Dart or Flutter launch shape. void validateProfileCommand(List command) { if (command.isEmpty) { @@ -170,7 +194,7 @@ ProfileCommandKind profileCommandKind(List command) { 'dart' => ProfileCommandKind.dart, 'flutter' => ProfileCommandKind.flutter, _ => throw ArgumentError( - 'Only Dart and Flutter VM commands are supported. Expected the first argument to be "dart" or "flutter".', + 'Only Dart and Flutter VM commands are supported. Expected the first argument to be "dart", "flutter", or a Dart file path.', ), }; } @@ -202,7 +226,7 @@ CommandLaunchPlan instrumentedCommandLaunchPlan( : command.first, arguments: [ '--observe=0', - '--pause-isolates-on-exit=false', + '--pause-isolates-on-exit', ...command.skip(1), ], ), diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart index 016ae54..25fcaa4 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart @@ -74,13 +74,31 @@ final class ProfileSessionController { Future attachToVmService( Uri serviceUri, { bool clearCpuSamples = false, + bool monitorExitPause = false, }) { return vmHookup.attachToVmService( serviceUri, clearCpuSamples: clearCpuSamples, + monitorExitPause: monitorExitPause, ); } + /// Future that completes when a Dart isolate pauses immediately before exit. + Future get exitPauseReached => vmHookup.exitPauseReached; + + /// Whether a Dart isolate has paused immediately before exit. + bool get hasExitPauseReached => vmHookup.hasExitPauseReached; + + /// Checks currently visible isolates for an exit pause. + Future recordCurrentlyPausedExitIsolates() { + return vmHookup.recordCurrentlyPausedExitIsolates(); + } + + /// Resumes isolates that were held at exit for final profiling capture. + Future resumePausedExitIsolates() { + return vmHookup.resumePausedExitIsolates(); + } + /// Handles the DTD session-info request. Future> handleGetSessionInfo(Parameters params) { return regionRpc.handleGetSessionInfo(params); diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart index 3ff9117..79122c9 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:json_rpc_2/json_rpc_2.dart'; import 'package:vm_service/utils.dart'; +import 'package:vm_service/vm_service.dart'; import 'package:vm_service/vm_service_io.dart'; import 'profile_session_context.dart'; @@ -16,16 +17,25 @@ final class ProfileSessionVmHookup { final ProfileSessionContext context; final ProfileSessionSnapshotCapture snapshotCapture; + final _pausedExitIsolateIds = {}; + final _exitPauseReached = Completer(); + + StreamSubscription? _debugSubscription; /// Attaches this session to [serviceUri] and starts whole-session capture. Future attachToVmService( Uri serviceUri, { bool clearCpuSamples = false, + bool monitorExitPause = false, }) async { final wsUri = convertToWebSocketUrl(serviceProtocolUrl: serviceUri); context.vmService = await vmServiceConnectUri(wsUri.toString()); context.vmServiceUri = serviceUri.toString(); + if (monitorExitPause) { + await startExitPauseMonitor(); + } + try { await context.vmService!.setFlag('profiler', 'true'); } catch (error) { @@ -43,6 +53,102 @@ final class ProfileSessionVmHookup { } } + /// Starts tracking Dart isolates that pause immediately before exit. + Future startExitPauseMonitor() async { + final vmService = context.vmService; + if (vmService == null || _debugSubscription != null) { + return; + } + + _debugSubscription = vmService.onDebugEvent.listen(handleDebugEvent); + try { + await vmService.streamListen(EventStreams.kDebug); + await recordCurrentlyPausedExitIsolates(); + } catch (error) { + await _debugSubscription?.cancel(); + _debugSubscription = null; + context.warnings.add( + 'Failed to monitor Dart isolate exit pauses: $error', + ); + } + } + + /// Future that completes once any app isolate pauses at exit. + Future get exitPauseReached => _exitPauseReached.future; + + /// Whether any app isolate has paused at exit. + bool get hasExitPauseReached => _exitPauseReached.isCompleted; + + /// Records a debug stream event relevant to Dart launch finalization. + void handleDebugEvent(Event event) { + if (event.kind != EventKind.kPauseExit) { + return; + } + final isolateId = event.isolate?.id; + if (isolateId == null) { + return; + } + _pausedExitIsolateIds.add(isolateId); + if (!_exitPauseReached.isCompleted) { + _exitPauseReached.complete(); + } + } + + /// Checks whether any currently visible isolate is already paused at exit. + Future recordCurrentlyPausedExitIsolates() async { + final vmService = context.vmService; + if (vmService == null) { + return; + } + + try { + final vm = await vmService.getVM(); + await Future.wait([ + for (final isolateRef in vm.isolates ?? const []) + if (!(isolateRef.isSystemIsolate ?? false) && isolateRef.id != null) + () async { + try { + final isolate = await vmService.getIsolate(isolateRef.id!); + if (isolate.pauseEvent?.kind == EventKind.kPauseExit) { + _pausedExitIsolateIds.add(isolateRef.id!); + } + } catch (_) { + // The isolate can disappear while the VM is shutting down. + } + }(), + ]); + if (_pausedExitIsolateIds.isNotEmpty && !_exitPauseReached.isCompleted) { + _exitPauseReached.complete(); + } + } catch (_) { + // The normal process-exit path will handle disconnected services. + } + } + + /// Resumes isolates paused at exit so the target process can terminate. + Future resumePausedExitIsolates() async { + await recordCurrentlyPausedExitIsolates(); + final vmService = context.vmService; + if (vmService == null || _pausedExitIsolateIds.isEmpty) { + return 0; + } + + final isolateIds = _pausedExitIsolateIds.toList(growable: false); + _pausedExitIsolateIds.clear(); + var resumedCount = 0; + for (final isolateId in isolateIds) { + try { + await vmService.resume(isolateId); + resumedCount++; + } catch (error) { + context.warnings.add( + 'Failed to resume exit-paused isolate "$isolateId": $error', + ); + } + } + return resumedCount; + } + /// Waits until this session is attached to a VM service. Future waitForVmService() { return context.vmServiceReady.future.timeout( @@ -68,6 +174,7 @@ final class ProfileSessionVmHookup { /// Disposes VM resources associated with this session. Future dispose() async { context.overallProfilePoller?.cancel(); + await _debugSubscription?.cancel(); await context.vmService?.dispose(); } } diff --git a/packages/devtools_profiler_core/pubspec.yaml b/packages/devtools_profiler_core/pubspec.yaml index e822dea..0f5dbd5 100644 --- a/packages/devtools_profiler_core/pubspec.yaml +++ b/packages/devtools_profiler_core/pubspec.yaml @@ -2,7 +2,7 @@ name: devtools_profiler_core description: Pure-Dart profiling backend for DevTools-inspired Dart and Flutter CLI and MCP workflows. -version: 0.2.0 +version: 0.2.1-wip environment: sdk: '>=3.10.0 <4.0.0' diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart new file mode 100644 index 0000000..cc5d722 --- /dev/null +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart @@ -0,0 +1,12 @@ +void main() { + final stopwatch = Stopwatch()..start(); + var state = 1; + while (stopwatch.elapsed < const Duration(milliseconds: 200)) { + for (var i = 0; i < 10000; i++) { + state = ((state * 1664525) + i) & 0x7fffffff; + } + } + if (state == -1) { + throw StateError('unreachable'); + } +} diff --git a/packages/devtools_profiler_core/test/profile_runner_test.dart b/packages/devtools_profiler_core/test/profile_runner_test.dart index 1a5c9e8..6ac3466 100644 --- a/packages/devtools_profiler_core/test/profile_runner_test.dart +++ b/packages/devtools_profiler_core/test/profile_runner_test.dart @@ -184,6 +184,35 @@ sleep 5 expect(session.regions, hasLength(1)); }); + test('profiles a bare Dart file before a fast process exits', () async { + final runner = ProfileRunner(); + final artifactRoot = await Directory.systemTemp.createTemp( + 'devtools_profiler_core_quick_exit.', + ); + addTearDown(() => artifactRoot.delete(recursive: true)); + + final result = await runner.run( + ProfileRunRequest( + command: const ['bin/quick_exit.dart'], + artifactDirectory: path.join(artifactRoot.path, 'session'), + workingDirectory: fixtureDirectory.path, + ), + ); + + expect(result.command, ['dart', 'run', 'bin/quick_exit.dart']); + expect(result.exitCode, 0); + expect(result.overallProfile, isNotNull); + expect(result.overallProfile!.succeeded, isTrue); + expect(result.overallProfile!.sampleCount, greaterThan(0)); + expect(result.overallProfile!.rawProfilePath, isNotNull); + expect( + result.warnings.where( + (warning) => warning.contains('Service connection disposed'), + ), + isEmpty, + ); + }); + test('attaches to an existing VM service without killing it', () async { final runner = ProfileRunner(); final tempDirectory = await Directory.systemTemp.createTemp( From d13baa26a8cc90fd94732d907279dbafe0f177c3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 28 Apr 2026 13:52:41 -0500 Subject: [PATCH 2/6] fix(core): wait for all Dart isolates at exit --- .../lib/src/capture/profile_runner.dart | 4 +-- .../runner/profile_session_controller.dart | 10 +++--- .../runner/profile_session_vm_hookup.dart | 33 ++++++++++++------- .../bin/quick_worker_isolate.dart | 25 ++++++++++++++ .../test/profile_runner_test.dart | 23 +++++++++++++ 5 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart index a3790ba..39d0fb9 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -296,7 +296,7 @@ _waitForDartProcessCompletion( (exitCode) => (kind: _ProfiledProcessCompletionKind.exited, exitCode: exitCode), ); - final exitPause = sessionController.exitPauseReached.then( + final exitPause = sessionController.allAppIsolatesPausedAtExit.then( (_) => (kind: _ProfiledProcessCompletionKind.pausedAtExit, exitCode: null), ); @@ -319,7 +319,7 @@ _waitForDartProcessCompletion( } await sessionController.recordCurrentlyPausedExitIsolates(); - if (sessionController.hasExitPauseReached) { + if (sessionController.haveAllAppIsolatesPausedAtExit) { return ( kind: _ProfiledProcessCompletionKind.pausedAtExit, exitCode: null, diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart index 25fcaa4..40bdeee 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart @@ -83,11 +83,13 @@ final class ProfileSessionController { ); } - /// Future that completes when a Dart isolate pauses immediately before exit. - Future get exitPauseReached => vmHookup.exitPauseReached; + /// Future that completes when all app isolates pause immediately before exit. + Future get allAppIsolatesPausedAtExit => + vmHookup.allAppIsolatesPausedAtExit; - /// Whether a Dart isolate has paused immediately before exit. - bool get hasExitPauseReached => vmHookup.hasExitPauseReached; + /// Whether all app isolates have paused immediately before exit. + bool get haveAllAppIsolatesPausedAtExit => + vmHookup.haveAllAppIsolatesPausedAtExit; /// Checks currently visible isolates for an exit pause. Future recordCurrentlyPausedExitIsolates() { diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart index 79122c9..7c3860b 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart @@ -18,7 +18,7 @@ final class ProfileSessionVmHookup { final ProfileSessionContext context; final ProfileSessionSnapshotCapture snapshotCapture; final _pausedExitIsolateIds = {}; - final _exitPauseReached = Completer(); + final _allAppIsolatesPausedAtExit = Completer(); StreamSubscription? _debugSubscription; @@ -73,11 +73,13 @@ final class ProfileSessionVmHookup { } } - /// Future that completes once any app isolate pauses at exit. - Future get exitPauseReached => _exitPauseReached.future; + /// Future that completes once every visible app isolate has paused at exit. + Future get allAppIsolatesPausedAtExit => + _allAppIsolatesPausedAtExit.future; - /// Whether any app isolate has paused at exit. - bool get hasExitPauseReached => _exitPauseReached.isCompleted; + /// Whether every visible app isolate has paused at exit. + bool get haveAllAppIsolatesPausedAtExit => + _allAppIsolatesPausedAtExit.isCompleted; /// Records a debug stream event relevant to Dart launch finalization. void handleDebugEvent(Event event) { @@ -89,12 +91,10 @@ final class ProfileSessionVmHookup { return; } _pausedExitIsolateIds.add(isolateId); - if (!_exitPauseReached.isCompleted) { - _exitPauseReached.complete(); - } + unawaited(recordCurrentlyPausedExitIsolates()); } - /// Checks whether any currently visible isolate is already paused at exit. + /// Checks whether every currently visible app isolate is paused at exit. Future recordCurrentlyPausedExitIsolates() async { final vmService = context.vmService; if (vmService == null) { @@ -103,22 +103,31 @@ final class ProfileSessionVmHookup { try { final vm = await vmService.getVM(); + final liveAppIsolateIds = {}; + final pausedExitIsolateIds = {}; await Future.wait([ for (final isolateRef in vm.isolates ?? const []) if (!(isolateRef.isSystemIsolate ?? false) && isolateRef.id != null) () async { + liveAppIsolateIds.add(isolateRef.id!); try { final isolate = await vmService.getIsolate(isolateRef.id!); if (isolate.pauseEvent?.kind == EventKind.kPauseExit) { - _pausedExitIsolateIds.add(isolateRef.id!); + pausedExitIsolateIds.add(isolateRef.id!); } } catch (_) { // The isolate can disappear while the VM is shutting down. + liveAppIsolateIds.remove(isolateRef.id!); } }(), ]); - if (_pausedExitIsolateIds.isNotEmpty && !_exitPauseReached.isCompleted) { - _exitPauseReached.complete(); + _pausedExitIsolateIds + ..clear() + ..addAll(pausedExitIsolateIds); + if (liveAppIsolateIds.isNotEmpty && + liveAppIsolateIds.length == pausedExitIsolateIds.length && + !_allAppIsolatesPausedAtExit.isCompleted) { + _allAppIsolatesPausedAtExit.complete(); } } catch (_) { // The normal process-exit path will handle disconnected services. diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart new file mode 100644 index 0000000..adc581a --- /dev/null +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import 'dart:isolate'; + +Future main() async { + final workerDone = ReceivePort(); + await Isolate.spawn(_worker, workerDone.sendPort); + await workerDone.first; + workerDone.close(); + + final stopwatch = Stopwatch()..start(); + var state = 1; + while (stopwatch.elapsed < const Duration(milliseconds: 500)) { + for (var i = 0; i < 10000; i++) { + state = ((state * 1664525) + i) & 0x7fffffff; + } + await Future.delayed(Duration.zero); + } + if (state == -1) { + throw StateError('unreachable'); + } +} + +void _worker(SendPort sendPort) { + sendPort.send(null); +} diff --git a/packages/devtools_profiler_core/test/profile_runner_test.dart b/packages/devtools_profiler_core/test/profile_runner_test.dart index 6ac3466..f46bd4e 100644 --- a/packages/devtools_profiler_core/test/profile_runner_test.dart +++ b/packages/devtools_profiler_core/test/profile_runner_test.dart @@ -213,6 +213,29 @@ sleep 5 ); }); + test('waits for worker isolates before finalizing a Dart run', () async { + final runner = ProfileRunner(); + final artifactRoot = await Directory.systemTemp.createTemp( + 'devtools_profiler_core_quick_worker.', + ); + addTearDown(() => artifactRoot.delete(recursive: true)); + + final result = await runner.run( + ProfileRunRequest( + command: const ['dart', 'run', 'bin/quick_worker_isolate.dart'], + artifactDirectory: path.join(artifactRoot.path, 'session'), + workingDirectory: fixtureDirectory.path, + ), + ); + + expect(result.exitCode, 0); + expect(result.overallProfile, isNotNull); + expect(result.overallProfile!.succeeded, isTrue); + expect(result.overallProfile!.durationMicros, greaterThan(300000)); + expect(result.overallProfile!.sampleCount, greaterThan(0)); + expect(result.overallProfile!.isolateIds.length, greaterThan(1)); + }); + test('attaches to an existing VM service without killing it', () async { final runner = ProfileRunner(); final tempDirectory = await Directory.systemTemp.createTemp( From cea92398e0cffef1d451af82a43292c231b46939 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 29 Apr 2026 07:03:37 -0500 Subject: [PATCH 3/6] fix(core): address exit-pause review feedback --- README.md | 4 +- .../lib/src/capture/profile_runner.dart | 58 +++++++++++++++++-- .../runner/profile_session_controller.dart | 5 +- .../runner/profile_session_vm_hookup.dart | 36 ++++++++---- .../fixtures/profiled_app/bin/quick_exit.dart | 4 +- .../bin/quick_worker_isolate.dart | 4 +- .../test/profile_runner_test.dart | 2 +- 7 files changed, 87 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 64ca105..cb7e789 100644 --- a/README.md +++ b/README.md @@ -721,7 +721,9 @@ models. It does not depend on `packages/devtools_app`, - Attach mode captures a fixed whole-session VM-service window from an existing process, but explicit region markers normally require launch mode. -- The launched command must start with `dart` or `flutter`. +- Launch mode supports bare Dart files, Dart VM commands, and supported Flutter + commands. Put profiler options before the target, and use `--` when the + target command has its own options. - `dart compile ...` targets and Flutter release/AOT targets are not supported. - Flutter support is limited to VM-service targets from `flutter run` and `flutter test`; browser/web profiling is not supported. diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart index 39d0fb9..63eb4c0 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -285,8 +285,23 @@ class ProfileRunner { } } -enum _ProfiledProcessCompletionKind { exited, pausedAtExit } +enum _ProfiledProcessCompletionKind { + exited, + exitPauseUnavailable, + pausedAtExit, +} +/// Waits for a Dart process to exit or pause every app isolate at exit. +/// +/// Dart launches use `--pause-isolates-on-exit` so a short script can be +/// captured before the VM service disappears. This helper races the process +/// [exitCodeFuture] with [ProfileSessionController.exitPauseSignal]. It also +/// polls [ProfileSessionController.recordCurrentlyPausedExitIsolates] because +/// debug stream events can be missed during shutdown. A returned +/// [_ProfiledProcessCompletionKind.exited] means the process produced an exit +/// code normally; [_ProfiledProcessCompletionKind.pausedAtExit] means +/// [ProfileSessionController.haveAllAppIsolatesPausedAtExit] is true and the +/// caller should capture final artifacts before resuming isolates. Future<({int? exitCode, _ProfiledProcessCompletionKind kind})> _waitForDartProcessCompletion( Future exitCodeFuture, @@ -296,9 +311,15 @@ _waitForDartProcessCompletion( (exitCode) => (kind: _ProfiledProcessCompletionKind.exited, exitCode: exitCode), ); - final exitPause = sessionController.allAppIsolatesPausedAtExit.then( - (_) => (kind: _ProfiledProcessCompletionKind.pausedAtExit, exitCode: null), + final exitPause = sessionController.exitPauseSignal.then( + (allPaused) => ( + kind: allPaused + ? _ProfiledProcessCompletionKind.pausedAtExit + : _ProfiledProcessCompletionKind.exitPauseUnavailable, + exitCode: null, + ), ); + var listenForExitPauseSignal = true; while (true) { final completion = @@ -306,7 +327,7 @@ _waitForDartProcessCompletion( ({int? exitCode, _ProfiledProcessCompletionKind? kind}) >([ exitCompletion, - exitPause, + if (listenForExitPauseSignal) exitPause, Future.delayed( const Duration(milliseconds: 50), () => (kind: null, exitCode: null), @@ -315,6 +336,10 @@ _waitForDartProcessCompletion( final kind = completion.kind; if (kind != null) { + if (kind == _ProfiledProcessCompletionKind.exitPauseUnavailable) { + listenForExitPauseSignal = false; + continue; + } return (kind: kind, exitCode: completion.exitCode); } @@ -328,6 +353,15 @@ _waitForDartProcessCompletion( } } +/// Resumes exit-paused Dart isolates and waits for process termination. +/// +/// The VM can report additional isolates paused at exit after an earlier resume +/// call, so this retries [ProfileSessionController.resumePausedExitIsolates] +/// up to five times. Each attempt gives [exitCodeFuture] two seconds to +/// complete. If the target still does not exit, this records a warning with +/// [ProfileSessionController.addWarning], tries to kill the process, waits with +/// a bounded timeout, and finally returns a forced non-zero exit code instead +/// of awaiting an unbounded process future. Future _resumeExitPausedProcess({ required Future exitCodeFuture, required Process process, @@ -347,8 +381,20 @@ Future _resumeExitPausedProcess({ 'The target process remained paused after final profile capture; ' 'terminating it.', ); - process.kill(); - return exitCodeFuture; + if (!process.kill()) { + sessionController.addWarning( + 'Failed to terminate the target process after exit-paused capture.', + ); + } + try { + return await exitCodeFuture.timeout(const Duration(seconds: 2)); + } on TimeoutException { + sessionController.addWarning( + 'Timed out waiting for the target process to exit after termination; ' + 'returning a forced non-zero exit code.', + ); + return 1; + } } String _resolveWorkingDirectory(String? workingDirectory) { diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart index 40bdeee..378ff16 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_controller.dart @@ -83,9 +83,8 @@ final class ProfileSessionController { ); } - /// Future that completes when all app isolates pause immediately before exit. - Future get allAppIsolatesPausedAtExit => - vmHookup.allAppIsolatesPausedAtExit; + /// Future that completes when exit-pause monitoring reaches a terminal state. + Future get exitPauseSignal => vmHookup.exitPauseSignal; /// Whether all app isolates have paused immediately before exit. bool get haveAllAppIsolatesPausedAtExit => diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart index 7c3860b..d0b532a 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_vm_hookup.dart @@ -18,9 +18,10 @@ final class ProfileSessionVmHookup { final ProfileSessionContext context; final ProfileSessionSnapshotCapture snapshotCapture; final _pausedExitIsolateIds = {}; - final _allAppIsolatesPausedAtExit = Completer(); + final _exitPauseSignal = Completer(); StreamSubscription? _debugSubscription; + bool _haveAllAppIsolatesPausedAtExit = false; /// Attaches this session to [serviceUri] and starts whole-session capture. Future attachToVmService( @@ -70,16 +71,19 @@ final class ProfileSessionVmHookup { context.warnings.add( 'Failed to monitor Dart isolate exit pauses: $error', ); + completeExitPauseSignal(allPaused: false); } } - /// Future that completes once every visible app isolate has paused at exit. - Future get allAppIsolatesPausedAtExit => - _allAppIsolatesPausedAtExit.future; + /// A signal that exit-pause monitoring reached a terminal state. + /// + /// The future completes with `true` when every visible app isolate is paused + /// at exit. It completes with `false` when monitoring cannot make progress, + /// such as when the debug stream or VM isolate list is unavailable. + Future get exitPauseSignal => _exitPauseSignal.future; /// Whether every visible app isolate has paused at exit. - bool get haveAllAppIsolatesPausedAtExit => - _allAppIsolatesPausedAtExit.isCompleted; + bool get haveAllAppIsolatesPausedAtExit => _haveAllAppIsolatesPausedAtExit; /// Records a debug stream event relevant to Dart launch finalization. void handleDebugEvent(Event event) { @@ -124,13 +128,23 @@ final class ProfileSessionVmHookup { _pausedExitIsolateIds ..clear() ..addAll(pausedExitIsolateIds); - if (liveAppIsolateIds.isNotEmpty && - liveAppIsolateIds.length == pausedExitIsolateIds.length && - !_allAppIsolatesPausedAtExit.isCompleted) { - _allAppIsolatesPausedAtExit.complete(); + if (liveAppIsolateIds.isEmpty) { + completeExitPauseSignal(allPaused: false); + return; + } + if (liveAppIsolateIds.length == pausedExitIsolateIds.length) { + completeExitPauseSignal(allPaused: true); } } catch (_) { - // The normal process-exit path will handle disconnected services. + completeExitPauseSignal(allPaused: false); + } + } + + /// Records an exit-pause coordination result without losing later polls. + void completeExitPauseSignal({required bool allPaused}) { + _haveAllAppIsolatesPausedAtExit |= allPaused; + if (!_exitPauseSignal.isCompleted) { + _exitPauseSignal.complete(allPaused); } } diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart index cc5d722..e6d71e7 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_exit.dart @@ -2,8 +2,8 @@ void main() { final stopwatch = Stopwatch()..start(); var state = 1; while (stopwatch.elapsed < const Duration(milliseconds: 200)) { - for (var i = 0; i < 10000; i++) { - state = ((state * 1664525) + i) & 0x7fffffff; + for (var i = 0; i < 10_000; i++) { + state = ((state * 1_664_525) + i) & 0x7fff_ffff; } } if (state == -1) { diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart index adc581a..ae70dc9 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart @@ -10,8 +10,8 @@ Future main() async { final stopwatch = Stopwatch()..start(); var state = 1; while (stopwatch.elapsed < const Duration(milliseconds: 500)) { - for (var i = 0; i < 10000; i++) { - state = ((state * 1664525) + i) & 0x7fffffff; + for (var i = 0; i < 10_000; i++) { + state = ((state * 1_664_525) + i) & 0x7fff_ffff; } await Future.delayed(Duration.zero); } diff --git a/packages/devtools_profiler_core/test/profile_runner_test.dart b/packages/devtools_profiler_core/test/profile_runner_test.dart index f46bd4e..bec0cff 100644 --- a/packages/devtools_profiler_core/test/profile_runner_test.dart +++ b/packages/devtools_profiler_core/test/profile_runner_test.dart @@ -231,7 +231,7 @@ sleep 5 expect(result.exitCode, 0); expect(result.overallProfile, isNotNull); expect(result.overallProfile!.succeeded, isTrue); - expect(result.overallProfile!.durationMicros, greaterThan(300000)); + expect(result.overallProfile!.durationMicros, greaterThan(300_000)); expect(result.overallProfile!.sampleCount, greaterThan(0)); expect(result.overallProfile!.isolateIds.length, greaterThan(1)); }); From e79d8335b77bfc409e4ad39358cecf207c2e239e Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 29 Apr 2026 08:00:58 -0500 Subject: [PATCH 4/6] docs: update profiler local skill guidance --- skills/devtools-profiler-local/SKILL.md | 30 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/skills/devtools-profiler-local/SKILL.md b/skills/devtools-profiler-local/SKILL.md index 365e1fd..e7fe412 100644 --- a/skills/devtools-profiler-local/SKILL.md +++ b/skills/devtools-profiler-local/SKILL.md @@ -17,7 +17,8 @@ local capture with useful output. Start by identifying the user's target: -- Dart script: use `run` with `dart run ...`. +- Dart script: use `run path/to/file.dart` for a bare file, or `run -- dart + run ...` when the target has its own arguments. - Flutter app or test: use `run` with `flutter run` or `flutter test`. - Already-running VM service: use `attach`. - Application code can be edited: offer region markers. @@ -55,7 +56,25 @@ command, ask the user to use their direct Dart SDK binary for `pub get`, ## Profile A Dart Program -Use this shape for a first Dart capture: +Use the bare-file shorthand for a first Dart capture when the target is a +single script: + +```bash +devtools-profiler run \ + --hide-sdk \ + --hide-runtime-helpers \ + --call-tree \ + --method-table \ + --cwd path/to/app \ + bin/main.dart +``` + +Bare Dart files are expanded to `dart run `. The profiler holds Dart +launches at isolate exit long enough to capture final CPU and memory snapshots, +so short scripts can still produce a whole-session profile. + +Use the full command shape when the target command has its own arguments or +needs a Dart subcommand: ```bash devtools-profiler run \ @@ -69,8 +88,8 @@ devtools-profiler run \ -- dart run bin/main.dart ``` -Everything after `--` is the target command. Keep `--cwd` pointed at the -target package or app directory. +Everything after `--` is the target command. Keep profiler options before the +target, and keep `--cwd` pointed at the target package or app directory. ## Profile A Flutter Target @@ -237,6 +256,9 @@ sessions when comparing region-scoped runs. - If output is dominated by SDK frames, add `--hide-sdk`. - If output is dominated by profiler transport frames, add `--hide-runtime-helpers`. +- If a Dart script has its own flags, use `--` before the target command, for + example `devtools-profiler run --cwd path/to/app -- dart run bin/main.dart + --input data.json`. - If locations are too compact, add `--full-locations`. - If an agent needs complete data, set limits to `0`, such as `--tree-depth 0`, `--tree-children 0`, `--method-limit 0`, From a442cbad7fe8254a35f6fd4ae23201dae83a8699 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 29 Apr 2026 08:05:29 -0500 Subject: [PATCH 5/6] fix(core): address exit cleanup review --- .../lib/src/capture/profile_runner.dart | 26 +++++++++++----- .../bin/quick_worker_isolate.dart | 31 +++++++++++++++---- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart index 63eb4c0..2efc504 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -129,17 +129,18 @@ class ProfileRunner { int exitCode; if (completion.kind == _ProfiledProcessCompletionKind.pausedAtExit) { await sessionController.handleProcessExit(); - exitCode = await _resumeExitPausedProcess( + final resumedProcess = await _resumeExitPausedProcess( exitCodeFuture: exitCodeFuture, process: process, sessionController: sessionController, ); + exitCode = resumedProcess.exitCode; + processExited = resumedProcess.processExited; } else { exitCode = completion.exitCode!; processExited = true; await sessionController.handleProcessExit(); } - processExited = true; final result = sessionController.buildResult( artifactDirectory: artifactDirectory.path, @@ -156,7 +157,13 @@ class ProfileRunner { runDurationTimer?.cancel(); if (process != null && !processExited) { process.kill(); - await process.exitCode; + try { + await process.exitCode.timeout(const Duration(seconds: 2)); + } on TimeoutException { + sessionController.addWarning( + 'Timed out waiting for target process cleanup after profiling.', + ); + } } await sessionController.dispose(); await dtdSession.dispose(); @@ -361,8 +368,9 @@ _waitForDartProcessCompletion( /// complete. If the target still does not exit, this records a warning with /// [ProfileSessionController.addWarning], tries to kill the process, waits with /// a bounded timeout, and finally returns a forced non-zero exit code instead -/// of awaiting an unbounded process future. -Future _resumeExitPausedProcess({ +/// of awaiting an unbounded process future. The result marks whether the exit +/// code came from an observed process exit or from that synthetic fallback. +Future<({int exitCode, bool processExited})> _resumeExitPausedProcess({ required Future exitCodeFuture, required Process process, required ProfileSessionController sessionController, @@ -370,7 +378,8 @@ Future _resumeExitPausedProcess({ for (var attempt = 0; attempt < 5; attempt++) { await sessionController.resumePausedExitIsolates(); try { - return await exitCodeFuture.timeout(const Duration(seconds: 2)); + final exitCode = await exitCodeFuture.timeout(const Duration(seconds: 2)); + return (exitCode: exitCode, processExited: true); } on TimeoutException { // Another isolate may have reached its exit pause after the previous // resume call. Loop and resume any newly observed exit pauses. @@ -387,13 +396,14 @@ Future _resumeExitPausedProcess({ ); } try { - return await exitCodeFuture.timeout(const Duration(seconds: 2)); + final exitCode = await exitCodeFuture.timeout(const Duration(seconds: 2)); + return (exitCode: exitCode, processExited: true); } on TimeoutException { sessionController.addWarning( 'Timed out waiting for the target process to exit after termination; ' 'returning a forced non-zero exit code.', ); - return 1; + return (exitCode: 1, processExited: false); } } diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart index ae70dc9..a4d5164 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/quick_worker_isolate.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'dart:isolate'; Future main() async { - final workerDone = ReceivePort(); - await Isolate.spawn(_worker, workerDone.sendPort); - await workerDone.first; - workerDone.close(); + final workerReady = ReceivePort(); + await Isolate.spawn(_worker, workerReady.sendPort); + final workerControlPort = await workerReady.first as SendPort; + workerReady.close(); final stopwatch = Stopwatch()..start(); var state = 1; @@ -18,8 +18,27 @@ Future main() async { if (state == -1) { throw StateError('unreachable'); } + workerControlPort.send(null); } -void _worker(SendPort sendPort) { - sendPort.send(null); +Future _worker(SendPort readyPort) async { + final controlPort = ReceivePort(); + var shouldStop = false; + final controlSubscription = controlPort.listen((_) { + shouldStop = true; + }); + readyPort.send(controlPort.sendPort); + + var state = 1; + while (!shouldStop) { + for (var i = 0; i < 10_000; i++) { + state = ((state * 1_664_525) + i) & 0x7fff_ffff; + } + await Future.delayed(Duration.zero); + } + await controlSubscription.cancel(); + controlPort.close(); + if (state == -1) { + throw StateError('unreachable'); + } } From 85a6afc25bb85dd3bd21a36df5de2f4be2bdcdaa Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 30 Apr 2026 11:48:38 -0500 Subject: [PATCH 6/6] fix(core): back off exit-pause polling --- .../lib/src/capture/profile_runner.dart | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart index 2efc504..bf1fa4e 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -298,15 +298,18 @@ enum _ProfiledProcessCompletionKind { pausedAtExit, } +const _initialExitPausePollDelay = Duration(milliseconds: 50); +const _maxExitPausePollDelay = Duration(seconds: 3); + /// Waits for a Dart process to exit or pause every app isolate at exit. /// /// Dart launches use `--pause-isolates-on-exit` so a short script can be /// captured before the VM service disappears. This helper races the process /// [exitCodeFuture] with [ProfileSessionController.exitPauseSignal]. It also -/// polls [ProfileSessionController.recordCurrentlyPausedExitIsolates] because -/// debug stream events can be missed during shutdown. A returned -/// [_ProfiledProcessCompletionKind.exited] means the process produced an exit -/// code normally; [_ProfiledProcessCompletionKind.pausedAtExit] means +/// polls [ProfileSessionController.recordCurrentlyPausedExitIsolates] with +/// backoff because debug stream events can be missed during shutdown. A +/// returned [_ProfiledProcessCompletionKind.exited] means the process produced +/// an exit code normally; [_ProfiledProcessCompletionKind.pausedAtExit] means /// [ProfileSessionController.haveAllAppIsolatesPausedAtExit] is true and the /// caller should capture final artifacts before resuming isolates. Future<({int? exitCode, _ProfiledProcessCompletionKind kind})> @@ -327,6 +330,7 @@ _waitForDartProcessCompletion( ), ); var listenForExitPauseSignal = true; + var pollDelay = _initialExitPausePollDelay; while (true) { final completion = @@ -335,16 +339,14 @@ _waitForDartProcessCompletion( >([ exitCompletion, if (listenForExitPauseSignal) exitPause, - Future.delayed( - const Duration(milliseconds: 50), - () => (kind: null, exitCode: null), - ), + Future.delayed(pollDelay, () => (kind: null, exitCode: null)), ]); final kind = completion.kind; if (kind != null) { if (kind == _ProfiledProcessCompletionKind.exitPauseUnavailable) { listenForExitPauseSignal = false; + pollDelay = _maxExitPausePollDelay; continue; } return (kind: kind, exitCode: completion.exitCode); @@ -357,9 +359,17 @@ _waitForDartProcessCompletion( exitCode: null, ); } + pollDelay = _nextExitPausePollDelay(pollDelay); } } +Duration _nextExitPausePollDelay(Duration currentDelay) { + final nextDelay = currentDelay * 2; + return nextDelay > _maxExitPausePollDelay + ? _maxExitPausePollDelay + : nextDelay; +} + /// Resumes exit-paused Dart isolates and waits for process termination. /// /// The VM can report additional isolates paused at exit after an earlier resume