diff --git a/README.md b/README.md index 86f7774..3fceba8 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,9 @@ devtools-profiler summarize \ /path/to/session ``` +JSON responses include a `cliCommand` field for the command that can reproduce +the same analysis selection. + Important result sections: - `overallProfile`: the whole run from process start to process exit. @@ -289,6 +292,7 @@ Important result sections: need to find the caller chain that led there. - `methodTable`: DevTools-style method context with callers and callees. - `memory`: heap and allocation summary when memory capture was available. +- `classes`: memory class rows from `inspect-classes`. - `regressions` and `trends`: comparison output for reasoning across sessions. Filtering options keep the output readable: @@ -458,6 +462,20 @@ devtools-profiler inspect \ Inspection shows self cost, inclusive cost, callers, callees, and representative paths. +### Inspect Memory Classes + +```bash +devtools-profiler inspect-classes \ + --json \ + --class Cart \ + --min-live-bytes 1048576 \ + /path/to/session +``` + +`inspect-classes` re-reads the stored memory artifact and reports retained class +rows, live instances, and allocation deltas. Use `--limit 0` for an unlimited +class list. + ### Compare Two Runs ```bash @@ -532,6 +550,7 @@ Commands: - `compare ` compares two profiles or sessions. - `trends ...` analyzes a sequence of profiles or sessions. - `inspect ` inspects one method in one profile. +- `inspect-classes ` inspects memory classes in one profile. - `search-methods ` searches methods in one profile. - `compare-method ` compares one method across two profiles. - `mcp` starts the local stdio MCP server. @@ -554,6 +573,10 @@ Common presentation flags: - `--tree-depth ` controls call-tree depth. `0` means unlimited. - `--tree-children ` controls children per tree node. `0` means unlimited. - `--method-limit ` controls method rows and relations. `0` means unlimited. +- `--min-live-bytes ` filters memory class rows for `compare` and + `inspect-classes`. +- `--memory-class-limit ` controls compared memory class rows for `compare`. + `0` means unlimited. `run` options: @@ -579,6 +602,7 @@ Path arguments accepted by read/analyze commands: - a session directory - a region `summary.json` - a raw `cpu_profile.json` +- a raw `memory_profile.json` for memory-class inspection ## MCP For AI Agents @@ -605,7 +629,8 @@ Tools by workflow: `profile_get_session`, `profile_list_regions`, `profile_get_region`. - Read artifacts: `profile_summarize`, `profile_read_artifact`. - Explain and drill down: `profile_explain_hotspots`, - `profile_search_methods`, `profile_inspect_method`. + `profile_search_methods`, `profile_inspect_method`, + `profile_inspect_classes`. - Compare: `profile_compare`, `profile_compare_method`, `profile_find_regressions`, `profile_analyze_trends`. @@ -618,7 +643,8 @@ Useful agent pattern: 3. If regions exist, call `profile_explain_hotspots` for the hottest region. 4. Use `profile_search_methods` and `profile_inspect_method` for named functions mentioned by the explanation. -5. Use `profile_compare` or `profile_find_regressions` after a code change. +5. Use `profile_inspect_classes` when memory summaries show retained growth. +6. Use `profile_compare` or `profile_find_regressions` after a code change. Most read-only tools accept either direct paths or stored-session selectors: diff --git a/packages/devtools_profiler_cli/CHANGELOG.md b/packages/devtools_profiler_cli/CHANGELOG.md index 4999b13..ea181ae 100644 --- a/packages/devtools_profiler_cli/CHANGELOG.md +++ b/packages/devtools_profiler_cli/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.2.0-wip + +- Added `inspect-classes` and the `profile_inspect_classes` MCP tool for + inspecting memory class allocations from stored artifacts. +- Added comparison filters for memory class output, including minimum live bytes + and memory class count limits. +- Added `attach --skip-dtd` and the matching MCP option for whole-session attach + profiling when explicit region markers are unavailable. +- Improved CLI and JSON output by surfacing region preparation warnings, + baseline/current comparison warnings, and sample-count fallback warnings. +- Added warnings when active frame filters remove every CPU frame, plus + reproduction blocks and matching CLI commands in agent-facing JSON responses. +- Improved memory summary tables to show live bytes, live instances, new + instances, and allocation deltas without requiring external JSON tools. +- Improved package-filtered output for local checkout frames when used with a + backend that recognizes local package file paths. + ## 0.1.0 - Initial release of the terminal and MCP profiler frontend. diff --git a/packages/devtools_profiler_cli/README.md b/packages/devtools_profiler_cli/README.md index 7b263b0..b6ac9f9 100644 --- a/packages/devtools_profiler_cli/README.md +++ b/packages/devtools_profiler_cli/README.md @@ -135,6 +135,9 @@ A session can contain: If the target app has no marked regions, the CLI still captures the whole session. +JSON responses include a `cliCommand` field for the command that can reproduce +the same analysis selection. + ## Read Existing Artifacts Summarize a session: @@ -177,6 +180,19 @@ devtools-profiler inspect \ /path/to/session ``` +Inspect memory classes: + +```bash +devtools-profiler inspect-classes \ + --json \ + --class String \ + --min-live-bytes 1048576 \ + /path/to/session +``` + +Use `--limit 0` for an unlimited class list. The command can read a session +directory, a region `summary.json`, or a raw `memory_profile.json` artifact. + Compare two sessions: ```bash @@ -214,6 +230,10 @@ devtools-profiler trends \ duration. Examples: `15s`, `2m`, `500ms`. - `--vm-service-timeout ` controls startup wait time before the VM service is available. Examples: `3m`, `300s`. +- `--min-live-bytes ` filters memory class rows for `compare` and + `inspect-classes`. +- `--memory-class-limit ` controls compared memory class rows for `compare`. + `0` means unlimited. Commands that operate on one profile use `--profile-id overall` for the whole-session profile or a generated region id for a marked region. Region names @@ -250,6 +270,7 @@ Agent-facing tools include: - `profile_explain_hotspots` - `profile_search_methods` - `profile_inspect_method` +- `profile_inspect_classes` - `profile_compare` - `profile_compare_method` - `profile_find_regressions` diff --git a/packages/devtools_profiler_cli/lib/src/cli.dart b/packages/devtools_profiler_cli/lib/src/cli.dart index d28947e..318c40e 100644 --- a/packages/devtools_profiler_cli/lib/src/cli.dart +++ b/packages/devtools_profiler_cli/lib/src/cli.dart @@ -42,6 +42,7 @@ Future runCli( ..addCommand(InspectCommand(profiler)) ..addCommand(CompareMethodCommand(profiler)) ..addCommand(SearchMethodsCommand(profiler)) + ..addCommand(InspectClassesCommand(profiler)) ..addCommand(McpCommand(profiler)); try { diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/analysis_commands.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/analysis_commands.dart index 6cc5c08..462489e 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/analysis_commands.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/analysis_commands.dart @@ -18,6 +18,21 @@ class CompareCommand extends ProfilerCommand { ..addOption( 'current-profile-id', help: 'Profile id to select from the current session directory.', + ) + ..addOption( + 'min-live-bytes', + help: + 'Re-read raw memory artifacts and include only classes with at ' + 'least this many live bytes at the end of each capture window. ' + 'Useful for surfacing large retained classes missed by the stored ' + 'top-class list.', + ) + ..addOption( + 'memory-class-limit', + help: + 'Maximum memory classes to compare. Use 0 for unlimited. ' + 'When set, re-reads raw memory artifacts to expand beyond the ' + 'stored top-class list.', ); } @@ -27,6 +42,16 @@ class CompareCommand extends ProfilerCommand { @override String get description => 'Compare two session/profile artifacts.'; + @override + String formatUsage({bool includeDescription = true}) => usageWithExamples( + super.formatUsage(includeDescription: includeDescription), + const [ + 'devtools-profiler compare path/to/baseline path/to/current', + 'devtools-profiler compare --method-table path/to/baseline path/to/current', + 'devtools-profiler compare --min-live-bytes 524288 path/to/baseline path/to/current', + ], + ); + @override Future run() async { if (argResults!.rest.length != 2) { @@ -36,12 +61,25 @@ class CompareCommand extends ProfilerCommand { } final options = presentationOptions; + + final memoryClassLimitStr = argResults!['memory-class-limit'] as String?; + final memoryClassLimitSpecified = memoryClassLimitStr != null; + final comparison = await prepareProfileComparison( profileRunner, baselinePath: argResults!.rest.first, currentPath: argResults!.rest.last, baselineProfileId: argResults!['baseline-profile-id'] as String?, currentProfileId: argResults!['current-profile-id'] as String?, + minLiveBytes: parseNonNegativeInt( + argResults!['min-live-bytes'] as String?, + optionName: 'min-live-bytes', + ), + memoryClassLimit: parseLimit( + memoryClassLimitStr, + optionName: 'memory-class-limit', + ), + memoryClassLimitSpecified: memoryClassLimitSpecified, options: options, ); @@ -280,3 +318,80 @@ class SearchMethodsCommand extends ProfilerCommand { return successExitCode; } } + +/// Command that inspects memory class data in a stored profile artifact. +class InspectClassesCommand extends ProfilerCommand { + /// Creates an inspect-classes command. + InspectClassesCommand(super.profileRunner) { + argParser + ..addOption( + 'class', + help: + 'Filter to classes whose name contains this query (case-insensitive).', + ) + ..addOption( + 'min-live-bytes', + help: + 'Only include classes with at least this many live bytes at the ' + 'end of the capture window.', + ) + ..addOption( + 'limit', + defaultsTo: '$defaultMemoryClassLimit', + help: 'Maximum classes to show. Use 0 for unlimited.', + ); + } + + @override + String get name => 'inspect-classes'; + + @override + String get description => + 'Inspect memory class data in a stored session or region artifact.'; + + @override + String get invocation => + '${runner!.executableName} inspect-classes [options] '; + + @override + String formatUsage({bool includeDescription = true}) => usageWithExamples( + super.formatUsage(includeDescription: includeDescription), + const [ + 'devtools-profiler inspect-classes path/to/session', + 'devtools-profiler inspect-classes --class LoveColor path/to/session', + 'devtools-profiler inspect-classes --min-live-bytes 1048576 path/to/session', + ], + ); + + @override + Future run() async { + if (argResults!.rest.length != 1) { + usageException( + 'Inspect-classes requires exactly one session directory or ' + 'profile artifact path.', + ); + } + + final limitStr = argResults!['limit'] as String; + final limit = parseLimit(limitStr, optionName: 'limit'); + + final inspection = await prepareMemoryClassInspection( + profileRunner, + argResults!.rest.single, + classQuery: argResults!['class'] as String?, + minLiveBytes: parseNonNegativeInt( + argResults!['min-live-bytes'] as String?, + optionName: 'min-live-bytes', + ), + topClassCount: limit ?? 0, + ); + + if (printJson) { + writeJson(memoryClassInspectionJson(inspection)); + } else { + writeMemoryClassInspection(io, inspection); + } + + return successExitCode; + } +} diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/artifact_commands.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/artifact_commands.dart index 8e8ad8f..9cc1dca 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/artifact_commands.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/artifact_commands.dart @@ -73,6 +73,7 @@ class SummarizeCommand extends ProfilerCommand { prepared.callTree, prepared.bottomUpTree, prepared.methodTable, + warnings: prepared.warnings, ), ); } else { @@ -83,6 +84,7 @@ class SummarizeCommand extends ProfilerCommand { bottomUpTree: prepared.bottomUpTree, methodTable: prepared.methodTable, workingDirectory: workingDirectoryFromRegionPath(prepared.region), + warnings: prepared.warnings, options: options, ); } 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 ed7905e..7a4fced 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 @@ -8,6 +8,11 @@ import '../constants.dart'; import '../options.dart'; import 'profiler_command.dart'; +const _attachRegionWarning = + 'Attach mode captures the existing VM-service process, but explicit ' + 'devtools_region_profiler markers are unavailable unless the target was ' + 'launched by devtools-profiler run.'; + /// Command that launches and profiles a Dart or Flutter process. class RunCommand extends ProfilerCommand { /// Creates a run command. @@ -141,6 +146,15 @@ class AttachCommand extends ProfilerCommand { 'duration', help: 'Required profiling duration. Supports raw seconds, "10s", "500ms", or "2m".', + ) + ..addFlag( + 'skip-dtd', + defaultsTo: false, + negatable: false, + help: + 'Skip the Dart Tooling Daemon for this attach session. ' + 'Explicit region markers will be unavailable. Use this when the ' + 'tooling daemon fails to start or is not needed.', ); } @@ -161,6 +175,7 @@ class AttachCommand extends ProfilerCommand { const [ 'devtools-profiler attach --duration 15s http://127.0.0.1:8181/abcd/', 'devtools-profiler attach --duration 30s --call-tree --hide-sdk http://127.0.0.1:8181/abcd/', + 'devtools-profiler attach --skip-dtd --duration 30s http://127.0.0.1:8181/abcd/', ], ); @@ -183,12 +198,14 @@ class AttachCommand extends ProfilerCommand { ); } + io.writelnErr('Warning: $_attachRegionWarning'); final session = await profileRunner.attach( ProfileAttachRequest( artifactDirectory: argResults!['artifact-dir'] as String?, duration: duration, vmServiceUri: parseVmServiceUriArgument(argResults!.rest.single), workingDirectory: argResults!['cwd'] as String?, + enableDtd: !(argResults!['skip-dtd'] as bool), ), ); final options = presentationOptions; @@ -228,18 +245,6 @@ class AttachCommand extends ProfilerCommand { } } -/// Appends a stable examples section to a formatted command usage string. -String usageWithExamples(String usage, List examples) { - final buffer = StringBuffer(usage.trimRight()) - ..writeln() - ..writeln() - ..writeln('Examples:'); - for (final example in examples) { - buffer.writeln(' $example'); - } - return buffer.toString().trimRight(); -} - /// Command that starts the stdio MCP server. class McpCommand extends Command { /// Creates an MCP command. diff --git a/packages/devtools_profiler_cli/lib/src/cli/constants.dart b/packages/devtools_profiler_cli/lib/src/cli/constants.dart index b2b7477..07ca19f 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/constants.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/constants.dart @@ -14,3 +14,15 @@ const defaultMethodPathLimit = 3; /// Shared JSON encoder for CLI output. const jsonEncoder = JsonEncoder.withIndent(' '); + +/// Appends a stable examples section to a formatted command usage string. +String usageWithExamples(String usage, List examples) { + final buffer = StringBuffer(usage.trimRight()) + ..writeln() + ..writeln() + ..writeln('Examples:'); + for (final example in examples) { + buffer.writeln(' $example'); + } + return buffer.toString().trimRight(); +} diff --git a/packages/devtools_profiler_cli/lib/src/cli/options.dart b/packages/devtools_profiler_cli/lib/src/cli/options.dart index 04064cc..58d8baa 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/options.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/options.dart @@ -162,3 +162,18 @@ int? parseLimit(String? value, {required String optionName}) { } return parsed == 0 ? null : parsed; } + +/// Parses a non-negative integer option. +int? parseNonNegativeInt(String? value, {required String optionName}) { + if (value == null || value.isEmpty) { + return null; + } + + final parsed = int.tryParse(value); + if (parsed == null || parsed < 0) { + throw FormatException( + 'The "$optionName" option must be a non-negative integer.', + ); + } + return parsed; +} diff --git a/packages/devtools_profiler_cli/lib/src/mcp/server.dart b/packages/devtools_profiler_cli/lib/src/mcp/server.dart index 7df4238..12b9e6a 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/server.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/server.dart @@ -65,6 +65,7 @@ base class ProfilerMcpServer extends MCPServer with ToolsSupport { registerTool(profileCompareTool, handlers.profileCompare); registerTool(profileAnalyzeTrendsTool, handlers.profileAnalyzeTrends); registerTool(profileFindRegressionsTool, handlers.profileFindRegressions); + registerTool(profileInspectClassesTool, handlers.profileInspectClasses); } /// The profiler backend used by all tool calls. diff --git a/packages/devtools_profiler_cli/lib/src/mcp/tool_handlers.dart b/packages/devtools_profiler_cli/lib/src/mcp/tool_handlers.dart index b1b17bc..e383f5b 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/tool_handlers.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/tool_handlers.dart @@ -84,7 +84,12 @@ class McpToolHandlers { action: (progress) async { final arguments = request.arguments ?? const {}; final treeOptions = _treeOptionsFromArguments(arguments); - progress(0, 3, 'Attaching to VM service.'); + progress( + 0, + 3, + 'Attaching to VM service. Explicit region markers are unavailable ' + 'unless the target was launched by devtools-profiler run.', + ); final result = await runner.attach( ProfileAttachRequest( artifactDirectory: _optionalStringArgument( @@ -97,6 +102,7 @@ class McpToolHandlers { arguments, key: 'workingDirectory', ), + enableDtd: !(arguments['skipDtd'] as bool? ?? false), ), ); progress( @@ -324,6 +330,7 @@ class McpToolHandlers { prepared.callTree, prepared.bottomUpTree, prepared.methodTable, + warnings: prepared.warnings, ), }; progress(3, 3, 'Region prepared.'); @@ -487,6 +494,10 @@ class McpToolHandlers { sessionIdKey: 'currentSessionId', ); progress(1, 3, 'Preparing comparison views.'); + final memoryClassLimit = _optionalLimitArgument( + arguments, + key: 'memoryClassLimit', + ); final comparison = await prepareProfileComparison( runner, baselinePath: baselinePath, @@ -499,6 +510,12 @@ class McpToolHandlers { arguments, key: 'currentProfileId', ), + minLiveBytes: _optionalNonNegativeIntArgument( + arguments, + key: 'minLiveBytes', + ), + memoryClassLimit: memoryClassLimit.value, + memoryClassLimitSpecified: memoryClassLimit.specified, options: treeOptions, ); progress(2, 3, 'Building comparison response.'); @@ -602,6 +619,42 @@ class McpToolHandlers { ); } + Future profileInspectClasses(CallToolRequest request) { + return _runTool( + request: request, + successMessage: 'Memory class inspection completed.', + action: (progress) async { + final arguments = request.arguments ?? const {}; + final targetPath = _requiredStringArgument(arguments, key: 'path'); + final classQuery = _optionalStringArgument( + arguments, + key: 'classQuery', + ); + final limit = _treeLimitFromArgument( + arguments, + key: 'limit', + defaultValue: defaultMemoryClassLimit, + ); + + progress(0, 2, 'Reading memory class data.'); + final inspection = await prepareMemoryClassInspection( + runner, + targetPath, + classQuery: classQuery, + minLiveBytes: _optionalNonNegativeIntArgument( + arguments, + key: 'minLiveBytes', + ), + topClassCount: limit ?? 0, + ); + progress(1, 2, 'Building class inspection response.'); + final response = memoryClassInspectionJson(inspection); + progress(2, 2, 'Memory class inspection completed.'); + return response; + }, + ); + } + Future _runTool({ required CallToolRequest request, required String successMessage, @@ -671,6 +724,7 @@ class McpToolHandlers { prepared.callTree, prepared.bottomUpTree, prepared.methodTable, + warnings: prepared.warnings, ); } return summary; @@ -1170,6 +1224,32 @@ int? _listLimitFromArguments(Map arguments) { return value == 0 ? null : value; } +int? _optionalNonNegativeIntArgument( + Map arguments, { + required String key, +}) { + final value = arguments[key]; + if (value == null) { + return null; + } + if (value is! int || value < 0) { + throw ArgumentError('The "$key" argument must be a non-negative integer.'); + } + return value; +} + +({bool specified, int? value}) _optionalLimitArgument( + Map arguments, { + required String key, +}) { + if (!arguments.containsKey(key) || arguments[key] == null) { + return (specified: false, value: null); + } + + final value = _optionalNonNegativeIntArgument(arguments, key: key); + return (specified: true, value: value == 0 ? null : value); +} + ProfilePresentationOptions _treeOptionsFromArguments( Map arguments, ) { diff --git a/packages/devtools_profiler_cli/lib/src/mcp/tools/analysis_tools.dart b/packages/devtools_profiler_cli/lib/src/mcp/tools/analysis_tools.dart index a39ca2b..ed99bec 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/tools/analysis_tools.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/tools/analysis_tools.dart @@ -412,6 +412,17 @@ final Tool profileCompareTool = Tool( description: 'Maximum methods to include in the method comparison. Use 0 for unlimited.', ), + 'minLiveBytes': Schema.int( + description: + 'Re-read raw memory artifacts and include only classes with ' + 'at least this many live bytes. Useful for surfacing large ' + 'retained classes missed by the stored top-class list.', + ), + 'memoryClassLimit': Schema.int( + description: + 'Maximum memory classes to compare when re-reading raw ' + 'memory artifacts. Use 0 for unlimited.', + ), 'treeDepth': Schema.int( description: 'Maximum call tree depth when trees are included. Use 0 for unlimited.', @@ -594,3 +605,45 @@ final Tool profileFindRegressionsTool = Tool( title: 'Profile Find Regressions', ), ); + +final Tool profileInspectClassesTool = Tool( + name: 'profile_inspect_classes', + title: 'Profile Inspect Classes', + description: + 'Inspect memory class data in a stored session or region artifact. ' + 'Re-reads the raw memory artifact to provide the full class list, ' + 'with optional filtering by class name or live-byte threshold.', + inputSchema: Schema.object( + properties: { + 'path': Schema.string( + description: + 'A session directory, region summary.json, or raw memory_profile.json.', + ), + 'classQuery': Schema.string( + description: + 'Filter to classes whose name contains this query (case-insensitive).', + ), + 'minLiveBytes': Schema.int( + description: + 'Only include classes with at least this many live bytes at ' + 'the end of the capture window.', + ), + 'limit': Schema.int( + description: 'Maximum classes to return. Use 0 for unlimited.', + ), + }, + required: ['path'], + additionalProperties: false, + ), + outputSchema: Schema.object( + description: 'Memory class inspection result.', + additionalProperties: true, + ), + annotations: ToolAnnotations( + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + title: 'Profile Inspect Classes', + ), +); diff --git a/packages/devtools_profiler_cli/lib/src/mcp/tools/capture_tools.dart b/packages/devtools_profiler_cli/lib/src/mcp/tools/capture_tools.dart index 2b43c19..e055fc9 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/tools/capture_tools.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/tools/capture_tools.dart @@ -109,6 +109,12 @@ final Tool profileAttachTool = Tool( 'artifactDirectory': Schema.string( description: 'Where session artifacts should be written.', ), + 'skipDtd': Schema.bool( + description: + 'Skip the Dart Tooling Daemon for this attach session. ' + 'Explicit region markers will be unavailable. ' + 'Use this when the tooling daemon fails to start or is not needed.', + ), 'includeCallTree': Schema.bool( description: 'Whether to attach top-down region call trees.', ), diff --git a/packages/devtools_profiler_cli/lib/src/presentation.dart b/packages/devtools_profiler_cli/lib/src/presentation.dart index e21eea1..cbc57a7 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation.dart @@ -1,3 +1,4 @@ +export 'presentation/cli_command.dart'; export 'presentation/json.dart'; export 'presentation/models.dart'; export 'presentation/options.dart'; diff --git a/packages/devtools_profiler_cli/lib/src/presentation/cli_command.dart b/packages/devtools_profiler_cli/lib/src/presentation/cli_command.dart new file mode 100644 index 0000000..356b3f8 --- /dev/null +++ b/packages/devtools_profiler_cli/lib/src/presentation/cli_command.dart @@ -0,0 +1,68 @@ +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; + +/// Returns the CLI command that can reproduce a captured session. +String sessionCliCommand(ProfileRunResult session) { + if (isAttachSession(session)) { + final duration = durationOptionForSession(session); + return shellJoin([ + 'devtools-profiler', + 'attach', + if (duration != null) ...['--duration', duration], + '--cwd', + session.workingDirectory, + '--artifact-dir', + session.artifactDirectory, + session.vmServiceUri ?? session.command.skip(1).join(' '), + ]); + } + return shellJoin([ + 'devtools-profiler', + 'run', + '--cwd', + session.workingDirectory, + '--artifact-dir', + session.artifactDirectory, + '--', + ...session.command, + ]); +} + +/// Whether [session] was captured through `devtools-profiler attach`. +bool isAttachSession(ProfileRunResult session) { + return session.command.isNotEmpty && session.command.first == 'attach'; +} + +/// Returns the closest CLI duration option for [session]. +String? durationOptionForSession(ProfileRunResult session) { + final micros = session.overallProfile?.durationMicros; + if (micros == null || micros <= 0) { + return null; + } + if (micros % Duration.microsecondsPerSecond == 0) { + return '${micros ~/ Duration.microsecondsPerSecond}s'; + } + final milliseconds = (micros / Duration.microsecondsPerMillisecond).ceil(); + return '${milliseconds}ms'; +} + +/// Joins shell arguments using POSIX-compatible quoting. +String shellJoin(Iterable arguments) { + return arguments.map(shellQuote).join(' '); +} + +/// Quotes one POSIX shell argument when needed. +String shellQuote(String value) { + if (value.isEmpty) { + return "''"; + } + const specialCharacters = "'\"\\\$`!|&;<>(){}[]*?"; + final needsQuoting = value.runes.any( + (rune) => + String.fromCharCode(rune).trim().isEmpty || + specialCharacters.contains(String.fromCharCode(rune)), + ); + if (!needsQuoting) { + return value; + } + return "'${value.replaceAll("'", "'\\''")}'"; +} diff --git a/packages/devtools_profiler_cli/lib/src/presentation/json.dart b/packages/devtools_profiler_cli/lib/src/presentation/json.dart index 78a977b..2080a85 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/json.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/json.dart @@ -1,6 +1,8 @@ import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'cli_command.dart'; import 'models.dart'; +import 'options.dart'; /// Converts a prepared session to structured JSON. Map sessionPresentationJson( @@ -14,6 +16,7 @@ Map sessionPresentationJson( ) { return { ...session.toJson(), + 'cliCommand': sessionCliCommand(session), if (session.overallProfile != null) 'overallProfile': regionPresentationJson( session.overallProfile!, @@ -38,13 +41,17 @@ Map regionPresentationJson( ProfileRegionResult region, ProfileCallTree? callTree, ProfileCallTree? bottomUpTree, - ProfileMethodTable? methodTable, -) { + ProfileMethodTable? methodTable, { + List warnings = const [], +}) { return { ...region.toJson(), + if (region.summaryPath.isNotEmpty) + 'cliCommand': _summarizeCliCommand(region.summaryPath), if (callTree != null) 'callTree': callTree.toJson(), if (bottomUpTree != null) 'bottomUpTree': bottomUpTree.toJson(), if (methodTable != null) 'methodTable': methodTable.toJson(), + if (warnings.isNotEmpty) 'preparationWarnings': warnings, }; } @@ -52,12 +59,20 @@ Map regionPresentationJson( Map comparisonPresentationJson( PreparedProfileComparison comparison, ) { + final preparationWarnings = _uniqueStrings([ + ...comparison.warnings, + ...comparison.baseline.presentation.warnings, + ...comparison.current.presentation.warnings, + ]); return { 'kind': 'profileComparison', + 'cliCommand': _compareCliCommand(comparison), 'baseline': _comparisonTargetJson(comparison.baseline), 'current': _comparisonTargetJson(comparison.current), 'comparison': comparison.comparison.toJson(), 'regressions': comparison.regressions.toJson(), + if (preparationWarnings.isNotEmpty) + 'preparationWarnings': preparationWarnings, }; } @@ -65,10 +80,14 @@ Map comparisonPresentationJson( Map hotspotExplanationJson( PreparedProfileExplanation explanation, ) { + final preparationWarnings = explanation.target.presentation.warnings; return { 'kind': 'hotspotExplanation', + 'cliCommand': _explainCliCommand(explanation), 'target': _comparisonTargetJson(explanation.target), 'hotspots': explanation.hotspots.toJson(), + if (preparationWarnings.isNotEmpty) + 'preparationWarnings': preparationWarnings, }; } @@ -76,10 +95,14 @@ Map hotspotExplanationJson( Map methodInspectionJson( PreparedProfileMethodInspection inspection, ) { + final preparationWarnings = inspection.target.presentation.warnings; return { 'kind': 'methodInspection', + 'cliCommand': _inspectCliCommand(inspection), 'target': _comparisonTargetJson(inspection.target), 'inspection': inspection.inspection.toJson(), + if (preparationWarnings.isNotEmpty) + 'preparationWarnings': preparationWarnings, }; } @@ -87,20 +110,31 @@ Map methodInspectionJson( Map methodComparisonJson( PreparedProfileMethodComparison comparison, ) { + final preparationWarnings = _uniqueStrings([ + ...comparison.baseline.presentation.warnings, + ...comparison.current.presentation.warnings, + ]); return { 'kind': 'methodComparison', + 'cliCommand': _compareMethodCliCommand(comparison), 'baseline': _comparisonTargetJson(comparison.baseline), 'current': _comparisonTargetJson(comparison.current), 'comparison': comparison.comparison.toJson(), + if (preparationWarnings.isNotEmpty) + 'preparationWarnings': preparationWarnings, }; } /// Converts prepared method search data to structured JSON. Map methodSearchJson(PreparedProfileMethodSearch search) { + final preparationWarnings = search.target.presentation.warnings; return { 'kind': 'methodSearch', + 'cliCommand': _searchMethodsCliCommand(search), 'target': _comparisonTargetJson(search.target), 'search': search.search.toJson(), + if (preparationWarnings.isNotEmpty) + 'preparationWarnings': preparationWarnings, }; } @@ -108,6 +142,7 @@ Map methodSearchJson(PreparedProfileMethodSearch search) { Map trendPresentationJson(PreparedProfileTrends trends) { return { 'kind': 'profileTrends', + 'cliCommand': _trendsCliCommand(trends), 'targets': [for (final target in trends.targets) _trendTargetJson(target)], 'trends': trends.trends.toJson(), }; @@ -125,6 +160,7 @@ Map _comparisonTargetJson(PreparedComparisonTarget target) { target.presentation.callTree, target.presentation.bottomUpTree, target.presentation.methodTable, + warnings: target.presentation.warnings, ), }; } @@ -144,9 +180,199 @@ Map _trendTargetJson(PreparedComparisonTarget target) { }; } +/// Converts prepared memory class inspection data to structured JSON. +Map memoryClassInspectionJson( + PreparedMemoryClassInspection inspection, +) { + final memory = inspection.memory; + return { + 'kind': 'memoryClassInspection', + 'cliCommand': _inspectClassesCliCommand(inspection), + 'targetPath': inspection.targetPath, + 'classQuery': inspection.classQuery, + 'minLiveBytes': inspection.minLiveBytes, + 'topClassCount': inspection.topClassCount, + 'totalClassCount': memory.classCount, + 'matchedClassCount': memory.topClasses.length, + 'deltaHeapBytes': memory.deltaHeapBytes, + 'deltaExternalBytes': memory.deltaExternalBytes, + 'deltaCapacityBytes': memory.deltaCapacityBytes, + 'classes': [ + for (final item in memory.topClasses) + { + 'className': item.className, + 'libraryUri': item.libraryUri, + 'liveBytes': item.liveBytes, + 'liveBytesDelta': item.liveBytesDelta, + 'liveInstances': item.liveInstances, + 'liveInstancesDelta': item.liveInstancesDelta, + 'allocationBytesDelta': item.allocationBytesDelta, + 'allocationInstancesDelta': item.allocationInstancesDelta, + }, + ], + }; +} + String _presentationScope(ProfileRegionResult region) { if (region.regionId == 'overall' || region.attributes['scope'] == 'session') { return 'session'; } return 'region'; } + +String _summarizeCliCommand(String targetPath) { + return shellJoin(['devtools-profiler', 'summarize', targetPath]); +} + +String _compareCliCommand(PreparedProfileComparison comparison) { + return shellJoin([ + 'devtools-profiler', + 'compare', + if (_usesProfileSelector(comparison.baseline)) ...[ + '--baseline-profile-id', + comparison.baseline.selectedProfileId, + ], + if (_usesProfileSelector(comparison.current)) ...[ + '--current-profile-id', + comparison.current.selectedProfileId, + ], + if (comparison.minLiveBytes != null) ...[ + '--min-live-bytes', + '${comparison.minLiveBytes}', + ], + if (comparison.memoryClassLimitSpecified) ...[ + '--memory-class-limit', + '${comparison.memoryClassLimit ?? 0}', + ], + comparison.baseline.path, + comparison.current.path, + ]); +} + +String _explainCliCommand(PreparedProfileExplanation explanation) { + return shellJoin([ + 'devtools-profiler', + 'explain', + if (_usesProfileSelector(explanation.target)) ...[ + '--profile-id', + explanation.target.selectedProfileId, + ], + explanation.target.path, + ]); +} + +String _inspectCliCommand(PreparedProfileMethodInspection inspection) { + final queryOption = inspection.inspection.queryKind == 'methodId' + ? '--method-id' + : '--method'; + return shellJoin([ + 'devtools-profiler', + 'inspect', + if (_usesProfileSelector(inspection.target)) ...[ + '--profile-id', + inspection.target.selectedProfileId, + ], + queryOption, + inspection.inspection.query, + inspection.target.path, + ]); +} + +String _compareMethodCliCommand(PreparedProfileMethodComparison comparison) { + final queryOption = comparison.comparison.queryKind == 'methodId' + ? '--method-id' + : '--method'; + return shellJoin([ + 'devtools-profiler', + 'compare-method', + if (_usesProfileSelector(comparison.baseline)) ...[ + '--baseline-profile-id', + comparison.baseline.selectedProfileId, + ], + if (_usesProfileSelector(comparison.current)) ...[ + '--current-profile-id', + comparison.current.selectedProfileId, + ], + queryOption, + comparison.comparison.query, + comparison.baseline.path, + comparison.current.path, + ]); +} + +String _searchMethodsCliCommand(PreparedProfileMethodSearch search) { + return shellJoin([ + 'devtools-profiler', + 'search-methods', + if (_usesProfileSelector(search.target)) ...[ + '--profile-id', + search.target.selectedProfileId, + ], + if (search.search.query?.isNotEmpty == true) ...[ + '--query', + search.search.query!, + ], + '--sort', + search.search.sortBy.name, + search.target.path, + ]); +} + +String _trendsCliCommand(PreparedProfileTrends trends) { + final profileId = _trendProfileSelector(trends.targets); + return shellJoin([ + 'devtools-profiler', + 'trends', + if (profileId != null) ...['--profile-id', profileId], + ...trends.targets.map((target) => target.path), + ]); +} + +bool _usesProfileSelector(PreparedComparisonTarget target) { + return target.inputKind == 'session' && target.selectedProfileId != 'overall'; +} + +String? _trendProfileSelector(List targets) { + if (targets.isEmpty || + targets.any((target) => target.inputKind != 'session')) { + return null; + } + + final selectedProfileIds = { + for (final target in targets) target.selectedProfileId, + }; + if (selectedProfileIds.length != 1) { + return null; + } + + final selectedProfileId = selectedProfileIds.single; + return selectedProfileId == 'overall' ? null : selectedProfileId; +} + +String _inspectClassesCliCommand(PreparedMemoryClassInspection inspection) { + return shellJoin([ + 'devtools-profiler', + 'inspect-classes', + if (inspection.classQuery?.isNotEmpty == true) ...[ + '--class', + inspection.classQuery!, + ], + if (inspection.minLiveBytes != null) ...[ + '--min-live-bytes', + '${inspection.minLiveBytes}', + ], + if (inspection.topClassCount != defaultMemoryClassLimit) ...[ + '--limit', + '${inspection.topClassCount}', + ], + inspection.targetPath, + ]); +} + +List _uniqueStrings(Iterable values) { + final seen = {}; + return [ + for (final value in values) + if (seen.add(value)) value, + ]; +} diff --git a/packages/devtools_profiler_cli/lib/src/presentation/models.dart b/packages/devtools_profiler_cli/lib/src/presentation/models.dart index 36bab33..79ce9de 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/models.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/models.dart @@ -35,6 +35,10 @@ class PreparedProfileComparison { required this.current, required this.comparison, required this.regressions, + required this.minLiveBytes, + required this.memoryClassLimit, + required this.memoryClassLimitSpecified, + this.warnings = const [], }); /// The baseline comparison target. @@ -48,6 +52,18 @@ class PreparedProfileComparison { /// Prioritized regression insights derived from the comparison. final ProfileRegressionSummary regressions; + + /// The memory live-bytes threshold requested for class comparison. + final int? minLiveBytes; + + /// The memory class limit requested for comparison. + final int? memoryClassLimit; + + /// Whether the memory class limit was explicitly requested. + final bool memoryClassLimitSpecified; + + /// Warnings generated while preparing comparison-specific data. + final List warnings; } /// Prepared hotspot explanation data for CLI or MCP output. @@ -161,6 +177,33 @@ class PreparedSessionPresentation { final Map regionMethodTables; } +/// Prepared memory class inspection data for CLI or MCP output. +class PreparedMemoryClassInspection { + /// Creates prepared memory class inspection data. + const PreparedMemoryClassInspection({ + required this.targetPath, + required this.memory, + required this.classQuery, + required this.minLiveBytes, + required this.topClassCount, + }); + + /// The user-supplied target path that was inspected. + final String targetPath; + + /// The rebuilt memory result containing filtered classes. + final ProfileMemoryResult memory; + + /// The class name query used for filtering, or null if none was supplied. + final String? classQuery; + + /// The minimum live-bytes threshold used for filtering, or null if none. + final int? minLiveBytes; + + /// The maximum class count requested, or 0 for unlimited results. + final int topClassCount; +} + /// Prepared region data for CLI or MCP output. class PreparedRegionPresentation { /// Creates prepared region data. @@ -169,6 +212,7 @@ class PreparedRegionPresentation { this.callTree, this.bottomUpTree, this.methodTable, + this.warnings = const [], }); /// The region summary adjusted for the view options. @@ -182,4 +226,9 @@ class PreparedRegionPresentation { /// The optional region method table. final ProfileMethodTable? methodTable; + + /// Warnings generated while preparing this region, such as a mismatch + /// between the stored sample count and the count re-derived from the raw + /// CPU profile artifact. + final List warnings; } diff --git a/packages/devtools_profiler_cli/lib/src/presentation/options.dart b/packages/devtools_profiler_cli/lib/src/presentation/options.dart index f1ef827..9d859de 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/options.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/options.dart @@ -9,6 +9,9 @@ const defaultTreeChildren = 12; /// Default number of rows to show in summary tables. const defaultFrameLimit = 12; +/// Default number of memory classes to show in class-inspection output. +const defaultMemoryClassLimit = 50; + /// Package prefixes that belong to profiler transport/runtime helpers. const runtimeHelperPackagePrefixes = [ 'devtools_profiler_', @@ -72,6 +75,27 @@ class ProfilePresentationOptions { /// Optional package prefixes to exclude. final List excludePackages; + /// Whether any frame-level filters are active. + bool get hasActiveFrameFilters => + hideSdk || + hideRuntimeHelpers || + includePackages.isNotEmpty || + excludePackages.isNotEmpty; + + /// User-facing descriptions for active frame filters. + List get activeFrameFilterDescriptions => [ + if (hideSdk) '--hide-sdk', + if (hideRuntimeHelpers) '--hide-runtime-helpers', + for (final package in includePackages) '--include-package $package', + for (final package in excludePackages) '--exclude-package $package', + ]; + + /// A compact user-facing label for active frame filters. + String get activeFrameFilterLabel { + final descriptions = activeFrameFilterDescriptions; + return descriptions.isEmpty ? '(none)' : descriptions.join(', '); + } + /// The predicate applied while building summaries and call trees. ProfileFramePredicate? get framePredicate { final excludePrefixes = [ @@ -79,9 +103,7 @@ class ProfilePresentationOptions { if (hideRuntimeHelpers) ...runtimeHelperPackagePrefixes, ]; final includePrefixes = includePackages; - final shouldFilter = - hideSdk || excludePrefixes.isNotEmpty || includePrefixes.isNotEmpty; - if (!shouldFilter) { + if (!hasActiveFrameFilters) { return null; } return (frame) { diff --git a/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart b/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart index 8eed7b0..5aa2646 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart @@ -1,4 +1,5 @@ import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:vm_service/vm_service.dart'; import 'models.dart'; import 'options.dart'; @@ -13,6 +14,7 @@ Future prepareSessionPresentation( ProfileCallTree? overallTree; ProfileCallTree? overallBottomUpTree; ProfileMethodTable? overallMethodTable; + final preparationWarnings = []; final storedOverall = session.overallProfile; if (storedOverall != null) { final prepared = await prepareRegionPresentation( @@ -24,6 +26,7 @@ Future prepareSessionPresentation( overallTree = prepared.callTree; overallBottomUpTree = prepared.bottomUpTree; overallMethodTable = prepared.methodTable; + preparationWarnings.addAll(prepared.warnings); } final preparedRegions = []; @@ -38,6 +41,7 @@ Future prepareSessionPresentation( options: options, ); preparedRegions.add(prepared.region); + preparationWarnings.addAll(prepared.warnings); if (prepared.callTree != null) { regionTrees[prepared.region.regionId] = prepared.callTree!; } @@ -60,7 +64,7 @@ Future prepareSessionPresentation( supportedIsolateScopes: session.supportedIsolateScopes, overallProfile: overallProfile, regions: preparedRegions, - warnings: session.warnings, + warnings: [...session.warnings, ...preparationWarnings], vmServiceUri: session.vmServiceUri, ), overallTree: overallTree, @@ -79,6 +83,9 @@ Future prepareProfileComparison( required String currentPath, String? baselineProfileId, String? currentProfileId, + int? minLiveBytes, + int? memoryClassLimit, + bool memoryClassLimitSpecified = false, required ProfilePresentationOptions options, }) async { final baseline = await _resolveComparisonTarget( @@ -93,6 +100,68 @@ Future prepareProfileComparison( requestedProfileId: currentProfileId, options: options, ); + + ProfileMemoryResult? baselineMemoryOverride; + ProfileMemoryResult? currentMemoryOverride; + final memoryWarnings = []; + + if (minLiveBytes != null || memoryClassLimitSpecified) { + final baselineRawPath = baseline.presentation.region.memory?.rawProfilePath; + final currentRawPath = current.presentation.region.memory?.rawProfilePath; + final memoryLimitDescription = memoryClassLimitSpecified + ? '${memoryClassLimit ?? 0}' + : 'default'; + + if (baselineRawPath != null && baselineRawPath.isNotEmpty) { + try { + baselineMemoryOverride = await runner.readMemoryClasses( + baselineRawPath, + minLiveBytes: minLiveBytes, + topClassCount: memoryClassLimit ?? 0, + ); + } catch (error) { + memoryWarnings.add( + 'readMemoryClasses could not build baselineMemoryOverride from ' + '"$baselineRawPath" (minLiveBytes=${minLiveBytes ?? 'none'}, ' + 'memoryClassLimit=$memoryLimitDescription): $error. Falling back ' + 'to stored memory summary classes.', + ); + } + } else { + memoryWarnings.add( + 'readMemoryClasses could not build baselineMemoryOverride because no ' + 'raw memory artifact path was stored (minLiveBytes=' + '${minLiveBytes ?? 'none'}, memoryClassLimit=' + '$memoryLimitDescription). Falling back to stored memory summary ' + 'classes.', + ); + } + if (currentRawPath != null && currentRawPath.isNotEmpty) { + try { + currentMemoryOverride = await runner.readMemoryClasses( + currentRawPath, + minLiveBytes: minLiveBytes, + topClassCount: memoryClassLimit ?? 0, + ); + } catch (error) { + memoryWarnings.add( + 'readMemoryClasses could not build currentMemoryOverride from ' + '"$currentRawPath" (minLiveBytes=${minLiveBytes ?? 'none'}, ' + 'memoryClassLimit=$memoryLimitDescription): $error. Falling back ' + 'to stored memory summary classes.', + ); + } + } else { + memoryWarnings.add( + 'readMemoryClasses could not build currentMemoryOverride because no ' + 'raw memory artifact path was stored (minLiveBytes=' + '${minLiveBytes ?? 'none'}, memoryClassLimit=' + '$memoryLimitDescription). Falling back to stored memory summary ' + 'classes.', + ); + } + } + final comparison = compareProfileRegions( baseline: baseline.presentation.region, current: current.presentation.region, @@ -100,13 +169,21 @@ Future prepareProfileComparison( currentMethodTable: current.presentation.methodTable, frameLimit: options.frameLimit, methodLimit: options.methodLimit, - memoryClassLimit: options.frameLimit, + memoryClassLimit: memoryClassLimitSpecified + ? memoryClassLimit + : options.frameLimit, + baselineMemoryOverride: baselineMemoryOverride, + currentMemoryOverride: currentMemoryOverride, ); return PreparedProfileComparison( baseline: baseline, current: current, comparison: comparison, regressions: summarizeProfileRegressions(comparison), + minLiveBytes: minLiveBytes, + memoryClassLimit: memoryClassLimit, + memoryClassLimitSpecified: memoryClassLimitSpecified, + warnings: memoryWarnings, ); } @@ -402,6 +479,33 @@ Future prepareProfileTrends( ); } +/// Prepares memory class inspection data for CLI or MCP output. +/// +/// Reads the raw memory artifact at [targetPath], rebuilds the full class +/// list with optional [classQuery] and [minLiveBytes] filtering, and returns +/// a [PreparedMemoryClassInspection]. +Future prepareMemoryClassInspection( + ProfileRunner runner, + String targetPath, { + String? classQuery, + int? minLiveBytes, + int topClassCount = 50, +}) async { + final memory = await runner.readMemoryClasses( + targetPath, + classQuery: classQuery, + minLiveBytes: minLiveBytes, + topClassCount: topClassCount, + ); + return PreparedMemoryClassInspection( + targetPath: targetPath, + memory: memory, + classQuery: classQuery, + minLiveBytes: minLiveBytes, + topClassCount: topClassCount, + ); +} + /// Rebuilds a single region summary and tree to match [options]. Future prepareRegionPresentation( ProfileRunner runner, @@ -417,6 +521,7 @@ Future prepareRegionPresentation( final cpuSamples = await runner.readCpuSamples(rawProfilePath); final memory = _filterStoredMemory(region.memory, options); + final preFilterSampleCount = _countCpuSamplesBeforeFilters(cpuSamples); final rebuiltRegion = summarizeCpuSamples( regionId: region.regionId, name: region.name, @@ -435,6 +540,50 @@ Future prepareRegionPresentation( topFrameCount: options.frameLimit ?? 0, includeFrame: options.framePredicate, ); + + // When no frame filter is active but the re-derived sample count is zero + // while the stored summary reports a positive count, the raw CPU profile + // round-trip produced an empty sample list (a known serialization edge case + // with certain VM builds). Fall back to the stored region summary so that + // the terminal and JSON output matches what was written to session.json. + // Call trees and the method table are still derived from the re-read data; + // they will be empty in this case, which is transparent to the caller. + final warnings = []; + if (options.hasActiveFrameFilters && + preFilterSampleCount > 0 && + rebuiltRegion.sampleCount == 0) { + warnings.add( + 'Profile "${region.name}": $preFilterSampleCount CPU sample(s) were ' + 'available before filtering, but no frames remained after applying ' + '${options.activeFrameFilterLabel}. Retry without those filters or use ' + 'a broader --include-package value.', + ); + } else if (_capturesCpu(region) && + preFilterSampleCount == 0 && + rebuiltRegion.sampleCount == 0 && + region.sampleCount == 0) { + warnings.add( + 'Profile "${region.name}": no CPU samples were captured. Use a longer ' + 'capture duration, and for Flutter startup runs make sure compilation ' + 'has finished or increase --vm-service-timeout.', + ); + } + final ProfileRegionResult regionForSummary; + if (rebuiltRegion.sampleCount == 0 && + region.sampleCount > 0 && + !options.hasActiveFrameFilters) { + warnings.add( + 'Region "${region.name}": the raw CPU profile artifact at ' + '"$rawProfilePath" produced 0 samples when re-read, but the stored ' + 'summary reported ${region.sampleCount} samples. The stored summary ' + 'data is used for this output. Re-run with --call-tree or ' + '--method-table to inspect whether the artifact can be parsed.', + ); + regionForSummary = _filterStoredRegion(region, options); + } else { + regionForSummary = rebuiltRegion; + } + final callTree = options.includeCallTree ? buildCallTree( cpuSamples: cpuSamples, @@ -458,13 +607,26 @@ Future prepareRegionPresentation( : null; return PreparedRegionPresentation( - region: rebuiltRegion, + region: regionForSummary, callTree: callTree, bottomUpTree: bottomUpTree, methodTable: methodTable, + warnings: warnings, ); } +bool _capturesCpu(ProfileRegionResult region) { + return region.captureKinds.contains(ProfileCaptureKind.cpu); +} + +int _countCpuSamplesBeforeFilters(CpuSamples cpuSamples) { + final sampleCount = cpuSamples.sampleCount; + if (sampleCount != null) { + return sampleCount; + } + return cpuSamples.samples?.length ?? 0; +} + ProfileRegionResult _filterStoredRegion( ProfileRegionResult region, ProfilePresentationOptions options, diff --git a/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart b/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart index 786570e..830d7e3 100644 --- a/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart +++ b/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart @@ -169,6 +169,14 @@ String topFrameName(List frames) { return frames.first.name; } +List uniqueWarnings(Iterable warnings) { + final seen = {}; + return [ + for (final warning in warnings) + if (seen.add(warning)) warning, + ]; +} + String formatCaptureKinds(List captureKinds) { return captureKinds.map((kind) => kind.name).join(', '); } diff --git a/packages/devtools_profiler_cli/lib/src/rendering/methods.dart b/packages/devtools_profiler_cli/lib/src/rendering/methods.dart index e8889b3..7fa6716 100644 --- a/packages/devtools_profiler_cli/lib/src/rendering/methods.dart +++ b/packages/devtools_profiler_cli/lib/src/rendering/methods.dart @@ -27,6 +27,10 @@ void writeMethodInspection( console.section('Details'); console.warn(result.message!); } + if (inspection.target.presentation.warnings.isNotEmpty) { + console.section('Warnings'); + console.components.bulletList(inspection.target.presentation.warnings); + } final method = result.method; if (method != null) { @@ -149,6 +153,10 @@ void writeMethodSearch( console.section('Details'); console.warn(result.message!); } + if (search.target.presentation.warnings.isNotEmpty) { + console.section('Warnings'); + console.components.bulletList(search.target.presentation.warnings); + } if (result.methods.isEmpty) { console.section('Matches'); @@ -248,9 +256,14 @@ void writeMethodComparison( options: options, ); - if (comparison.comparison.warnings.isNotEmpty) { + final warnings = uniqueWarnings([ + ...comparison.comparison.warnings, + ...comparison.baseline.presentation.warnings, + ...comparison.current.presentation.warnings, + ]); + if (warnings.isNotEmpty) { console.section('Warnings'); - console.components.bulletList(comparison.comparison.warnings); + console.components.bulletList(warnings); } } diff --git a/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart b/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart index 8e22eb2..bd76d82 100644 --- a/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart +++ b/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart @@ -32,6 +32,7 @@ void writeSessionSummary( }, if (session.vmServiceUri != null) 'VM service': session.vmServiceUri, }); + _writeReproductionBlock(console, session, options: options); final overallProfile = session.overallProfile; if (overallProfile == null && session.regions.isEmpty) { @@ -118,6 +119,7 @@ void writeRegionSummary( ProfileCallTree? bottomUpTree, ProfileMethodTable? methodTable, String? workingDirectory, + List warnings = const [], required ProfilePresentationOptions options, }) { console.title('Region Summary'); @@ -130,6 +132,10 @@ void writeRegionSummary( workingDirectory: workingDirectory, options: options, ); + if (warnings.isNotEmpty) { + console.section('Warnings'); + console.components.bulletList(warnings); + } } void writeComparisonSummary( @@ -186,9 +192,15 @@ void writeComparisonSummary( _writeRegressionInsights(console, comparison.regressions); - if (delta.warnings.isNotEmpty) { + final allWarnings = uniqueWarnings([ + ...delta.warnings, + ...comparison.warnings, + ...comparison.baseline.presentation.warnings, + ...comparison.current.presentation.warnings, + ]); + if (allWarnings.isNotEmpty) { console.section('Warnings'); - console.components.bulletList(delta.warnings); + console.components.bulletList(allWarnings); } _writeFrameDeltaTable( @@ -276,9 +288,13 @@ void writeHotspotExplanation( ]); } - if (explanation.hotspots.warnings.isNotEmpty) { + final warnings = [ + ...explanation.hotspots.warnings, + ...explanation.target.presentation.warnings, + ]; + if (warnings.isNotEmpty) { console.section('Warnings'); - console.components.bulletList(explanation.hotspots.warnings); + console.components.bulletList(warnings); } _writeRegionDetails( @@ -410,6 +426,57 @@ void writeTrendSummary( } } +/// Writes a memory class inspection summary to [console]. +void writeMemoryClassInspection( + Console console, + PreparedMemoryClassInspection inspection, +) { + final memory = inspection.memory; + console.title('Memory Class Inspection'); + console.components.definitionList({ + 'Path': inspection.targetPath, + 'Class query': inspection.classQuery ?? '(all classes)', + 'Min live bytes': inspection.minLiveBytes != null + ? formatBytes(inspection.minLiveBytes!) + : '(none)', + 'Heap delta': formatSignedBytes(memory.deltaHeapBytes), + 'External delta': formatSignedBytes(memory.deltaExternalBytes), + 'Capacity delta': formatSignedBytes(memory.deltaCapacityBytes), + 'Matched classes': + '${memory.topClasses.length} of ${memory.classCount} total', + }); + + if (memory.topClasses.isEmpty) { + console.warn('No classes matched the current filter criteria.'); + return; + } + + console.section('Classes'); + console.table( + headers: const [ + 'Class', + 'Library', + 'Live', + 'Live Δ', + 'Instances', + 'Inst Δ', + 'Alloc Δ', + ], + rows: [ + for (final item in memory.topClasses) + [ + item.className, + item.libraryUri ?? '-', + formatBytes(item.liveBytes), + formatSignedBytes(item.liveBytesDelta), + item.liveInstances, + formatSignedCount(item.liveInstancesDelta), + formatSignedBytes(item.allocationBytesDelta), + ], + ], + ); +} + String _hotspotInsightLine( ProfileHotspotInsight insight, { required String? workingDirectory, @@ -498,6 +565,41 @@ void _writeRegressionInsights( ]); } +void _writeReproductionBlock( + Console console, + ProfileRunResult session, { + required ProfilePresentationOptions options, +}) { + console.section('Reproduce'); + console.components.definitionList({ + 'Profiler command': sessionCliCommand(session), + 'Target command': isAttachSession(session) + ? '-' + : shellJoin(session.command), + 'Target cwd': session.workingDirectory, + 'VM service': session.vmServiceUri ?? '-', + 'Capture duration': _captureDurationForSession(session), + 'Artifact dir': session.artifactDirectory, + 'Active filters': options.activeFrameFilterLabel, + }); +} + +String _captureDurationForSession(ProfileRunResult session) { + final overallDuration = session.overallProfile?.durationMicros; + if (overallDuration != null && overallDuration > 0) { + return formatMicros(overallDuration); + } + final regionDurations = [ + for (final region in session.regions) + if (region.durationMicros > 0) region.durationMicros, + ]; + if (regionDurations.isEmpty) { + return '-'; + } + regionDurations.sort(); + return formatMicros(regionDurations.last); +} + void _writeRegionDetails( Console console, ProfileRegionResult region, { @@ -656,24 +758,28 @@ void _writeMemorySummary( return; } - console.section('Top Allocation Classes'); + console.section('Top Memory Classes By Delta'); console.table( headers: const [ 'Class', - 'Allocated', - 'Live Delta', 'Live', - 'Instances', + 'Live Δ', + 'Live Inst', + 'Inst Δ', + 'New Inst', + 'Alloc Δ', 'Source', ], rows: [ for (final item in memory.topClasses) [ item.className, - formatSignedBytes(item.allocationBytesDelta), - formatSignedBytes(item.liveBytesDelta), formatBytes(item.liveBytes), + formatSignedBytes(item.liveBytesDelta), + item.liveInstances, + formatSignedCount(item.liveInstancesDelta), formatSignedCount(item.allocationInstancesDelta), + formatSignedBytes(item.allocationBytesDelta), displayLocation( item.libraryUri, fullLocations: fullLocationsEnabled(options), diff --git a/packages/devtools_profiler_cli/pubspec.yaml b/packages/devtools_profiler_cli/pubspec.yaml index 72c3659..19bbbdd 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.1.0 +version: 0.2.0-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.1.0 + devtools_profiler_core: ^0.2.0-wip path: ^1.9.0 stream_channel: ^2.1.4 diff --git a/packages/devtools_profiler_cli/test/cli_test.dart b/packages/devtools_profiler_cli/test/cli_test.dart index b2721c8..f1a23ec 100644 --- a/packages/devtools_profiler_cli/test/cli_test.dart +++ b/packages/devtools_profiler_cli/test/cli_test.dart @@ -195,7 +195,8 @@ void main() { final json = jsonDecode(stdoutCapture.text) as Map; expect(json['sessionId'], 'session-attach'); expect(json['command'], ['attach', 'http://127.0.0.1:8181/abcd/']); - expect(stderrCapture.text, isEmpty); + expect(stderrCapture.text, contains('Attach mode captures')); + expect(stderrCapture.text, contains('devtools-profiler run')); }); test('run prints json output with a call tree when expanded', () async { @@ -865,6 +866,158 @@ void main() { expect(stderrCapture.text, isEmpty); }); + test( + 'json cli commands omit profile selectors for artifact targets', + () async { + const baselinePath = + '/tmp/artifacts/session-1/regions/region-1/summary.json'; + const currentPath = + '/tmp/artifacts/session-2/regions/region-2/summary.json'; + + final explain = await _runJsonCommand(['explain', '--json', currentPath]); + expect(explain['cliCommand'], 'devtools-profiler explain $currentPath'); + + final inspect = await _runJsonCommand([ + 'inspect', + '--json', + '--method', + 'Worker.hotLeaf', + currentPath, + ]); + expect( + inspect['cliCommand'], + 'devtools-profiler inspect --method Worker.hotLeaf $currentPath', + ); + + final search = await _runJsonCommand([ + 'search-methods', + '--json', + '--query', + 'Worker', + currentPath, + ]); + expect( + search['cliCommand'], + 'devtools-profiler search-methods --query Worker --sort total ' + '$currentPath', + ); + + final compare = await _runJsonCommand([ + 'compare', + '--json', + baselinePath, + currentPath, + ]); + expect( + compare['cliCommand'], + 'devtools-profiler compare $baselinePath $currentPath', + ); + + final compareMethod = await _runJsonCommand([ + 'compare-method', + '--json', + '--method', + 'Worker.hotLeaf', + baselinePath, + currentPath, + ]); + expect( + compareMethod['cliCommand'], + 'devtools-profiler compare-method --method Worker.hotLeaf ' + '$baselinePath $currentPath', + ); + }, + ); + + test('compare treats memory-class-limit 0 as unlimited', () async { + final runner = _FakeMemoryProfileRunner(); + final json = await _runJsonCommand(const [ + 'compare', + '--json', + '--min-live-bytes', + '512', + '--memory-class-limit', + '0', + '/tmp/memory/session-1', + '/tmp/memory/session-2', + ], runner: runner); + + expect(runner.readMemoryTopClassCounts, [0, 0]); + expect( + json['cliCommand'], + 'devtools-profiler compare --min-live-bytes 512 ' + '--memory-class-limit 0 /tmp/memory/session-1 /tmp/memory/session-2', + ); + final comparison = json['comparison'] as Map; + final memory = comparison['memory'] as Map; + final topClasses = memory['topClasses'] as List; + expect(topClasses, hasLength(2)); + }); + + test('compare warns when memory class re-read fails', () async { + final runner = _FakeMemoryProfileRunner(failMemoryReads: true); + final json = await _runJsonCommand(const [ + 'compare', + '--json', + '--min-live-bytes', + '512', + '/tmp/memory/session-1', + '/tmp/memory/session-2', + ], runner: runner); + + final warnings = json['preparationWarnings'] as List; + expect( + warnings, + contains( + allOf( + contains('readMemoryClasses could not build baselineMemoryOverride'), + contains('minLiveBytes=512'), + contains('memoryClassLimit=default'), + ), + ), + ); + expect( + warnings, + contains( + allOf( + contains('readMemoryClasses could not build currentMemoryOverride'), + contains('minLiveBytes=512'), + contains('memoryClassLimit=default'), + ), + ), + ); + }); + + test('compare rejects negative memory options', () async { + final limitResult = await _runCliCommand(const [ + 'compare', + '--memory-class-limit', + '-1', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + ]); + + expect(limitResult.exitCode, 64); + expect( + limitResult.stderr, + contains('"memory-class-limit" option must be a non-negative integer'), + ); + + final minLiveBytesResult = await _runCliCommand(const [ + 'compare', + '--min-live-bytes', + '-1', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + ]); + + expect(minLiveBytesResult.exitCode, 64); + expect( + minLiveBytesResult.stderr, + contains('"min-live-bytes" option must be a non-negative integer'), + ); + }); + test('compare prints artisanal delta output', () async { final runner = _FakeProfileRunner(); final stdoutCapture = _OutputCapture(); @@ -932,6 +1085,32 @@ void main() { expect(stderrCapture.text, isEmpty); }); + test('trends json cli command preserves session profile selection', () async { + final json = await _runJsonCommand(const [ + 'trends', + '--json', + '--profile-id', + 'startup', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + '/tmp/artifacts/session-3', + ], runner: _FakeProfileRunnerWithSharedTrendRegion()); + + expect( + json['cliCommand'], + 'devtools-profiler trends --profile-id startup ' + '/tmp/artifacts/session-1 /tmp/artifacts/session-2 ' + '/tmp/artifacts/session-3', + ); + final targets = json['targets'] as List; + expect( + targets.cast>().map( + (target) => target['selectedProfileId'], + ), + everyElement('startup'), + ); + }); + test('trends prints artisanal trend output', () async { final runner = _FakeProfileRunner(); final stdoutCapture = _OutputCapture(); @@ -963,6 +1142,68 @@ void main() { expect(stderrCapture.text, isEmpty); }); + test('inspect-classes prints json memory class output', () async { + final runner = _FakeMemoryProfileRunner(); + final json = await _runJsonCommand(const [ + 'inspect-classes', + '--json', + '--class', + 'Love', + '--min-live-bytes', + '512', + '--limit', + '1', + '/tmp/memory/session-1', + ], runner: runner); + + expect(json['kind'], 'memoryClassInspection'); + expect(json['classQuery'], 'Love'); + expect(json['minLiveBytes'], 512); + expect(json['topClassCount'], 1); + expect( + json['cliCommand'], + 'devtools-profiler inspect-classes --class Love --min-live-bytes 512 ' + '--limit 1 /tmp/memory/session-1', + ); + expect(runner.lastReadMemoryPath, '/tmp/memory/session-1'); + expect(runner.lastMemoryClassQuery, 'Love'); + expect(runner.lastMinLiveBytes, 512); + expect(runner.readMemoryTopClassCounts, [1]); + final classes = json['classes'] as List; + expect(classes, hasLength(1)); + expect((classes.single as Map)['className'], 'LoveImage'); + }); + + test('inspect-classes prints artisanal memory class output', () async { + final result = await _runCliCommand(const [ + 'inspect-classes', + '--class', + 'Love', + '/tmp/memory/session-1', + ], runner: _FakeMemoryProfileRunner()); + + expect(result.exitCode, 0); + expect(result.stdout, contains('Memory Class Inspection')); + expect(result.stdout, contains('Classes')); + expect(result.stdout, contains('LoveImage')); + expect(result.stderr, isEmpty); + }); + + test('inspect-classes rejects negative min-live-bytes', () async { + final result = await _runCliCommand(const [ + 'inspect-classes', + '--min-live-bytes', + '-1', + '/tmp/memory/session-1', + ]); + + expect(result.exitCode, 64); + expect( + result.stderr, + contains('"min-live-bytes" option must be a non-negative integer'), + ); + }); + test('summarize hides runtime helper packages when requested', () async { final runner = _FakeProfileRunner(); final stdoutCapture = _OutputCapture(); @@ -997,6 +1238,141 @@ void main() { ); expect(stderrCapture.text, isEmpty); }); + + test('summarize warns when active filters remove every CPU frame', () async { + final runner = _FakeProfileRunner(); + final stdoutCapture = _OutputCapture(); + final stderrCapture = _OutputCapture(); + addTearDown(() async { + await stdoutCapture.close(); + await stderrCapture.close(); + }); + + final exitCode = await runCli( + const [ + 'summarize', + '--include-package', + 'missing_package', + '/tmp/profile.json', + ], + runner: runner, + output: stdoutCapture.sink, + errorOutput: stderrCapture.sink, + ); + await stdoutCapture.flush(); + + expect(exitCode, 0); + expect(stdoutCapture.text, contains('available before filtering')); + expect(stdoutCapture.text, contains('--include-package missing_package')); + expect(stdoutCapture.text, contains('Retry without those filters')); + expect(stderrCapture.text, isEmpty); + }); + + group('prepareRegionPresentation sample-count fallback', () { + test( + 'emits warning and preserves stored count when raw profile produces 0 samples', + () async { + final stdoutCapture = _OutputCapture(); + final stderrCapture = _OutputCapture(); + addTearDown(() async { + await stdoutCapture.close(); + await stderrCapture.close(); + }); + + final exitCode = await runCli( + const ['run', '--', 'dart', 'run', 'bin/main.dart'], + runner: _FakeRunnerWithZeroSamples(), + output: stdoutCapture.sink, + errorOutput: stderrCapture.sink, + ); + await stdoutCapture.flush(); + await stderrCapture.flush(); + + expect(exitCode, 0); + expect( + stdoutCapture.text, + contains('0 samples when re-read'), + reason: 'warning about zero re-read samples should appear', + ); + expect( + stdoutCapture.text, + contains('50 samples'), + reason: 'warning should mention the stored sample count', + ); + expect(stderrCapture.text, isEmpty); + }, + ); + + test( + 'no warning emitted when raw CPU profile produces non-zero samples', + () async { + final stdoutCapture = _OutputCapture(); + final stderrCapture = _OutputCapture(); + addTearDown(() async { + await stdoutCapture.close(); + await stderrCapture.close(); + }); + + final exitCode = await runCli( + const ['run', '--', 'dart', 'run', 'bin/main.dart'], + runner: _FakeProfileRunner(), + output: stdoutCapture.sink, + errorOutput: stderrCapture.sink, + ); + await stdoutCapture.flush(); + await stderrCapture.flush(); + + expect(exitCode, 0); + expect( + stdoutCapture.text, + isNot(contains('produced 0 samples when re-read')), + reason: 'no fallback warning when samples are healthy', + ); + expect(stderrCapture.text, isEmpty); + }, + ); + }); +} + +Future> _runJsonCommand( + List arguments, { + ProfileRunner? runner, +}) async { + final result = await _runCliCommand( + arguments, + runner: runner ?? _FakeProfileRunner(), + ); + + expect(result.exitCode, 0, reason: result.stderr); + expect(result.stderr, isEmpty); + return jsonDecode(result.stdout) as Map; +} + +Future<({int exitCode, String stdout, String stderr})> _runCliCommand( + List arguments, { + ProfileRunner? runner, +}) async { + final stdoutCapture = _OutputCapture(); + final stderrCapture = _OutputCapture(); + addTearDown(() async { + await stdoutCapture.close(); + await stderrCapture.close(); + }); + + final exitCode = await runCli( + arguments, + runner: runner ?? _FakeProfileRunner(), + output: stdoutCapture.sink, + errorOutput: stderrCapture.sink, + ); + await stdoutCapture.flush(); + await stderrCapture.flush(); + + return ( + exitCode: exitCode, + stdout: stdoutCapture.text, + stderr: stderrCapture.text, + ); } class _FakeProfileRunner extends ProfileRunner { @@ -1099,6 +1475,15 @@ class _FakeProfileRunner extends ProfileRunner { ], ); + static final _cpuSamplesEmpty = CpuSamples( + sampleCount: 0, + samplePeriod: 0, + timeOriginMicros: 0, + timeExtentMicros: 0, + functions: const [], + samples: const [], + ); + static final _region = ProfileRegionResult( regionId: 'region-1', name: 'cpu-burn', @@ -1311,6 +1696,9 @@ class _FakeProfileRunner extends ProfileRunner { @override Future readCpuSamples(String targetPath) async { + if (targetPath.contains('zero-samples')) { + return _cpuSamplesEmpty; + } if (targetPath == '/tmp/helper_profile.cpu.json') { return _cpuSamplesWithHelper; } @@ -1324,6 +1712,339 @@ class _FakeProfileRunner extends ProfileRunner { } } +class _FakeMemoryProfileRunner extends _FakeProfileRunner { + _FakeMemoryProfileRunner({this.failMemoryReads = false}); + + final bool failMemoryReads; + String? lastReadMemoryPath; + String? lastMemoryClassQuery; + int? lastMinLiveBytes; + final List readMemoryTopClassCounts = []; + + static const _baselineRawMemoryPath = + '/tmp/memory/session-1/overall/memory_profile.json'; + static const _currentRawMemoryPath = + '/tmp/memory/session-2/overall/memory_profile.json'; + + static final _memoryClasses = [ + const ProfileMemoryClassSummary( + className: 'LoveImage', + libraryUri: 'package:love2d/image.dart', + liveBytes: 2048, + liveBytesDelta: 512, + liveInstances: 2, + liveInstancesDelta: 1, + allocationBytesDelta: 4096, + allocationInstancesDelta: 4, + ), + const ProfileMemoryClassSummary( + className: 'LoveCanvas', + libraryUri: 'package:love2d/canvas.dart', + liveBytes: 1024, + liveBytesDelta: 256, + liveInstances: 1, + liveInstancesDelta: 1, + allocationBytesDelta: 2048, + allocationInstancesDelta: 2, + ), + const ProfileMemoryClassSummary( + className: 'WorkerBuffer', + libraryUri: 'package:fixture/buffer.dart', + liveBytes: 256, + liveBytesDelta: 128, + liveInstances: 1, + liveInstancesDelta: 0, + allocationBytesDelta: 512, + allocationInstancesDelta: 1, + ), + ]; + + @override + Future> summarizeArtifact(String path) async { + return switch (path) { + '/tmp/memory/session-1' => _sessionWithMemory( + _FakeProfileRunner._session, + _baselineRawMemoryPath, + ).toJson(), + '/tmp/memory/session-2' => _sessionWithMemory( + _FakeProfileRunner._sessionCurrent, + _currentRawMemoryPath, + ).toJson(), + _ => super.summarizeArtifact(path), + }; + } + + @override + Future readMemoryClasses( + String targetPath, { + String? classQuery, + int? minLiveBytes, + int topClassCount = 50, + }) async { + lastReadMemoryPath = targetPath; + lastMemoryClassQuery = classQuery; + lastMinLiveBytes = minLiveBytes; + readMemoryTopClassCounts.add(topClassCount); + if (failMemoryReads) { + throw StateError('raw memory artifact unavailable'); + } + + var classes = _memoryClasses + .where((item) { + if (classQuery != null && + !item.className.toLowerCase().contains( + classQuery.toLowerCase(), + )) { + return false; + } + if (minLiveBytes != null && item.liveBytes < minLiveBytes) { + return false; + } + return true; + }) + .toList(growable: false); + if (topClassCount > 0 && classes.length > topClassCount) { + classes = classes.take(topClassCount).toList(growable: false); + } + return _memoryResult(rawProfilePath: targetPath, classes: classes); + } + + static ProfileRunResult _sessionWithMemory( + ProfileRunResult session, + String rawMemoryPath, + ) { + return ProfileRunResult( + sessionId: session.sessionId, + command: session.command, + workingDirectory: session.workingDirectory, + exitCode: session.exitCode, + artifactDirectory: session.artifactDirectory, + vmServiceUri: session.vmServiceUri, + overallProfile: _regionWithMemory(session.overallProfile!, rawMemoryPath), + regions: session.regions, + warnings: session.warnings, + ); + } + + static ProfileRegionResult _regionWithMemory( + ProfileRegionResult region, + String rawMemoryPath, + ) { + return ProfileRegionResult( + regionId: region.regionId, + name: region.name, + attributes: region.attributes, + isolateId: region.isolateId, + isolateIds: region.isolateIds, + captureKinds: region.captureKinds, + isolateScope: region.isolateScope, + parentRegionId: region.parentRegionId, + memory: _memoryResult(rawProfilePath: rawMemoryPath), + startTimestampMicros: region.startTimestampMicros, + endTimestampMicros: region.endTimestampMicros, + durationMicros: region.durationMicros, + sampleCount: region.sampleCount, + samplePeriodMicros: region.samplePeriodMicros, + topSelfFrames: region.topSelfFrames, + topTotalFrames: region.topTotalFrames, + summaryPath: region.summaryPath, + rawProfilePath: region.rawProfilePath, + error: region.error, + ); + } + + static ProfileMemoryResult _memoryResult({ + required String rawProfilePath, + List? classes, + }) { + final topClasses = classes ?? _memoryClasses; + return ProfileMemoryResult.fromJson({ + 'start': _heapSampleJson(timestamp: 1, used: 1024), + 'end': _heapSampleJson(timestamp: 2, used: 4096), + 'deltaHeapBytes': 3072, + 'deltaExternalBytes': 128, + 'deltaCapacityBytes': 4096, + 'classCount': _memoryClasses.length, + 'topClasses': [for (final item in topClasses) item.toJson()], + 'rawProfilePath': rawProfilePath, + }); + } + + static Map _heapSampleJson({ + required int timestamp, + required int used, + }) { + return { + 'timestamp': timestamp, + 'rss': 0, + 'capacity': 8192, + 'used': used, + 'external': 0, + 'gc': false, + 'adb_memoryInfo': { + 'Realtime': 0, + 'Java Heap': 0, + 'Native Heap': 0, + 'Code': 0, + 'Stack': 0, + 'Graphics': 0, + 'Private Other': 0, + 'System': 0, + 'Total': 0, + }, + 'memory_eventInfo': { + 'timestamp': -1, + 'gcEvent': false, + 'snapshotEvent': false, + 'snapshotAutoEvent': false, + 'allocationAccumulatorEvent': { + 'start': false, + 'continues': false, + 'reset': false, + }, + 'extensionEvents': null, + }, + 'rasterCache': {'layerBytes': 0, 'pictureBytes': 0}, + }; + } +} + +class _FakeProfileRunnerWithSharedTrendRegion extends _FakeProfileRunner { + static const _profileId = 'startup'; + + static final _session1Region = _sharedRegion( + sessionId: 'session-1', + durationMicros: 1_400, + sampleCount: 7, + ); + + static final _session2Region = _sharedRegion( + sessionId: 'session-2', + durationMicros: 2_100, + sampleCount: 11, + ); + + static final _session3Region = _sharedRegion( + sessionId: 'session-3', + durationMicros: 2_700, + sampleCount: 14, + ); + + @override + Future> summarizeArtifact(String path) async { + return switch (path) { + '/tmp/artifacts/session-1' => _withRegion( + _FakeProfileRunner._session, + _session1Region, + ).toJson(), + '/tmp/artifacts/session-2' => _withRegion( + _FakeProfileRunner._sessionCurrent, + _session2Region, + ).toJson(), + '/tmp/artifacts/session-3' => _withRegion( + _FakeProfileRunner._sessionTrend, + _session3Region, + ).toJson(), + _ => super.summarizeArtifact(path), + }; + } + + static ProfileRunResult _withRegion( + ProfileRunResult session, + ProfileRegionResult region, + ) { + return ProfileRunResult( + sessionId: session.sessionId, + command: session.command, + workingDirectory: session.workingDirectory, + exitCode: session.exitCode, + artifactDirectory: session.artifactDirectory, + vmServiceUri: session.vmServiceUri, + overallProfile: session.overallProfile, + regions: [...session.regions, region], + warnings: session.warnings, + ); + } + + static ProfileRegionResult _sharedRegion({ + required String sessionId, + required int durationMicros, + required int sampleCount, + }) { + return ProfileRegionResult( + regionId: _profileId, + name: 'startup', + attributes: const {'phase': 'startup'}, + isolateId: 'isolates/123', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 100, + endTimestampMicros: 100 + durationMicros, + durationMicros: durationMicros, + sampleCount: sampleCount, + samplePeriodMicros: 50, + topSelfFrames: const [], + topTotalFrames: const [], + summaryPath: '/tmp/artifacts/$sessionId/regions/$_profileId/summary.json', + rawProfilePath: + '/tmp/artifacts/$sessionId/regions/$_profileId/cpu_profile.json', + ); + } +} + +class _FakeRunnerWithZeroSamples extends _FakeProfileRunner { + static const _zeroRawPath = + '/tmp/zero-samples/regions/zero-region/cpu_profile.json'; + + static final _zeroRegion = ProfileRegionResult( + regionId: 'zero-region', + name: 'zero-render', + attributes: const {}, + isolateId: 'isolates/1', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 0, + endTimestampMicros: 1_000_000, + durationMicros: 1_000_000, + sampleCount: 50, + samplePeriodMicros: 1_000, + topSelfFrames: const [], + topTotalFrames: const [], + summaryPath: '/tmp/zero-samples/regions/zero-region/summary.json', + rawProfilePath: _zeroRawPath, + ); + + static final _zeroOverall = ProfileRegionResult( + regionId: 'overall', + name: 'whole-session', + attributes: const {'scope': 'session'}, + isolateId: 'isolates/1', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 0, + endTimestampMicros: 1_000_000, + durationMicros: 1_000_000, + sampleCount: 50, + samplePeriodMicros: 1_000, + topSelfFrames: const [], + topTotalFrames: const [], + summaryPath: '/tmp/zero-samples/overall/summary.json', + rawProfilePath: '/tmp/zero-samples/overall/cpu_profile.json', + ); + + @override + Future run(ProfileRunRequest request) async { + return ProfileRunResult( + sessionId: 'session-zero', + command: request.command, + workingDirectory: request.workingDirectory ?? '/workspace', + exitCode: 0, + artifactDirectory: '/tmp/zero-samples', + vmServiceUri: 'http://127.0.0.1:8181/zero/', + overallProfile: _zeroOverall, + regions: [_zeroRegion], + warnings: const [], + ); + } +} + class _OutputCapture { _OutputCapture() { sink = IOSink(_controller.sink); diff --git a/packages/devtools_profiler_cli/test/mcp_server_test.dart b/packages/devtools_profiler_cli/test/mcp_server_test.dart index 301abfd..728d495 100644 --- a/packages/devtools_profiler_cli/test/mcp_server_test.dart +++ b/packages/devtools_profiler_cli/test/mcp_server_test.dart @@ -38,6 +38,7 @@ void main() { 'profile_compare', 'profile_analyze_trends', 'profile_find_regressions', + 'profile_inspect_classes', ]), ); @@ -705,6 +706,122 @@ void main() { expect((methods.first as Map)['name'], 'Worker.hotLeaf'); }); + test('compares memory classes with an unlimited MCP limit', () async { + final runner = _FakeMemoryProfileRunner(); + final environment = _McpTestEnvironment(runner); + addTearDown(environment.shutdown); + await _initializeServer(environment); + + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_compare', + arguments: { + 'baselinePath': '/tmp/memory/session-1', + 'currentPath': '/tmp/memory/session-2', + 'memoryClassLimit': 0, + }, + ), + ); + + expect(result.isError, isNot(true)); + expect(runner.readMemoryTopClassCounts, [0, 0]); + final payload = result.structuredContent!; + final comparison = payload['comparison'] as Map; + final memory = comparison['memory'] as Map; + final topClasses = memory['topClasses'] as List; + expect(topClasses, hasLength(3)); + }); + + test('rejects negative MCP memory comparison limits', () async { + final environment = _McpTestEnvironment(_FakeProfileRunner()); + addTearDown(environment.shutdown); + await _initializeServer(environment); + + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_compare', + arguments: { + 'baselinePath': '/tmp/artifacts/session-1', + 'currentPath': '/tmp/artifacts/session-2', + 'memoryClassLimit': -1, + }, + ), + ); + + expect(result.isError, isTrue); + expect( + (result.content.single as TextContent).text, + contains('"memoryClassLimit" argument must be a non-negative integer'), + ); + }); + + test('inspects memory classes for agents', () async { + final runner = _FakeMemoryProfileRunner(); + final environment = _McpTestEnvironment(runner); + addTearDown(environment.shutdown); + await _initializeServer(environment); + + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_inspect_classes', + arguments: { + 'path': '/tmp/memory/session-1', + 'classQuery': 'Love', + 'minLiveBytes': 512, + 'limit': 1, + }, + ), + ); + + expect(result.isError, isNot(true)); + expect(runner.lastReadMemoryPath, '/tmp/memory/session-1'); + expect(runner.lastMemoryClassQuery, 'Love'); + expect(runner.lastMinLiveBytes, 512); + expect(runner.readMemoryTopClassCounts, [1]); + final payload = result.structuredContent!; + expect(payload['kind'], 'memoryClassInspection'); + final classes = payload['classes'] as List; + expect(classes, hasLength(1)); + expect((classes.single as Map)['className'], 'LoveImage'); + }); + + test('inspects memory classes with MCP default class limit', () async { + final runner = _FakeMemoryProfileRunner(); + final environment = _McpTestEnvironment(runner); + addTearDown(environment.shutdown); + await _initializeServer(environment); + + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_inspect_classes', + arguments: {'path': '/tmp/memory/session-1'}, + ), + ); + + expect(result.isError, isNot(true)); + expect(runner.readMemoryTopClassCounts, [50]); + expect(result.structuredContent!['topClassCount'], 50); + }); + + test('rejects negative MCP inspect-classes minLiveBytes', () async { + final environment = _McpTestEnvironment(_FakeProfileRunner()); + addTearDown(environment.shutdown); + await _initializeServer(environment); + + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_inspect_classes', + arguments: {'path': '/tmp/memory/session-1', 'minLiveBytes': -1}, + ), + ); + + expect(result.isError, isTrue); + expect( + (result.content.single as TextContent).text, + contains('"minLiveBytes" argument must be a non-negative integer'), + ); + }); + test('analyzes trends across explicit session paths for agents', () async { final environment = _McpTestEnvironment(_FakeProfileRunner()); addTearDown(environment.shutdown); @@ -1410,3 +1527,203 @@ class _FakeProfileRunner extends ProfileRunner { return _cpuSamples; } } + +class _FakeMemoryProfileRunner extends _FakeProfileRunner { + String? lastReadMemoryPath; + String? lastMemoryClassQuery; + int? lastMinLiveBytes; + final List readMemoryTopClassCounts = []; + + static const _baselineRawMemoryPath = + '/tmp/memory/session-1/overall/memory_profile.json'; + static const _currentRawMemoryPath = + '/tmp/memory/session-2/overall/memory_profile.json'; + + static final _memoryClasses = [ + const ProfileMemoryClassSummary( + className: 'LoveImage', + libraryUri: 'package:love2d/image.dart', + liveBytes: 2048, + liveBytesDelta: 512, + liveInstances: 2, + liveInstancesDelta: 1, + allocationBytesDelta: 4096, + allocationInstancesDelta: 4, + ), + const ProfileMemoryClassSummary( + className: 'LoveCanvas', + libraryUri: 'package:love2d/canvas.dart', + liveBytes: 1024, + liveBytesDelta: 256, + liveInstances: 1, + liveInstancesDelta: 1, + allocationBytesDelta: 2048, + allocationInstancesDelta: 2, + ), + const ProfileMemoryClassSummary( + className: 'WorkerBuffer', + libraryUri: 'package:fixture/buffer.dart', + liveBytes: 256, + liveBytesDelta: 128, + liveInstances: 1, + liveInstancesDelta: 0, + allocationBytesDelta: 512, + allocationInstancesDelta: 1, + ), + ]; + + @override + Future> summarizeArtifact(String targetPath) async { + return switch (targetPath) { + '/tmp/memory/session-1' => _sessionWithMemory( + sessionId: 'session-1', + artifactDirectory: '/tmp/memory/session-1', + vmServiceUri: 'http://127.0.0.1:8181/abcd/', + region: _FakeProfileRunner._overallProfile, + rawMemoryPath: _baselineRawMemoryPath, + ).toJson(), + '/tmp/memory/session-2' => _sessionWithMemory( + sessionId: 'session-2', + artifactDirectory: '/tmp/memory/session-2', + vmServiceUri: 'http://127.0.0.1:8181/efgh/', + region: _FakeProfileRunner._overallProfileCurrent, + rawMemoryPath: _currentRawMemoryPath, + ).toJson(), + _ => super.summarizeArtifact(targetPath), + }; + } + + @override + Future readMemoryClasses( + String targetPath, { + String? classQuery, + int? minLiveBytes, + int topClassCount = 50, + }) async { + lastReadMemoryPath = targetPath; + lastMemoryClassQuery = classQuery; + lastMinLiveBytes = minLiveBytes; + readMemoryTopClassCounts.add(topClassCount); + + var classes = _memoryClasses + .where((item) { + if (classQuery != null && + !item.className.toLowerCase().contains( + classQuery.toLowerCase(), + )) { + return false; + } + if (minLiveBytes != null && item.liveBytes < minLiveBytes) { + return false; + } + return true; + }) + .toList(growable: false); + if (topClassCount > 0 && classes.length > topClassCount) { + classes = classes.take(topClassCount).toList(growable: false); + } + return _memoryResult(rawProfilePath: targetPath, classes: classes); + } + + static ProfileRunResult _sessionWithMemory({ + required String sessionId, + required String artifactDirectory, + required String vmServiceUri, + required ProfileRegionResult region, + required String rawMemoryPath, + }) { + return ProfileRunResult( + sessionId: sessionId, + command: const ['dart', 'run', 'bin/main.dart'], + workingDirectory: '/workspace', + exitCode: 0, + artifactDirectory: artifactDirectory, + vmServiceUri: vmServiceUri, + overallProfile: _regionWithMemory(region, rawMemoryPath), + regions: const [], + warnings: const [], + ); + } + + static ProfileRegionResult _regionWithMemory( + ProfileRegionResult region, + String rawMemoryPath, + ) { + return ProfileRegionResult( + regionId: region.regionId, + name: region.name, + attributes: region.attributes, + isolateId: region.isolateId, + isolateIds: region.isolateIds, + captureKinds: region.captureKinds, + isolateScope: region.isolateScope, + parentRegionId: region.parentRegionId, + memory: _memoryResult(rawProfilePath: rawMemoryPath), + startTimestampMicros: region.startTimestampMicros, + endTimestampMicros: region.endTimestampMicros, + durationMicros: region.durationMicros, + sampleCount: region.sampleCount, + samplePeriodMicros: region.samplePeriodMicros, + topSelfFrames: region.topSelfFrames, + topTotalFrames: region.topTotalFrames, + summaryPath: region.summaryPath, + rawProfilePath: region.rawProfilePath, + error: region.error, + ); + } + + static ProfileMemoryResult _memoryResult({ + required String rawProfilePath, + List? classes, + }) { + final topClasses = classes ?? _memoryClasses; + return ProfileMemoryResult.fromJson({ + 'start': _heapSampleJson(timestamp: 1, used: 1024), + 'end': _heapSampleJson(timestamp: 2, used: 4096), + 'deltaHeapBytes': 3072, + 'deltaExternalBytes': 128, + 'deltaCapacityBytes': 4096, + 'classCount': _memoryClasses.length, + 'topClasses': [for (final item in topClasses) item.toJson()], + 'rawProfilePath': rawProfilePath, + }); + } + + static Map _heapSampleJson({ + required int timestamp, + required int used, + }) { + return { + 'timestamp': timestamp, + 'rss': 0, + 'capacity': 8192, + 'used': used, + 'external': 0, + 'gc': false, + 'adb_memoryInfo': { + 'Realtime': 0, + 'Java Heap': 0, + 'Native Heap': 0, + 'Code': 0, + 'Stack': 0, + 'Graphics': 0, + 'Private Other': 0, + 'System': 0, + 'Total': 0, + }, + 'memory_eventInfo': { + 'timestamp': -1, + 'gcEvent': false, + 'snapshotEvent': false, + 'snapshotAutoEvent': false, + 'allocationAccumulatorEvent': { + 'start': false, + 'continues': false, + 'reset': false, + }, + 'extensionEvents': null, + }, + 'rasterCache': {'layerBytes': 0, 'pictureBytes': 0}, + }; + } +} diff --git a/packages/devtools_profiler_core/CHANGELOG.md b/packages/devtools_profiler_core/CHANGELOG.md index 3fb1134..49d329e 100644 --- a/packages/devtools_profiler_core/CHANGELOG.md +++ b/packages/devtools_profiler_core/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.2.0-wip + +- Added memory class artifact inspection helpers for stored session, region, and + raw memory profile artifacts. +- Added memory class comparison filters for minimum live bytes and class count + limits. +- Added optional DTD disabling for attach sessions when region markers are not + needed or the tooling daemon cannot start. +- Normalized artifact output directories to fully resolved paths and kept + relative paths anchored against the profiled working directory. +- Allowed artifact readers to summarize per-profile artifact directories such + as `overall/` and `regions//` in addition to `summary.json` files. +- Improved package detection for local checkout `file://...//lib/...` + CPU frames so package filters work outside `.pub-cache`. + ## 0.1.0 - Initial release of the pure-Dart profiler backend. diff --git a/packages/devtools_profiler_core/lib/src/analysis/profile_region_comparison.dart b/packages/devtools_profiler_core/lib/src/analysis/profile_region_comparison.dart index 73c6826..452cd56 100644 --- a/packages/devtools_profiler_core/lib/src/analysis/profile_region_comparison.dart +++ b/packages/devtools_profiler_core/lib/src/analysis/profile_region_comparison.dart @@ -15,6 +15,8 @@ ProfileRegionComparison compareProfileRegions({ int? frameLimit, int? methodLimit, int? memoryClassLimit, + ProfileMemoryResult? baselineMemoryOverride, + ProfileMemoryResult? currentMemoryOverride, }) { final warnings = []; if (baseline.name != current.name) { @@ -35,7 +37,9 @@ ProfileRegionComparison compareProfileRegions({ '${baseline.isolateScope.name} vs ${current.isolateScope.name}.', ); } - if ((baseline.memory == null) != (current.memory == null)) { + final effectiveBaselineMemory = baselineMemoryOverride ?? baseline.memory; + final effectiveCurrentMemory = currentMemoryOverride ?? current.memory; + if ((effectiveBaselineMemory == null) != (effectiveCurrentMemory == null)) { warnings.add('Memory data was only available for one compared profile.'); } if ((baselineMethodTable == null) != (currentMethodTable == null)) { @@ -74,7 +78,7 @@ ProfileRegionComparison compareProfileRegions({ methodLimit, ); - final memory = switch ((baseline.memory, current.memory)) { + final memory = switch ((effectiveBaselineMemory, effectiveCurrentMemory)) { (null, null) => null, (final ProfileMemoryResult left, final ProfileMemoryResult right) => _buildMemoryComparison(left, right, memoryClassLimit: memoryClassLimit), diff --git a/packages/devtools_profiler_core/lib/src/capture/artifacts.dart b/packages/devtools_profiler_core/lib/src/capture/artifacts.dart index 114757c..2a55593 100644 --- a/packages/devtools_profiler_core/lib/src/capture/artifacts.dart +++ b/packages/devtools_profiler_core/lib/src/capture/artifacts.dart @@ -8,6 +8,7 @@ import 'package:vm_service/vm_service.dart'; import '../cpu/call_tree.dart'; import '../cpu/cpu_profile_summary.dart'; import '../memory/memory_models.dart'; +import '../memory/memory_profile_summary.dart'; import 'models.dart'; /// Utilities for reading and summarizing profiler artifacts. @@ -18,6 +19,7 @@ import 'models.dart'; /// - session directories written by [ProfileArtifactStore] /// - region `summary.json` files /// - raw `cpu_profile.json` files +/// - raw `memory_profile.json` files class ProfileArtifacts { /// Reads a session artifact directory and returns the stored session result. static Future readSession(String directoryPath) async { @@ -37,7 +39,27 @@ class ProfileArtifacts { final entityType = FileSystemEntity.typeSync(targetPath); switch (entityType) { case FileSystemEntityType.directory: - return (await readSession(targetPath)).toJson(); + final sessionFile = _sessionFileFor(targetPath); + if (sessionFile.existsSync()) { + return (await readSession(targetPath)).toJson(); + } + final summaryFile = _summaryFileFor(targetPath); + if (summaryFile.existsSync()) { + return readArtifact(summaryFile.path); + } + final rawCpuFile = _rawCpuFileFor(targetPath); + if (rawCpuFile.existsSync()) { + return readArtifact(rawCpuFile.path); + } + final rawMemoryFile = _rawMemoryFileFor(targetPath); + if (rawMemoryFile.existsSync()) { + return readArtifact(rawMemoryFile.path); + } + throw ArgumentError.value( + targetPath, + 'targetPath', + 'No profiler artifact found in directory', + ); case FileSystemEntityType.file: final text = await File(targetPath).readAsString(); Object? decoded; @@ -66,15 +88,40 @@ class ProfileArtifacts { /// Summarizes an artifact directory or raw CPU profile JSON file. /// - /// Region summary files are returned as-is. Raw CPU profile files are lifted - /// into a synthesized [ProfileRegionResult]-style summary so downstream tools - /// can treat them like other profiler artifacts. + /// Session directories, per-profile artifact directories, `session.json`, + /// region `summary.json` files, and raw CPU profile files are accepted. Raw + /// CPU profile files are lifted into a synthesized [ProfileRegionResult]- + /// style summary so downstream tools can treat them like other profiler + /// artifacts. static Future> summarizeArtifact( String targetPath, ) async { final entityType = FileSystemEntity.typeSync(targetPath); if (entityType == FileSystemEntityType.directory) { - return (await readSession(targetPath)).toJson(); + final sessionFile = _sessionFileFor(targetPath); + if (sessionFile.existsSync()) { + return (await readSession(targetPath)).toJson(); + } + final summaryFile = _summaryFileFor(targetPath); + if (summaryFile.existsSync()) { + return summarizeArtifact(summaryFile.path); + } + final rawCpuFile = _rawCpuFileFor(targetPath); + if (rawCpuFile.existsSync()) { + return summarizeArtifact(rawCpuFile.path); + } + final rawMemoryFile = _rawMemoryFileFor(targetPath); + if (rawMemoryFile.existsSync()) { + return summarizeArtifact(rawMemoryFile.path); + } + throw ArgumentError.value( + targetPath, + 'targetPath', + 'No profiler summary found in directory', + ); + } + if (entityType != FileSystemEntityType.file) { + throw ArgumentError.value(targetPath, 'targetPath', 'Artifact not found'); } final json = jsonDecode(await File(targetPath).readAsString()) as Map; @@ -132,6 +179,115 @@ class ProfileArtifacts { return buildCallTree(cpuSamples: await readCpuSamples(targetPath)); } + /// Reads and filters memory class data from a stored profiling artifact. + /// + /// [targetPath] may be a session directory, a region `summary.json` file, + /// or a raw `memory_profile.json` file. Session directories resolve to the + /// whole-session overall profile. + /// + /// When [classQuery] is provided, only classes whose name contains the query + /// (case-insensitive) are included. When [minLiveBytes] is provided, only + /// classes with at least that many live bytes at the end of the window are + /// included. When both are provided, both conditions must hold. + /// + /// Pass [topClassCount] as 0 for unlimited results; otherwise the result is + /// truncated to that many classes after sorting by allocation-bytes delta. + static Future readMemoryClasses( + String targetPath, { + String? classQuery, + int? minLiveBytes, + int topClassCount = 50, + }) async { + final rawMemoryPath = await _resolveRawMemoryPath(targetPath); + + final query = classQuery?.toLowerCase().trim(); + final normalizedQuery = query?.isEmpty == true ? null : query; + ProfileMemoryClassPredicate? predicate; + if (normalizedQuery != null || minLiveBytes != null) { + predicate = (summary) => + (normalizedQuery == null || + summary.className.toLowerCase().contains(normalizedQuery)) && + (minLiveBytes == null || summary.liveBytes >= minLiveBytes); + } + + return readMemoryClassesFromArtifact( + rawMemoryPath, + includeClass: predicate, + topClassCount: topClassCount, + ); + } + + static Future _resolveRawMemoryPath(String targetPath) async { + final entityType = FileSystemEntity.typeSync(targetPath); + if (entityType == FileSystemEntityType.directory) { + final sessionFile = _sessionFileFor(targetPath); + if (sessionFile.existsSync()) { + final session = await readSession(targetPath); + return _rawMemoryPathForSession(session, targetPath); + } + final summaryFile = _summaryFileFor(targetPath); + if (summaryFile.existsSync()) { + return _resolveRawMemoryPath(summaryFile.path); + } + final rawMemoryFile = _rawMemoryFileFor(targetPath); + if (rawMemoryFile.existsSync()) { + return rawMemoryFile.path; + } + throw ArgumentError.value(targetPath, 'targetPath', 'Artifact not found'); + } + if (entityType != FileSystemEntityType.file) { + throw ArgumentError.value(targetPath, 'targetPath', 'Artifact not found'); + } + + final json = + jsonDecode(await File(targetPath).readAsString()) + as Map; + final map = json.cast(); + + if (map['type'] == 'ProfileMemoryArtifact') { + return targetPath; + } + + if (map case {'topSelfFrames': final Object? _}) { + final region = ProfileRegionResult.fromJson(map); + final rawPath = region.memory?.rawProfilePath; + if (rawPath == null || rawPath.isEmpty) { + throw StateError( + 'No memory profile is available for the region at "$targetPath". ' + 'Re-run the target with memory capture enabled.', + ); + } + return rawPath; + } + + if (map case {'regions': final Object? _}) { + return _rawMemoryPathForSession( + ProfileRunResult.fromJson(map), + targetPath, + ); + } + + throw ArgumentError.value( + targetPath, + 'targetPath', + 'Unsupported artifact type for memory class inspection.', + ); + } + + static String _rawMemoryPathForSession( + ProfileRunResult session, + String targetPath, + ) { + final rawPath = session.overallProfile?.memory?.rawProfilePath; + if (rawPath == null || rawPath.isEmpty) { + throw StateError( + 'No memory profile is available for the session at "$targetPath". ' + 'Re-run the target with memory capture enabled.', + ); + } + return rawPath; + } + static Future _cpuSamplesFromArtifact( Map map, { required String targetPath, @@ -165,6 +321,30 @@ class ProfileArtifacts { 'Unsupported artifact type for CPU sample loading.', ); } + + static File _sessionFileFor(String directoryPath) { + return File( + path.join(directoryPath, ProfileArtifactStore._sessionFileName), + ); + } + + static File _summaryFileFor(String directoryPath) { + return File( + path.join(directoryPath, ProfileArtifactStore._summaryFileName), + ); + } + + static File _rawCpuFileFor(String directoryPath) { + return File( + path.join(directoryPath, ProfileArtifactStore._rawProfileFileName), + ); + } + + static File _rawMemoryFileFor(String directoryPath) { + return File( + path.join(directoryPath, ProfileArtifactStore._rawMemoryProfileFileName), + ); + } } /// Writes session and region artifacts for a profiling run. diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_attach_request.dart b/packages/devtools_profiler_core/lib/src/capture/profile_attach_request.dart index 422c7a4..4f834f1 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_attach_request.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_attach_request.dart @@ -10,6 +10,7 @@ class ProfileAttachRequest { required this.duration, this.workingDirectory, this.artifactDirectory, + this.enableDtd = true, }); /// The HTTP URI printed by the Dart or Flutter VM service. @@ -25,4 +26,10 @@ class ProfileAttachRequest { /// /// When omitted, a session directory will be created under `.dart_tool`. final String? artifactDirectory; + + /// Whether to start the Dart Tooling Daemon for this attach session. + /// + /// Set to false when region markers are not needed and the tooling daemon + /// would cause startup failures. + final bool enableDtd; } 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 a133cb1..738d4e9 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as path; import 'package:vm_service/vm_service.dart'; import '../cpu/call_tree.dart'; +import '../memory/memory_models.dart'; import 'artifacts.dart'; import 'models.dart'; import 'runner/dtd_process_session.dart'; @@ -39,18 +40,13 @@ class ProfileRunner { validateProfileCommand(request.command); final sessionId = generateProfileSessionId(); - final workingDirectory = path.normalize( - request.workingDirectory ?? Directory.current.path, - ); + final workingDirectory = _resolveWorkingDirectory(request.workingDirectory); final artifactDirectory = Directory( - request.artifactDirectory ?? - path.join( - workingDirectory, - '.dart_tool', - 'devtools_profiler', - 'sessions', - sessionId, - ), + _resolveArtifactDirectory( + requestedDirectory: request.artifactDirectory, + sessionId: sessionId, + workingDirectory: workingDirectory, + ), ); final artifactStore = ProfileArtifactStore(artifactDirectory); await artifactStore.create(); @@ -152,35 +148,37 @@ class ProfileRunner { } final sessionId = generateProfileSessionId(); - final workingDirectory = path.normalize( - request.workingDirectory ?? Directory.current.path, - ); + final workingDirectory = _resolveWorkingDirectory(request.workingDirectory); final artifactDirectory = Directory( - request.artifactDirectory ?? - path.join( - workingDirectory, - '.dart_tool', - 'devtools_profiler', - 'sessions', - sessionId, - ), + _resolveArtifactDirectory( + requestedDirectory: request.artifactDirectory, + sessionId: sessionId, + workingDirectory: workingDirectory, + ), ); final artifactStore = ProfileArtifactStore(artifactDirectory); await artifactStore.create(); - final dtdSession = await DtdProcessSession.start(); + final DtdProcessSession? dtdSession = request.enableDtd + ? await DtdProcessSession.start() + : null; final sessionController = ProfileSessionController( artifactStore: artifactStore, childProcessId: null, - dtd: dtdSession.daemon, + dtd: dtdSession?.daemon, sessionId: sessionId, ); try { await sessionController.registerServices(); sessionController.addWarning( - 'Attached to an existing VM service. Explicit region markers are only available if the target process was started with this profiler session configuration.', + 'Attach mode captured an existing VM-service process. Explicit region markers are unavailable unless the target was launched by devtools-profiler run.', ); + if (!request.enableDtd) { + sessionController.addWarning( + 'The Dart Tooling Daemon was disabled for this attach session. Explicit region markers are unavailable.', + ); + } await sessionController.attachToVmService( request.vmServiceUri, clearCpuSamples: true, @@ -199,7 +197,7 @@ class ProfileRunner { return result; } finally { await sessionController.dispose(); - await dtdSession.dispose(); + await dtdSession?.dispose(); } } @@ -233,4 +231,53 @@ class ProfileRunner { Future readCallTree(String targetPath) { return ProfileArtifacts.readCallTree(targetPath); } + + /// Reads and filters memory class data from a stored profiling artifact. + /// + /// [targetPath] may be a session directory, a region `summary.json` file, + /// or a raw `memory_profile.json` file. + /// + /// When [classQuery] is provided, only classes whose name contains the query + /// (case-insensitive) are included. When [minLiveBytes] is provided, only + /// classes with at least that many live bytes at the end of the window are + /// included. + /// + /// Pass [topClassCount] as 0 for unlimited results. + Future readMemoryClasses( + String targetPath, { + String? classQuery, + int? minLiveBytes, + int topClassCount = 50, + }) { + return ProfileArtifacts.readMemoryClasses( + targetPath, + classQuery: classQuery, + minLiveBytes: minLiveBytes, + topClassCount: topClassCount, + ); + } +} + +String _resolveWorkingDirectory(String? workingDirectory) { + return path.normalize( + path.absolute(workingDirectory ?? Directory.current.path), + ); +} + +String _resolveArtifactDirectory({ + required String? requestedDirectory, + required String sessionId, + required String workingDirectory, +}) { + return switch (requestedDirectory) { + final String dir when path.isAbsolute(dir) => path.normalize(dir), + final String dir => path.normalize(path.join(workingDirectory, dir)), + null => path.join( + workingDirectory, + '.dart_tool', + 'devtools_profiler', + 'sessions', + sessionId, + ), + }; } diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_context.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_context.dart index 333724c..5e90b0a 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_context.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_context.dart @@ -17,7 +17,12 @@ final class ProfileSessionContext { }); final ProfileArtifactStore artifactStore; - final DartToolingDaemon dtd; + + /// Optional Dart Tooling Daemon connection for region-marker RPC/events. + /// + /// When null, region markers are unavailable for this session, but + /// whole-session VM-service capture still works. + final DartToolingDaemon? dtd; final String sessionId; final List regions = []; 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 f653889..016ae54 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 @@ -14,7 +14,7 @@ final class ProfileSessionController { ProfileSessionController({ required ProfileArtifactStore artifactStore, required int? childProcessId, - required DartToolingDaemon dtd, + required DartToolingDaemon? dtd, required String sessionId, }) : context = ProfileSessionContext( artifactStore: artifactStore, @@ -47,22 +47,23 @@ final class ProfileSessionController { /// Registers the profiler DTD services for this session. Future registerServices() async { - await context.dtd.registerService( + if (context.dtd == null) return; + await context.dtd!.registerService( profilerControlService, getSessionInfoMethod, handleGetSessionInfo, ); - await context.dtd.registerService( + await context.dtd!.registerService( profilerControlService, pingMethod, handlePing, ); - await context.dtd.registerService( + await context.dtd!.registerService( profilerControlService, startRegionMethod, handleStartRegion, ); - await context.dtd.registerService( + await context.dtd!.registerService( profilerControlService, stopRegionMethod, handleStopRegion, diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_region_rpc.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_region_rpc.dart index 2ba0510..cfab33a 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_region_rpc.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_region_rpc.dart @@ -271,8 +271,9 @@ final class ProfileSessionRegionRpcHandler { required int timestampMicros, Map extraData = const {}, }) async { + if (context.dtd == null) return; try { - await context.dtd.postEvent(regionEventStream, kind, { + await context.dtd!.postEvent(regionEventStream, kind, { 'sessionId': context.sessionId, 'regionId': region.regionId, 'name': region.name, diff --git a/packages/devtools_profiler_core/lib/src/cpu/profile_frames.dart b/packages/devtools_profiler_core/lib/src/cpu/profile_frames.dart index b92379c..2905612 100644 --- a/packages/devtools_profiler_core/lib/src/cpu/profile_frames.dart +++ b/packages/devtools_profiler_core/lib/src/cpu/profile_frames.dart @@ -53,7 +53,7 @@ class ProfileFrame { /// Whether the frame belongs to SDK-managed libraries. bool get isSdk => isDartCore || isFlutterCore; - /// The package name for `package:` or pub-cache sourced frames. + /// The package name for `package:`, pub-cache, or local package frames. String? get packageName { final source = location; if (source == null || source.isEmpty) { @@ -70,17 +70,7 @@ class ProfileFrame { return null; } - final filePath = parsedUri.toFilePath(); - final segments = path.split(path.normalize(filePath)); - final pubCacheIndex = segments.lastIndexOf('.pub-cache'); - if (pubCacheIndex == -1) { - return null; - } - final libIndex = segments.indexOf('lib', pubCacheIndex); - if (libIndex == -1 || libIndex <= pubCacheIndex + 1) { - return null; - } - return _packageNameFromPubCacheFolder(segments[libIndex - 1]); + return _packageNameFromFilePath(parsedUri.toFilePath()); } /// Whether the frame represents native code. @@ -94,6 +84,29 @@ class ProfileFrame { } } +String? _packageNameFromFilePath(String filePath) { + final segments = path.split(path.normalize(filePath)); + + final pubCacheIndex = segments.lastIndexOf('.pub-cache'); + if (pubCacheIndex != -1) { + final libIndex = segments.indexOf('lib', pubCacheIndex); + if (libIndex == -1 || libIndex <= pubCacheIndex + 1) { + return null; + } + return _packageNameFromPubCacheFolder(segments[libIndex - 1]); + } + + final libIndex = segments.indexOf('lib'); + if (libIndex <= 0) { + return null; + } + final packageDirectoryName = segments[libIndex - 1]; + if (packageDirectoryName.isEmpty || packageDirectoryName == path.separator) { + return null; + } + return _packageNameFromPubCacheFolder(packageDirectoryName); +} + String _packageNameFromPubCacheFolder(String folder) { final versionMatch = RegExp( r'^(.+)-(\d+\.\d+\.\d+(?:[-+].*)?)$', diff --git a/packages/devtools_profiler_core/lib/src/memory/memory_profile_summary.dart b/packages/devtools_profiler_core/lib/src/memory/memory_profile_summary.dart index 5a978d5..9db0b73 100644 --- a/packages/devtools_profiler_core/lib/src/memory/memory_profile_summary.dart +++ b/packages/devtools_profiler_core/lib/src/memory/memory_profile_summary.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:devtools_shared/devtools_shared.dart'; import 'package:vm_service/vm_service.dart'; @@ -61,6 +64,121 @@ ProfileMemoryResult summarizeMemoryProfile({ ); } +/// Parses a raw `ProfileMemoryArtifact` JSON map and returns a +/// [ProfileMemoryResult] with optional class filtering. +/// +/// [rawArtifact] must be the decoded JSON of a `memory_profile.json` file +/// produced by [buildRawMemoryPayload]. +/// +/// When [includeClass] is supplied, only classes that pass the predicate are +/// included in [ProfileMemoryResult.topClasses]. +/// +/// Pass [topClassCount] as 0 for unlimited results; otherwise the result is +/// truncated to that many classes after sorting. +/// +/// This rebuild path defaults to 50 classes for deep-dive inspection, while +/// [summarizeMemoryProfile] defaults to 10 for brief capture summaries. +ProfileMemoryResult rebuildMemoryProfileFromArtifact( + Map rawArtifact, { + required String rawProfilePath, + ProfileMemoryClassPredicate? includeClass, + int topClassCount = 50, +}) { + final startMap = _requiredArtifactMap( + rawArtifact['start'], + label: 'start', + rawProfilePath: rawProfilePath, + ); + final endMap = _requiredArtifactMap( + rawArtifact['end'], + label: 'end', + rawProfilePath: rawProfilePath, + ); + + final start = HeapSample.fromJson( + _requiredArtifactMap( + startMap['heapSample'], + label: 'start.heapSample', + rawProfilePath: rawProfilePath, + ), + ); + final end = HeapSample.fromJson( + _requiredArtifactMap( + endMap['heapSample'], + label: 'end.heapSample', + rawProfilePath: rawProfilePath, + ), + ); + + final startClasses = _extractClassStats(startMap); + final endClasses = _extractClassStats(endMap); + + return summarizeMemoryProfile( + start: start, + end: end, + startClasses: startClasses, + endClasses: endClasses, + rawProfilePath: rawProfilePath, + topClassCount: topClassCount, + includeClass: includeClass, + ); +} + +/// Reads a raw `memory_profile.json` artifact from [rawProfilePath] and +/// returns a [ProfileMemoryResult] with optional class filtering. +/// +/// When [includeClass] is supplied, only classes that pass the predicate are +/// included in [ProfileMemoryResult.topClasses]. +/// +/// Pass [topClassCount] as 0 for unlimited results. +/// +/// This artifact-reader defaults to 50 classes for inspect-style deep dives; +/// use [summarizeMemoryProfile] for the shorter 10-class summary default. +Future readMemoryClassesFromArtifact( + String rawProfilePath, { + ProfileMemoryClassPredicate? includeClass, + int topClassCount = 50, +}) async { + final json = + jsonDecode(await File(rawProfilePath).readAsString()) + as Map; + return rebuildMemoryProfileFromArtifact( + json.cast(), + rawProfilePath: rawProfilePath, + includeClass: includeClass, + topClassCount: topClassCount, + ); +} + +Map _requiredArtifactMap( + Object? value, { + required String label, + required String rawProfilePath, +}) { + if (value is Map) { + return value.cast(); + } + throw FormatException( + 'Invalid memory profile artifact at "$rawProfilePath": expected "$label" ' + 'to be an object.', + ); +} + +/// Extracts the flat list of [ClassHeapStats] from one snapshot half of a +/// raw `ProfileMemoryArtifact` (either the `start` or `end` map). +List _extractClassStats(Map snapshot) { + final profiles = (snapshot['profiles'] as List? ?? const []) + .cast>(); + final result = []; + for (final profile in profiles) { + final apJson = (profile['allocationProfile'] as Map?) + ?.cast(); + final ap = AllocationProfile.parse(apJson); + result.addAll(ap?.members ?? const []); + } + return result; +} + HeapSample heapSampleFromMemoryUsage({ required MemoryUsage? memoryUsage, required int timestampMicros, diff --git a/packages/devtools_profiler_core/pubspec.yaml b/packages/devtools_profiler_core/pubspec.yaml index 88409eb..9105b28 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.1.0 +version: 0.2.0-wip environment: sdk: '>=3.10.0 <4.0.0' diff --git a/packages/devtools_profiler_core/test/profile_artifacts_test.dart b/packages/devtools_profiler_core/test/profile_artifacts_test.dart new file mode 100644 index 0000000..3e96de7 --- /dev/null +++ b/packages/devtools_profiler_core/test/profile_artifacts_test.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + test('summarizeArtifact accepts memory-only artifact directories', () async { + final artifactRoot = await Directory.systemTemp.createTemp( + 'devtools_profiler_memory_artifact.', + ); + addTearDown(() => artifactRoot.delete(recursive: true)); + + final rawMemoryFile = File( + path.join(artifactRoot.path, 'memory_profile.json'), + ); + await rawMemoryFile.writeAsString(jsonEncode(_rawMemoryArtifact())); + + final summary = await ProfileArtifacts.summarizeArtifact(artifactRoot.path); + + expect(summary['type'], 'ProfileMemoryArtifact'); + expect(summary['start'], isA>()); + expect(summary['end'], isA>()); + }); + + test('readMemoryClasses resolves session json file targets', () async { + final artifactRoot = await Directory.systemTemp.createTemp( + 'devtools_profiler_session_memory.', + ); + addTearDown(() => artifactRoot.delete(recursive: true)); + + final rawMemoryFile = File( + path.join(artifactRoot.path, 'memory_profile.json'), + ); + await rawMemoryFile.writeAsString(jsonEncode(_rawMemoryArtifact())); + + final sessionFile = File(path.join(artifactRoot.path, 'session.json')); + final session = ProfileRunResult( + sessionId: 'session-1', + command: const ['dart', 'run', 'bin/main.dart'], + workingDirectory: artifactRoot.path, + exitCode: 0, + artifactDirectory: artifactRoot.path, + overallProfile: _regionWithMemory(rawMemoryFile.path), + regions: const [], + warnings: const [], + ); + await sessionFile.writeAsString(jsonEncode(session.toJson())); + + final memory = await ProfileArtifacts.readMemoryClasses(sessionFile.path); + + expect(memory.rawProfilePath, rawMemoryFile.path); + expect(memory.classCount, 0); + }); + + test('rebuildMemoryProfileFromArtifact validates required maps', () { + expect( + () => rebuildMemoryProfileFromArtifact(const { + 'type': 'ProfileMemoryArtifact', + }, rawProfilePath: '/tmp/malformed_memory_profile.json'), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('/tmp/malformed_memory_profile.json'), + contains('start'), + ), + ), + ), + ); + }); +} + +ProfileRegionResult _regionWithMemory(String rawMemoryPath) { + return ProfileRegionResult( + regionId: 'overall', + name: 'whole-session', + attributes: const {'scope': 'session'}, + isolateId: 'isolates/1', + isolateIds: const ['isolates/1'], + captureKinds: const [ProfileCaptureKind.memory], + isolateScope: ProfileIsolateScope.current, + startTimestampMicros: 1, + endTimestampMicros: 2, + durationMicros: 1, + sampleCount: 0, + samplePeriodMicros: 0, + topSelfFrames: const [], + topTotalFrames: const [], + memory: _memoryResult(rawMemoryPath), + summaryPath: path.join(path.dirname(rawMemoryPath), 'summary.json'), + ); +} + +ProfileMemoryResult _memoryResult(String rawMemoryPath) { + return ProfileMemoryResult.fromJson({ + 'start': _heapSampleJson(timestamp: 1, used: 1024), + 'end': _heapSampleJson(timestamp: 2, used: 2048), + 'deltaHeapBytes': 1024, + 'deltaExternalBytes': 0, + 'deltaCapacityBytes': 1024, + 'classCount': 0, + 'topClasses': const [], + 'rawProfilePath': rawMemoryPath, + }); +} + +Map _rawMemoryArtifact() { + return { + 'type': 'ProfileMemoryArtifact', + 'isolateIds': const ['isolates/1'], + 'start': { + 'heapSample': _heapSampleJson(timestamp: 1, used: 1024), + 'profiles': const [], + }, + 'end': { + 'heapSample': _heapSampleJson(timestamp: 2, used: 2048), + 'profiles': const [], + }, + }; +} + +Map _heapSampleJson({ + required int timestamp, + required int used, +}) { + return { + 'timestamp': timestamp, + 'rss': 0, + 'capacity': 4096, + 'used': used, + 'external': 0, + 'gc': false, + 'adb_memoryInfo': { + 'Realtime': 0, + 'Java Heap': 0, + 'Native Heap': 0, + 'Code': 0, + 'Stack': 0, + 'Graphics': 0, + 'Private Other': 0, + 'System': 0, + 'Total': 0, + }, + 'memory_eventInfo': { + 'timestamp': -1, + 'gcEvent': false, + 'snapshotEvent': false, + 'snapshotAutoEvent': false, + 'allocationAccumulatorEvent': { + 'start': false, + 'continues': false, + 'reset': false, + }, + 'extensionEvents': null, + }, + 'rasterCache': {'layerBytes': 0, 'pictureBytes': 0}, + }; +} diff --git a/packages/devtools_profiler_core/test/profile_comparison_test.dart b/packages/devtools_profiler_core/test/profile_comparison_test.dart index 5478f66..df4e578 100644 --- a/packages/devtools_profiler_core/test/profile_comparison_test.dart +++ b/packages/devtools_profiler_core/test/profile_comparison_test.dart @@ -300,6 +300,26 @@ void main() { final regressions = summarizeProfileRegressions(comparison); expect(regressions.warnings, comparison.warnings); }); + + test( + 'compareProfileRegions uses memory overrides for availability warnings', + () { + final comparison = compareProfileRegions( + baseline: _region(regionId: 'baseline'), + current: _region(regionId: 'current'), + baselineMemoryOverride: _memoryBaseline(), + currentMemoryOverride: _memoryCurrent(), + ); + + expect(comparison.memory, isNotNull); + expect( + comparison.warnings, + isNot( + contains('Memory data was only available for one compared profile.'), + ), + ); + }, + ); } ProfileRegionResult _region({ @@ -333,3 +353,49 @@ ProfileRegionResult _region({ summaryPath: '/tmp/$regionId/summary.json', ); } + +ProfileMemoryResult _memoryBaseline() { + return ProfileMemoryResult( + start: HeapSample(1, 0, 2048, 1024, 0, false, null, null, null), + end: HeapSample(2, 0, 3072, 1536, 0, false, null, null, null), + deltaHeapBytes: 512, + deltaExternalBytes: 0, + deltaCapacityBytes: 1024, + classCount: 1, + topClasses: const [ + ProfileMemoryClassSummary( + className: 'Buffer', + libraryUri: 'package:fixture/buffer.dart', + allocationBytesDelta: 512, + allocationInstancesDelta: 1, + liveBytes: 512, + liveBytesDelta: 512, + liveInstances: 1, + liveInstancesDelta: 1, + ), + ], + ); +} + +ProfileMemoryResult _memoryCurrent() { + return ProfileMemoryResult( + start: HeapSample(3, 0, 3072, 1536, 0, false, null, null, null), + end: HeapSample(4, 0, 4096, 2560, 0, false, null, null, null), + deltaHeapBytes: 1024, + deltaExternalBytes: 0, + deltaCapacityBytes: 1024, + classCount: 1, + topClasses: const [ + ProfileMemoryClassSummary( + className: 'Buffer', + libraryUri: 'package:fixture/buffer.dart', + allocationBytesDelta: 1024, + allocationInstancesDelta: 2, + liveBytes: 1024, + liveBytesDelta: 1024, + liveInstances: 2, + liveInstancesDelta: 2, + ), + ], + ); +} diff --git a/packages/devtools_profiler_core/test/profile_frames_test.dart b/packages/devtools_profiler_core/test/profile_frames_test.dart new file mode 100644 index 0000000..7703ad4 --- /dev/null +++ b/packages/devtools_profiler_core/test/profile_frames_test.dart @@ -0,0 +1,117 @@ +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + test('packageName resolves package URIs', () { + const frame = ProfileFrame( + name: 'Value.toString', + kind: 'Dart', + location: 'package:lualike/src/value.dart', + ); + + expect(frame.packageName, 'lualike'); + }); + + test('packageName resolves pub-cache file URIs', () { + final frame = ProfileFrame( + name: 'Value.toString', + kind: 'Dart', + location: Uri.file( + path.posix.join( + '/', + 'home', + 'user', + '.pub-cache', + 'hosted', + 'pub.dev', + 'lualike-1.2.3', + 'lib', + 'src', + 'value.dart', + ), + windows: false, + ).toString(), + ); + + expect(frame.packageName, 'lualike'); + }); + + test('packageName resolves local package file URIs', () { + final frame = ProfileFrame( + name: 'Value.toString', + kind: 'Dart', + location: Uri.file( + path.posix.join( + '/', + 'repo', + 'pkgs', + 'lualike', + 'lib', + 'src', + 'value.dart', + ), + windows: false, + ).toString(), + ); + + expect(frame.packageName, 'lualike'); + }); + + test('packageName resolves local packages before nested lib folders', () { + final frame = ProfileFrame( + name: 'Value.toString', + kind: 'Dart', + location: Uri.file( + path.posix.join( + '/', + 'repo', + 'pkgs', + 'lualike', + 'lib', + 'src', + 'generated', + 'lib', + 'value.dart', + ), + windows: false, + ).toString(), + ); + + expect(frame.packageName, 'lualike'); + }); + + test('packageName strips version suffixes from file package folders', () { + final frame = ProfileFrame( + name: 'Value.toString', + kind: 'Dart', + location: Uri.file( + path.posix.join( + '/', + 'workspace', + 'cache', + 'lualike-1.2.3', + 'lib', + 'src', + 'value.dart', + ), + windows: false, + ).toString(), + ); + + expect(frame.packageName, 'lualike'); + }); + + test('packageName ignores file URIs outside package layouts', () { + final frame = ProfileFrame( + name: 'main', + kind: 'Dart', + location: Uri.file( + path.posix.join('/', 'repo', 'tool', 'main.dart'), + windows: false, + ).toString(), + ); + + expect(frame.packageName, isNull); + }); +} diff --git a/packages/devtools_profiler_core/test/profile_runner_test.dart b/packages/devtools_profiler_core/test/profile_runner_test.dart index 60e5f45..1a5c9e8 100644 --- a/packages/devtools_profiler_core/test/profile_runner_test.dart +++ b/packages/devtools_profiler_core/test/profile_runner_test.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:devtools_profiler_core/devtools_profiler_core.dart'; import 'package:path/path.dart' as path; import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; void main() { final fixtureDirectory = _fixtureDirectory(); @@ -228,7 +229,7 @@ Future main() async { expect(result.overallProfile!.sampleCount, greaterThan(0)); expect( result.warnings, - contains(contains('Attached to an existing VM service')), + contains(contains('Attach mode captured an existing VM-service process')), ); expect(File(result.overallProfile!.summaryPath).existsSync(), isTrue); expect(File(result.overallProfile!.rawProfilePath!).existsSync(), isTrue); @@ -435,6 +436,78 @@ Future main() async { expect(region.sampleCount, 1); expect(region.topSelfFrames.single.name, 'Worker.hotLeaf'); }); + + test('summarizes a profile artifact directory', () async { + final artifactRoot = await Directory.systemTemp.createTemp( + 'devtools_profiler_core_summary_dir.', + ); + addTearDown(() => artifactRoot.delete(recursive: true)); + + final profileDirectory = Directory(path.join(artifactRoot.path, 'overall')); + await profileDirectory.create(recursive: true); + final rawArtifact = File( + path.join(profileDirectory.path, 'cpu_profile.json'), + ); + await rawArtifact.writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'type': 'CpuSamples', + 'samplePeriod': 1000, + 'timeOriginMicros': 0, + 'timeExtentMicros': 2000, + 'functions': [ + { + 'kind': 'Dart', + 'resolvedUrl': 'package:fixture/a.dart', + 'function': { + 'type': '@Function', + 'id': 'functions/a', + 'name': 'hotLeaf', + 'owner': { + 'type': '@Class', + 'id': 'classes/worker', + 'name': 'Worker', + }, + }, + }, + ], + 'samples': [ + { + 'timestamp': 1, + 'stack': [0], + }, + ], + }), + ); + + final summaryFile = File(path.join(profileDirectory.path, 'summary.json')); + final summary = summarizeCpuSamples( + regionId: 'overall', + name: 'whole-session', + attributes: const {'scope': 'session'}, + isolateId: 'isolates/1', + isolateIds: const ['isolates/1'], + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 0, + endTimestampMicros: 2000, + cpuSamples: CpuSamples.parse( + jsonDecode(await rawArtifact.readAsString()) as Map, + )!, + summaryPath: summaryFile.path, + rawProfilePath: rawArtifact.path, + ); + await summaryFile.writeAsString( + const JsonEncoder.withIndent(' ').convert(summary.toJson()), + ); + + final resolved = await ProfileArtifacts.summarizeArtifact( + profileDirectory.path, + ); + final region = ProfileRegionResult.fromJson(resolved); + + expect(region.regionId, 'overall'); + expect(region.rawProfilePath, rawArtifact.path); + expect(region.topSelfFrames.single.name, 'Worker.hotLeaf'); + }); } Directory _fixtureDirectory() { diff --git a/skills/devtools-profiler-local/SKILL.md b/skills/devtools-profiler-local/SKILL.md index b45d069..365e1fd 100644 --- a/skills/devtools-profiler-local/SKILL.md +++ b/skills/devtools-profiler-local/SKILL.md @@ -25,6 +25,8 @@ Start by identifying the user's target: Prefer one working command over a broad explanation. Once the first capture works, help the user add filters, regions, method inspection, or comparisons. +Use `inspect-classes` when the question is about retained memory classes or +allocation deltas. ## Install The CLI @@ -165,12 +167,27 @@ devtools-profiler inspect \ path/to/session ``` +Inspect memory classes: + +```bash +devtools-profiler inspect-classes \ + --json \ + --class String \ + --min-live-bytes 1048576 \ + path/to/session +``` + +Use `--limit 0` when the agent needs the complete class list. The command can +read a session directory, a region `summary.json`, or a raw +`memory_profile.json` artifact. + Compare two sessions: ```bash devtools-profiler compare \ --json \ --method-table \ + --min-live-bytes 1048576 \ path/to/baseline-session \ path/to/current-session ``` @@ -178,6 +195,11 @@ devtools-profiler compare \ Use `--profile-id overall` for the whole session. Use the printed region id to inspect a marked region. +For memory comparisons, use `--memory-class-limit 0` when the agent needs an +unlimited class list. Negative memory thresholds and limits are invalid. +JSON responses include a `cliCommand` field that reproduces the same analysis +selection. + ## MCP Server Use MCP when an AI agent should run or inspect profiles directly: @@ -191,6 +213,21 @@ trees, method tables, and memory summaries when diagnosing performance. Those views give enough context to explain both where time is spent and how callers reach the hot method. +Useful MCP tools: + +- Capture: `profile_run`, `profile_attach`. +- Navigate stored runs: `profile_list_sessions`, `profile_latest_session`, + `profile_get_session`, `profile_list_regions`, `profile_get_region`. +- Explain and drill down: `profile_explain_hotspots`, + `profile_search_methods`, `profile_inspect_method`, + `profile_inspect_classes`. +- Compare: `profile_compare`, `profile_compare_method`, + `profile_find_regressions`, `profile_analyze_trends`. + +When using session directories, pass `profileId: overall` or a generated +region id. For trend analysis, keep the selected region consistent across +sessions when comparing region-scoped runs. + ## Troubleshooting - If startup times out, increase `--vm-service-timeout`. @@ -202,4 +239,5 @@ reach the hot method. `--hide-runtime-helpers`. - 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`, and `--method-limit 0`. + `--tree-depth 0`, `--tree-children 0`, `--method-limit 0`, + `--limit 0`, and `--memory-class-limit 0`.