diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c1c492..76bbce9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Dart uses: dart-lang/setup-dart@v1 with: - sdk: 3.11.4 + sdk: 3.13.1 - name: Show tool versions run: | @@ -68,7 +68,7 @@ jobs: - name: Set up Dart uses: dart-lang/setup-dart@v1 with: - sdk: 3.11.4 + sdk: 3.13.1 - name: Show tool versions run: | diff --git a/README.md b/README.md index 4ee3d56..40c20b1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ It intentionally does not use the DevTools Flutter or web UI. ## Fast Start +Requires Dart 3.13 or later (before Dart 4). The region-marking helper also +requires Dart 3.13, so Flutter apps using it need a Flutter SDK that bundles +Dart 3.13 or later. The profiler itself remains a pure-Dart CLI/MCP tool. + Install the CLI once: ```bash @@ -277,6 +281,14 @@ await profileRegion( Human output is designed for terminal scanning. JSON output is designed for automation and AI agents. +`run --json` reserves stdout for the JSON result and forwards target logs from +both streams to stderr. Use `--no-forward-output` to suppress target logs: + +```bash +devtools-profiler run --json -- dart run bin/main.dart \ + > profile.json 2> target.log +``` + Use human output while exploring: ```bash @@ -304,6 +316,25 @@ devtools-profiler summarize \ JSON responses include a `cliCommand` field for the command that can reproduce the same analysis selection. +Positional arguments accept session ids in addition to file paths, making it +quick to reference stored runs by their id (listed by `devtools-profiler +profiles`): + +```bash +devtools-profiler summarize 0712060003-8c410 +devtools-profiler compare 0712060003-8c410 0711235455-ebfb3 +devtools-profiler trends session-a session-b session-c +devtools-profiler inspect --method Parser.parseFile 0712060003-8c410 +devtools-profiler inspect-classes --class String 0712060003-8c410 +``` + +Check for regressions against a known-good baseline (exits 1 on regression): + +```bash +devtools-profiler regress path/to/baseline-session +devtools-profiler regress --warn-only 0712060003-8c410 +``` + Important result sections: - `overallProfile`: the whole run from process start to process exit. @@ -574,7 +605,10 @@ Commands: `--duration`. - `summarize ` summarizes a session directory or profile artifact. - `explain ` explains likely hotspots in one selected profile. -- `compare ` compares two profiles or sessions. +- `compare ` compares two profiles or sessions. Also + accepts 3+ positional args for multi-compare aligned hotspot tables. +- `regress [current]` compares current against a baseline and + exits with code 1 when regressions are found. Use `--warn-only` to exit 0. - `trends ...` analyzes a sequence of profiles or sessions. - `inspect ` inspects one method in one profile. - `inspect-classes ` inspects memory classes in one profile. @@ -585,12 +619,15 @@ Commands: Common presentation flags: - `--json` emits structured JSON instead of human output. +- `--csv` outputs compact CSV tables instead of formatted terminal output. - `--call-tree` includes a top-down call tree. - `--expand` is an alias for `--call-tree`. - `--bottom-up` includes a bottom-up call tree. - `--method-table` includes a DevTools-style method table. - `--hide-sdk` hides Dart and Flutter SDK frames. - `--hide-runtime-helpers` hides common profiler/runtime helper packages. +- `--collapse-async` collapses all `dart:async` frames into a single + "async overhead" entry in summary tables. - `--include-package ` keeps only matching package prefixes. May be repeated. - `--exclude-package ` excludes matching package prefixes. May be @@ -628,6 +665,7 @@ Common presentation flags: Path arguments accepted by read/analyze commands: +- a session id (listed by `devtools-profiler profiles`) - a session directory - a region `summary.json` - a raw `cpu_profile.json` @@ -765,3 +803,6 @@ dart test packages/devtools_profiler_cli dart test packages/devtools_region_profiler dart test packages/devtools_profiler_protocol ``` + +All four test suites must pass before publishing. Current totals: +The test suites cover all four packages. diff --git a/packages/devtools_profiler_cli/CHANGELOG.md b/packages/devtools_profiler_cli/CHANGELOG.md index c2915cd..b5e2ffc 100644 --- a/packages/devtools_profiler_cli/CHANGELOG.md +++ b/packages/devtools_profiler_cli/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## 0.6.0 + +- Allowed Artisanal releases from 0.6.0 up to, but not including, 1.0.0. +- Implemented `profiles --json` with structured empty results, session metadata, + and explicit listing counts and truncation. +- Validated replay, trend, and output-format options before execution. +- Kept replay output capturable and skipped animation for redirected hosts. +- Unified session-directory resolution and preferred existing artifact paths. +- Clarified annotation granularity and allocation/CPU correlation semantics. +- Fixed unlimited annotation output and aligned run/attach text with JSON. +- Added an opt-in `browse` terminal UI for searchable stored sessions and + explicit baseline/current region selection, summary previews, and command + export. Redirected hosts cannot enter terminal mode. +- Matched multi-run CPU frames by name, kind, and location instead of name + alone; missing entries no longer claim a function was eliminated. +- Corrected default pairwise comparison order: previous run is the baseline, + newest run is current. +- Rebuilt multi-run frame lists before filtering/alignment and applied row + limits after alignment. Added MCP `profile_compare.paths` support. +- Added aligned nullable rows to multi-run JSON and trend JSON. Multi-run CSV + now includes kind/location columns and blank cells for missing observations. +- Aligned the MCP implementation version with the CLI release and required + core 0.6.0 for the improved artifact reader and capture lifecycle. +- Improved saved-profile frame names and CPU isolate/thread metadata through the + shared core reader, for CLI and MCP workflows. +- Finalized in-flight region captures before terminating duration-limited runs. +- Kept `run --json` stdout parseable by forwarding target logs to stderr; + `--no-forward-output` still suppresses them. +- Shared complete CPU call paths across top-down, bottom-up, and method-table + presentation for both CLI and MCP, before applying output limits. +- Required Dart 3.13 or later and refreshed dependencies, including artisanal + 0.6 and dart_mcp 0.5.2 (remaining on the supported 0.5.x line). + +## 0.5.2 + +- Updated the CLI release to use core 0.5.2, fixing profiling from AOT bundles + installed with `dart install`. + +## 0.5.1 + +- Updated the CLI release to use `devtools_profiler_core` 0.5.1, including + reliable completion of interrupted CPU and memory profile captures. + +## 0.5.0 + +- Added `replay` command that animates through stored CPU samples as a + live flame chart. Shows time-windowed frame samples with visual bars. + Use `--window` to control the time slice, `--speed` for playback rate, + and `--top` for number of frames shown. +- Added `annotate` command that shows per-file hotspot breakdowns with + sample counts, percentages, and visual bars. Use `--file` to filter to + one source file, `--top` and `--min-samples` to control output. + Package URIs are resolved via package_config.json. +- Added `lineForFunction()` helper to extract line numbers from VM + profile function data. + +- Positional arguments can now be session ids in addition to file paths. + Commands like `summarize`, `compare`, `explain`, `trends`, `inspect`, + `search-methods`, `inspect-classes`, and `compare-method` first try to + match a positional argument against stored session ids before falling + back to file-path treatment. +- Improved error output when an artifact is not found — a tip now suggests + `devtools-profiler profiles` to list available sessions or to pass a + session id as a positional argument. +- Added `--csv` flag for compact machine-readable table output. Supports + `summarize`, `compare`, `trends`, and multi-compare modes. +- Added `--last N` flag to `trends` for analyzing the N most recent stored + sessions without specifying explicit paths. +- Added `--collapse-async` flag that replaces individual `dart:async` frames + with categorized entries grouped by async type (normal completions, error + completions, listener dispatch, microtask scheduling, zone overhead). + When raw CPU samples are available, async cost is further attributed to + the first non-async caller in the stack, producing actionable entries like + `async (await _runFrame)` that show which calling function triggered the + async cost. +- Added what-if async removal estimation to `--collapse-async`: when raw + CPU samples are available, the profiler estimates how much time could be + saved by converting specific functions from async to sync. Warnings appear + for functions where normal completions dominate the async cost, making + them prime candidates for sync conversion. +- Multi-compare mode: `compare` now accepts 3+ positional arguments and + produces an aligned hotspot table showing each method's self-percentage + across all sessions, with "eliminated" for absent entries. +- Added `isAsyncOverhead` getter to `ProfileFrame` for identifying + `dart:async` frames. +- Added `regress` command that compares the current profile against a + known-good baseline and reports regressions. Exits with code 1 when + regressions are found. Use `--warn-only` to always exit 0. + Examples: + + ```bash + devtools-profiler regress path/to/baseline + devtools-profiler regress 0712060003-8c410 0711235455-ebfb3 + devtools-profiler regress --warn-only path/to/baseline + ``` + +- Updated `--csv` output: now also supports the multi-compare mode and + the `regress` command. + ## 0.4.0 - Added `profiles` command that lists stored profiling sessions in a diff --git a/packages/devtools_profiler_cli/INTERACTIVE_ANALYSIS.md b/packages/devtools_profiler_cli/INTERACTIVE_ANALYSIS.md new file mode 100644 index 0000000..f3fb277 --- /dev/null +++ b/packages/devtools_profiler_cli/INTERACTIVE_ANALYSIS.md @@ -0,0 +1,112 @@ +# Interactive analysis proposal + +Status: initial summary browser and shared frame alignment implemented. +The remaining items below are a roadmap, not shipped capabilities. + +The first implementation uses the core runtime and Style's cell-aware clipping, +with a vertically stacked list/preview and a scrollable details mode. It +deliberately does not load raw samples, auto-match region names, or introduce a +widget framework. Full filtered analysis is available through the exported +comparison command. Multi-run CLI/MCP analysis rebuilds raw frames when +available and preserves missing observations across output formats. + +## Recommendation + +Add an explicit `browse` command for stored sessions, using Artisanal's +core TEA runtime. Keep existing one-shot commands as the primary scripting +interface. Do not automatically enter raw mode when a command lacks arguments. + +A session browser with a comparison preview is a better first step than a +full live dashboard: it addresses session selection and cross-run investigation +without adding capture lifecycle state to the UI. + +## Existing foundations + +- `profiles` already discovers sessions and supports compact/extended listings. +- `compare` supports two-run and multi-run comparisons. +- `compare-method`, `trends --last N`, and `regress` provide drill-down and + regression workflows. +- Shared presentation preparation already supplies CLI and MCP analysis. +- Current terminal rendering uses Artisanal Console tables and definition lists. + +Reuse session discovery and prepared analysis; do not parse rendered text or +spawn the CLI recursively from the browser. + +## Relevant Artisanal 0.6 capabilities + +The installed package's changelog documents: + +- Shared command-palette matching, stable item IDs, viewport windows, and + `CommandPaletteOverlay` / `CommandPaletteComponent`: useful for searchable + session and action selection. +- `FrameView` and `FrameLayout`: positioned panes with fixed, percentage, and + weighted-fill sizing; suitable for a session list and comparison preview. +- Focused runtime, layout, style, and charting imports: no need to add the + widget framework for this small UI. +- Charting primitives, including sparklines and histograms: useful for repeated + measurements, provided exact values and units remain visible. +- Capability-aware Console operations and corrected non-interactive behavior: + useful for a static rendering refresh independently of a TUI. + +These are available capabilities, not claims that the profiler uses them today. + +## First browser slice + +1. Discover session summaries without loading every raw CPU artifact. +2. Search by session ID, command, and directory. +3. Select a baseline and current run explicitly; show their roles at all times. +4. Choose whole-session or matching named regions. Region IDs are run-local; + repeated names require disambiguation, not silent first-match selection. +5. Preview duration, sample count, memory deltas, warnings, and hotspot changes. +6. Offer method inspection and an exact reproducible one-shot command. + +Use a two-pane layout on wide terminals and one pane with a details toggle on +narrow terminals. Support keyboard-only navigation, visible key hints, +Escape/back, Ctrl+C/quit, resizing, and terminal restoration after errors. +Refuse non-TTY input/output with an actionable one-shot alternative. +Never consume MCP stdin or emit terminal escapes into JSON/CSV output. + +Cache only selected prepared artifacts with a bounded policy. Discard stale +asynchronous results after selection changes; rendering must not perform I/O. + +## Comparison correctness before decoration + +The old multi-run terminal table keyed frames by display name and labeled +missing entries "eliminated", even though its input was top-frame lists. +This is now corrected through shared core alignment, used by terminal, CSV, +JSON, MCP, and the browser. Exact locations are retained rather than guessing +cross-checkout equivalence. + +Recommended semantics: + +- Match by canonical function identity including location, not name alone. + Normalize checkout roots/package versions deliberately and test collisions. +- Distinguish "not in selected top frames", "not sampled", and unavailable data. + Do not infer zero cost from any of them. +- Compute comparisons from complete filtered data; limit only displayed rows. +- Show percentage-point changes separately from estimated sampled CPU time. + Neither is elapsed wall time or a direct throughput measurement. +- Warn about different sample periods, capture durations, region scopes, + filters, SDK/build modes, or incomplete isolate coverage when known. + Unknown capture metadata must remain unknown, not assumed compatible. +- For repeated benchmarks, group comparable workloads and show sample size, + median and dispersion before introducing statistical regression claims. + A fixed capture-window duration is not itself a benchmark performance metric. + +New analysis semantics belong in core and must remain accessible via MCP. + +## Delivery and validation + +1. Shared cross-run comparison correctness with fixtures for truncated top + lists, same-name functions, missing data, and different capture settings. +2. Static rendering refresh: compact metric header, explicit baseline/current + labels, units, signed deltas, and bounded terminal widths. +3. Read-only browser reusing those models and renderers. +4. Repeated-run grouping and distribution summaries after capture metadata and + comparability rules are defined. + +Test plain/no-color and Unicode output, narrow and wide layouts, JSON/CSV +isolation, and deterministic keyboard navigation. Exercise a real PTY for +resize, cancellation, interrupted artifact loading, and terminal restoration. +Benchmark discovery with many sessions and memory usage with large CPU +artifacts; do not load all samples just to paint a session list. diff --git a/packages/devtools_profiler_cli/README.md b/packages/devtools_profiler_cli/README.md index 61223b1..9291c4c 100644 --- a/packages/devtools_profiler_cli/README.md +++ b/packages/devtools_profiler_cli/README.md @@ -10,6 +10,39 @@ automation, and serve the same capabilities to AI agents over stdio MCP. For the full CLI guide, see [the profiler README](../../README.md). +## Browse And Compare Stored Runs + +```bash +devtools-profiler browse --cwd /path/to/project +devtools-profiler compare --hide-sdk --frame-limit 20 baseline middle current +devtools-profiler trends --last 5 --json +``` + +`browse` is an explicit, read-only terminal UI. Use `/` to search session IDs, +commands, directories, and region names; arrows or `j`/`k` to move; `b` to set +the baseline; and `c` or Enter to set the current profile. Regions are listed +with their run-local IDs, so repeated names are not silently matched. +Press `d` for scrollable comparison details, `e` to exit and print the full +POSIX-shell comparison command, or `q`/Ctrl+C to quit. + +The browser reads stored summaries only, not large raw CPU artifacts. It +requires terminal stdin/stdout and does not accept JSON, CSV, or frame filters. +Use its exported command for full filtered analysis. No profile is modified. + +Multi-run `compare` rebuilds frame lists from raw CPU data when available, +applies filters, aligns by name/kind/exact location, then limits output rows. +Different checkout paths are not guessed to be equivalent. Missing entries +mean **not listed**, not eliminated; stored summaries may be incomplete. +Terminal output shows kind and location; CSV adds `kind` and `location` columns +and uses blank cells for missing observations. JSON has aligned `rows` with +nullable observations. MCP `profile_compare` accepts `paths` for the same +cross-run CPU alignment; pairwise baseline/current selectors remain supported. + +Self percentages describe share of sampled CPU stacks, not elapsed time. +Browser deltas are percentage points. Verify workloads, capture settings, +build mode, and isolate coverage before interpreting a change as a regression. +Repeated-run statistics and live capture controls are not part of this browser. + ## Install And Run Install the CLI once: @@ -292,6 +325,17 @@ stored session exists — it falls back to the latest session automatically. Use `--session-id latest`, `--session-id previous`, or `--session-id ` to pick a different stored session explicitly. +Positional arguments accept session ids in addition to file paths, so you can +pass a session id directly instead of its on-disk path: + +```bash +devtools-profiler summarize 0712060003-8c410 +devtools-profiler compare 0712060003-8c410 0711235455-ebfb3 +devtools-profiler trends session-a session-b session-c +devtools-profiler inspect --method Parser.parseFile 0712060003-8c410 +devtools-profiler inspect-classes --class String 0712060003-8c410 +``` + Summarize a session: ```bash @@ -363,16 +407,42 @@ devtools-profiler trends \ /path/to/session-1 \ /path/to/session-2 \ /path/to/session-3 + +# Or use the N most recent stored sessions: +devtools-profiler trends --last 5 +``` + +Check for regressions against a known-good baseline (useful in CI): + +```bash +devtools-profiler regress path/to/baseline-session +devtools-profiler regress 0712060003-8c410 0711235455-ebfb3 +``` + +Exits with code 1 when regressions are found. Use `--warn-only` to exit 0. + +Compare three or more sessions with an aligned hotspot table: + +```bash +devtools-profiler compare session-a session-b session-c +devtools-profiler compare --csv session-a session-b session-c ``` ## Important Flags - `--json` emits machine-readable JSON. +- `run --json` sends forwarded target stdout and stderr to the profiler's + stderr, keeping stdout parseable. `--no-forward-output` suppresses target logs. +- `--csv` outputs compact CSV tables instead of formatted terminal output. - `--call-tree` includes the top-down call tree. - `--bottom-up` includes the bottom-up caller tree. - `--method-table` includes DevTools-style caller and callee context. - `--hide-sdk` hides Dart and Flutter SDK frames. - `--hide-runtime-helpers` hides profiler transport and runtime helper frames. +- `--collapse-async` categorizes `dart:async` frames by type (normal + completions, error completions, listener dispatch, microtask scheduling, + zone overhead) and attributes async cost to the calling function when + raw CPU samples are available (e.g. `async (await _executeFrame)`). - `--include-package ` keeps only matching package prefixes. - `--exclude-package ` removes matching package prefixes. - `--full-locations` keeps full source locations instead of compact labels. @@ -393,6 +463,8 @@ devtools-profiler trends \ `inspect-classes`. - `--memory-class-limit ` controls compared memory class rows for `compare`. `0` means unlimited. +- `--last ` on `trends` uses the `n` most recent stored sessions. + Example: `devtools-profiler trends --last 5`. 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 @@ -411,6 +483,12 @@ devtools-profiler profiles --limit 0 # all stored sessions devtools-profiler profiles --json # machine-readable output ``` +JSON output is a single object with `kind: "sessions"`, `sessionsDirectory`, +`totalCount`, `returnedCount`, `truncated`, and a newest-first `sessions` array. +Each entry contains the absolute `path`, UTC `modifiedTime`, and the complete +stored `session` object. Empty discovery returns an empty array with zero counts. +`--limit 0` includes every session; `--extended` only changes text output. + ## MCP Server Start the local stdio MCP server: diff --git a/packages/devtools_profiler_cli/lib/src/browser/session_browser.dart b/packages/devtools_profiler_cli/lib/src/browser/session_browser.dart new file mode 100644 index 0000000..215a793 --- /dev/null +++ b/packages/devtools_profiler_cli/lib/src/browser/session_browser.dart @@ -0,0 +1,262 @@ +import 'dart:math' as math; + +import 'package:artisanal/runtime.dart' as tui; +import 'package:artisanal/style.dart'; +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; + +import '../cli/commands/profile_session_resolution.dart'; +import '../presentation/cli_command.dart'; + +/// A selectable whole-session or explicit region summary. +final class BrowserProfile { + /// Creates an entry without loading raw artifacts. + const BrowserProfile(this.session, this.profile); + + /// The owning stored session. + final StoredSession session; + + /// The selected profile, including its run-local region ID. + final ProfileRegionResult profile; + + /// A searchable, unambiguous label. + String get label => + '${session.result.sessionId} / ${profile.name} [${profile.regionId}] ' + '${session.result.command.join(' ')} ${session.result.workingDirectory}'; +} + +/// Read-only, summary-backed session selection and comparison. +/// +/// Holds no CPU sample artifacts, performs no rendering-time I/O, and never +/// guesses region equivalence. Each region is selected explicitly. +final class SessionBrowser implements tui.Model { + /// Creates a browser from stored summary metadata. + SessionBrowser(List sessions) + : entries = [ + for (final session in sessions) ...[ + if (session.result.overallProfile case final profile?) + BrowserProfile(session, profile), + for (final profile in session.result.regions) + BrowserProfile(session, profile), + ], + ]; + + /// Available summary entries. + final List entries; + + /// The selected baseline, independent of the active search. + BrowserProfile? baseline; + + /// The selected current run, independent of the active search. + BrowserProfile? current; + + /// Search text. + String query = ''; + + /// Whether keyboard input is editing the search. + bool searching = false; + + /// The cursor within the filtered list. + int cursor = 0; + + int _width = 80; + int _height = 24; + bool _details = false; + int _detailOffset = 0; + + /// The command to print after restoring the terminal, if requested. + String? exportedCommand; + + /// Returns entries matching the current query. + List get visible => [ + for (final entry in entries) + if (entry.label.toLowerCase().contains(query.toLowerCase())) entry, + ]; + + /// Returns a reproducible POSIX-shell comparison command. + String? get comparisonCommand { + final base = baseline; + final next = current; + if (base == null || next == null) return null; + return shellJoin([ + 'devtools-profiler', + 'compare', + '--baseline-profile-id', + base.profile.regionId, + '--current-profile-id', + next.profile.regionId, + '--', + base.session.directory.path, + next.session.directory.path, + ]); + } + + @override + tui.Cmd? init() => null; + + @override + (tui.Model, tui.Cmd?) update(tui.Msg msg) { + if (msg is tui.InterruptMsg) return (this, tui.Cmd.quit()); + if (msg case tui.WindowSizeMsg(:final width, :final height)) { + _width = math.max(1, width); + _height = math.max(1, height); + } + if (msg is! tui.KeyMsg) return (this, null); + final key = msg.key; + final text = String.fromCharCodes(key.runes); + if (key.ctrl && text == 'c') return (this, tui.Cmd.quit()); + if (searching) { + if (key.type == tui.KeyType.escape || key.type == tui.KeyType.enter) { + searching = false; + } else if (key.type == tui.KeyType.backspace) { + query = String.fromCharCodes( + query.runes.take(math.max(0, query.runes.length - 1)), + ); + } else if (key.type == tui.KeyType.runes && !key.ctrl && !key.alt) { + query += _safe(text); + } + cursor = 0; + return (this, null); + } + if (text == 'q' || key.type == tui.KeyType.escape) { + if (_details) { + _details = false; + return (this, null); + } + return (this, tui.Cmd.quit()); + } + if (text == '/') { + searching = true; + _details = false; + } else if (text == 'd') { + _details = !_details; + _detailOffset = 0; + } else if (text == 'e' && comparisonCommand != null) { + exportedCommand = comparisonCommand; + return (this, tui.Cmd.quit()); + } else if (key.type == tui.KeyType.down || text == 'j') { + if (_details) { + _detailOffset = math.min( + math.max(0, _preview().length - 1), + _detailOffset + 1, + ); + } else { + cursor = math.min(math.max(0, visible.length - 1), cursor + 1); + } + } else if (key.type == tui.KeyType.up || text == 'k') { + if (_details) { + _detailOffset = math.max(0, _detailOffset - 1); + } else { + cursor = math.max(0, cursor - 1); + } + } else if (visible.isNotEmpty) { + if (text == 'b') baseline = visible[cursor]; + if (text == 'c' || key.type == tui.KeyType.enter) { + current = visible[cursor]; + } + } + return (this, null); + } + + @override + String view() { + final lines = [ + 'PROFILE BROWSER | stored summaries', + 'B: ${_selectionLabel(baseline)}', + 'C: ${_selectionLabel(current)}', + '/ search | b baseline | c current | d details | e export | q quit', + ]; + if (_details) { + lines.addAll(_preview().skip(_detailOffset)); + } else { + lines.add('${searching ? 'Search>' : 'Filter:'} $query'); + final filtered = visible; + final count = math.max(1, (_height - 8) ~/ 2); + final start = (cursor ~/ count) * count; + if (filtered.isEmpty) lines.add('No matching profiles.'); + for (var i = start; i < math.min(filtered.length, start + count); i++) { + lines.add('${i == cursor ? '>' : ' '} ${filtered[i].label}'); + } + lines.add('--- Preview (d for scrollable details) ---'); + lines.addAll(_preview()); + } + return Style() + .maxWidth(_width) + .maxHeight(_height) + .render( + [ + for (var i = 0; i < lines.length; i++) + if (i == 0 || lines[i].startsWith('> ')) + Style().bold().render(_safe(lines[i])) + else + _safe(lines[i]), + ].join('\n'), + ); + } + + List _preview() { + final base = baseline; + final next = current; + if (base == null || next == null) { + return [ + 'Select baseline and current profiles; regions have explicit IDs.', + ]; + } + final a = base.profile; + final b = next.profile; + final rows = alignProfileFrames([ + ProfileFrameColumn(label: 'baseline', frames: a.topSelfFrames), + ProfileFrameColumn(label: 'current', frames: b.topSelfFrames), + ]); + return [ + 'Duration: ${(a.durationMicros / 1000).toStringAsFixed(1)} -> ' + '${(b.durationMicros / 1000).toStringAsFixed(1)} ms (capture window)', + 'Samples: ${a.sampleCount} -> ${b.sampleCount}; ' + 'period: ${a.samplePeriodMicros} -> ${b.samplePeriodMicros} us', + 'Heap delta: ${a.memory?.deltaHeapBytes ?? 'unavailable'} -> ' + '${b.memory?.deltaHeapBytes ?? 'unavailable'} bytes', + if (a.samplePeriodMicros != b.samplePeriodMicros) + 'WARNING: Different sample periods.', + if (a.name != b.name) + 'WARNING: Different profile names; verify matching workloads.', + if (a.isolateScope != b.isolateScope) + 'WARNING: Different isolate scopes.', + if (a.durationMicros != b.durationMicros) + 'WARNING: Different capture durations.', + if (a.error case final error?) 'WARNING: Baseline capture: $error', + if (b.error case final error?) 'WARNING: Current capture: $error', + 'Comparability is not guaranteed: verify build mode, workload and coverage.', + for (final warning in { + ...base.session.result.warnings, + ...next.session.result.warnings, + }) + 'WARNING: $warning', + 'Self share: baseline -> current (delta in percentage points).', + 'Not listed is NOT eliminated. Stored top lists may be incomplete.', + for (final row in rows) + '${row.name} [${row.kind}] ${row.location ?? '(unknown location)'}: ' + '${_percent(row.frames[0])} -> ${_percent(row.frames[1])}' + '${_delta(row.frames[0], row.frames[1])}', + 'POSIX command (e prints the complete command after exit):', + comparisonCommand!, + ]; + } +} + +String _selectionLabel(BrowserProfile? entry) => entry == null + ? '(not selected)' + : '${entry.session.result.sessionId} / ' + '${entry.profile.name} [${entry.profile.regionId}]'; + +String _percent(ProfileFrameSummary? frame) => frame == null + ? 'not listed' + : '${(frame.selfPercent * 100).toStringAsFixed(2)}%'; + +String _delta(ProfileFrameSummary? a, ProfileFrameSummary? b) { + if (a == null || b == null) return ''; + final delta = (b.selfPercent - a.selfPercent) * 100; + return ' (${delta >= 0 ? '+' : ''}${delta.toStringAsFixed(2)} pp)'; +} + +// Artifacts are data, not terminal commands. Never render embedded controls. +String _safe(String text) => + text.replaceAll(RegExp(r'[\x00-\x1f\x7f-\x9f]'), ' '); diff --git a/packages/devtools_profiler_cli/lib/src/cli.dart b/packages/devtools_profiler_cli/lib/src/cli.dart index 7a30e66..75e59b6 100644 --- a/packages/devtools_profiler_cli/lib/src/cli.dart +++ b/packages/devtools_profiler_cli/lib/src/cli.dart @@ -5,11 +5,15 @@ import 'package:artisanal/args.dart'; import 'package:devtools_profiler_core/devtools_profiler_core.dart'; import 'cli/commands/analysis_commands.dart'; +import 'cli/commands/annotate_command.dart'; import 'cli/commands/artifact_commands.dart'; +import 'cli/commands/browse_command.dart'; +import 'cli/commands/replay_command.dart'; import 'cli/commands/capture_commands.dart'; import 'cli/commands/discover_command.dart'; import 'cli/commands/flutter_commands.dart'; import 'cli/commands/profiles_command.dart'; +import 'cli/commands/profiler_command.dart'; import 'cli/constants.dart'; /// Runs the `devtools-profiler` CLI. @@ -25,7 +29,7 @@ Future runCli( int exitCode = successExitCode; final commandRunner = - CommandRunner( + _ProfilerCommandRunner( 'devtools-profiler', 'Profile Dart and Flutter apps and analyze the results.', out: stdoutSink.writeln, @@ -41,12 +45,19 @@ Future runCli( ..addCommand(SummarizeCommand(profiler)) ..addCommand(ExplainCommand(profiler)) ..addCommand(CompareCommand(profiler)) + ..addCommand(RegressCommand(profiler)) ..addCommand(TrendsCommand(profiler)) ..addCommand(InspectCommand(profiler)) ..addCommand(CompareMethodCommand(profiler)) ..addCommand(SearchMethodsCommand(profiler)) ..addCommand(InspectClassesCommand(profiler)) ..addCommand(ProfilesCommand(profiler)) + ..addCommand( + BrowseCommand( + profiler, + terminalAllowed: output == null && errorOutput == null, + ), + ) ..addCommand(DiscoverCommand(profiler)) ..addCommand(FrameProfileCommand(profiler)) ..addCommand(TimelineCommand(profiler)) @@ -57,6 +68,13 @@ Future runCli( ..addCommand(ScreenshotCommand(profiler)) ..addCommand(DebugDumpCommand(profiler)) ..addCommand(LogsCommand(profiler)) + ..addCommand(AnnotateCommand(profiler)) + ..addCommand( + ReplayCommand( + profiler, + terminalAllowed: output == null && errorOutput == null, + ), + ) ..addCommand(McpCommand(profiler)); try { @@ -66,7 +84,42 @@ Future runCli( stderrSink.writeln(error.message); return usageExitCode; } catch (error) { - stderrSink.writeln(error); + stderrSink.writeln(error.toString()); + final message = error.toString(); + if (message.contains('Artifact not found') || + message.contains('No profiler artifact')) { + stderrSink.writeln( + 'Tip: Use "devtools-profiler profiles" to list available stored ' + 'sessions, or pass a session id as a positional argument.', + ); + } return softwareExitCode; } } + +/// Validates output contracts before any command can launch or read a target. +class _ProfilerCommandRunner extends CommandRunner { + _ProfilerCommandRunner( + super.executableName, + super.description, { + super.out, + super.err, + super.outRaw, + super.errRaw, + super.usageExitCode, + super.setExitCode, + super.ansi, + }); + + @override + Future runCommand(ArgResults topLevelResults) { + final parsed = topLevelResults.command; + final command = commands[parsed?.name]; + if (parsed != null && + command is ProfilerCommand && + !(parsed['help'] as bool)) { + command.validateOutputFormat(parsed); + } + return super.runCommand(topLevelResults); + } +} 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 f010def..687402e 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 @@ -42,7 +42,8 @@ class CompareCommand extends ProfilerCommand with ProfileSessionResolution { String get name => 'compare'; @override - String get description => 'Compare two session/profile artifacts.'; + String get description => + 'Compare session/profile artifacts pairwise or across multiple sessions.'; @override String formatUsage({bool includeDescription = true}) => usageWithExamples( @@ -51,6 +52,7 @@ class CompareCommand extends ProfilerCommand with ProfileSessionResolution { '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', + 'devtools-profiler compare session-a session-b session-c', ], ); @@ -59,49 +61,54 @@ class CompareCommand extends ProfilerCommand with ProfileSessionResolution { if (argResults!.rest.isEmpty) { final baselinePath = await _resolveDefaultTarget('baseline'); final currentPath = await _resolveDefaultTarget('current'); - final options = presentationOptions; - final memoryClassLimitStr = argResults!['memory-class-limit'] as String?; - final memoryClassLimitSpecified = memoryClassLimitStr != null; - final comparison = await prepareProfileComparison( - profileRunner, - baselinePath: baselinePath, - currentPath: currentPath, - 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, - ); - if (printJson) { - writeJson(comparisonPresentationJson(comparison)); - } else { - writeComparisonSummary(io, comparison, options: options); - } - return successExitCode; + return _comparePair(baselinePath, currentPath); } - if (argResults!.rest.length != 2) { - usageException( - 'Compare requires exactly two targets or no targets to compare the two latest stored sessions.', - ); + if (argResults!.rest.length == 2) { + final baselinePath = await resolveSessionOrPath(argResults!.rest.first); + final currentPath = await resolveSessionOrPath(argResults!.rest.last); + return _comparePair(baselinePath, currentPath); } - final options = presentationOptions; + // 3+ args: multi-compare mode + final prepared = await prepareProfileFrameColumns( + profileRunner, + paths: [ + for (final arg in argResults!.rest) await resolveSessionOrPath(arg), + ], + options: presentationOptions, + ); + final columns = prepared.columns; - final memoryClassLimitStr = argResults!['memory-class-limit'] as String?; - final memoryClassLimitSpecified = memoryClassLimitStr != null; + if (printJson) { + writeJson( + frameColumnsJson( + columns, + warnings: prepared.warnings, + frameLimit: presentationOptions.frameLimit, + ), + ); + } else if (printCsv) { + writeCsvMultiCompare( + line, + columns, + frameLimit: presentationOptions.frameLimit, + ); + } else { + for (final warning in prepared.warnings) warn(warning); + writeMultiCompareSummary(io, columns, options: presentationOptions); + } + return successExitCode; + } + + Future _comparePair(String baselinePath, String currentPath) async { + final options = presentationOptions; + final memoryClassLimit = argResults!['memory-class-limit'] as String?; final comparison = await prepareProfileComparison( profileRunner, - baselinePath: argResults!.rest.first, - currentPath: argResults!.rest.last, + baselinePath: baselinePath, + currentPath: currentPath, baselineProfileId: argResults!['baseline-profile-id'] as String?, currentProfileId: argResults!['current-profile-id'] as String?, minLiveBytes: parseNonNegativeInt( @@ -109,19 +116,19 @@ class CompareCommand extends ProfilerCommand with ProfileSessionResolution { optionName: 'min-live-bytes', ), memoryClassLimit: parseLimit( - memoryClassLimitStr, + memoryClassLimit, optionName: 'memory-class-limit', ), - memoryClassLimitSpecified: memoryClassLimitSpecified, + memoryClassLimitSpecified: memoryClassLimit != null, options: options, ); - if (printJson) { writeJson(comparisonPresentationJson(comparison)); + } else if (printCsv) { + writeCsvComparisonFrames(line, comparison.comparison); } else { writeComparisonSummary(io, comparison, options: options); } - return successExitCode; } @@ -134,11 +141,156 @@ class CompareCommand extends ProfilerCommand with ProfileSessionResolution { 'sessions were found under "${sessionsDirectory.path}" for $label.', ); } - if (label == 'baseline') return sessions.first.directory.path; - if (sessions.length >= 2) return sessions[1].directory.path; - throw ArgumentError( - 'A second profile target is required and only one stored session is available.', + return selectComparisonSession( + sessions, + baseline: label == 'baseline', + ).directory.path; + } +} + +/// Exit code returned when regressions are detected by the regress command. +/// +/// This allows the command to be used in CI pipelines: a non-zero exit +/// signals that the current profile regressed against the baseline. +const regressionExitCode = 1; + +/// Command that compares the current profile against a known-good baseline +/// and reports regressions. +/// +/// Use this in CI or after profiling workflow to quickly check whether a +/// change slowed down the target. Exits with code [regressionExitCode] when +/// regressions are found, unless `--warn-only` is set. +class RegressCommand extends ProfilerCommand with ProfileSessionResolution { + /// Creates a regress command. + RegressCommand(super.profileRunner) { + argParser + ..addOption( + 'baseline-profile-id', + help: 'Profile id to select from the baseline session directory.', + ) + ..addOption( + 'current-profile-id', + help: 'Profile id to select from the current session directory.', + ) + ..addFlag( + 'warn-only', + negatable: false, + help: + 'Print regressions as warnings but always exit with code 0. ' + 'Useful for development workflows where regressions are expected.', + ) + ..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.', + ) + ..addOption( + 'memory-class-limit', + help: 'Maximum memory classes to compare. Use 0 for unlimited.', + ); + } + + @override + String get name => 'regress'; + + @override + String get description => + 'Compare the current profile against a baseline and report regressions. ' + 'Useful for CI and post-change verification.'; + + @override + String get invocation => + '${runner!.executableName} regress [options] [current]'; + + @override + String formatUsage({bool includeDescription = true}) => usageWithExamples( + super.formatUsage(includeDescription: includeDescription), + const [ + 'devtools-profiler regress path/to/baseline', + 'devtools-profiler regress --warn-only path/to/baseline', + 'devtools-profiler regress 0712060003-8c410 0711235455-ebfb3', + 'devtools-profiler regress --min-live-bytes 524288 path/to/baseline', + ], + ); + + @override + Future run() async { + final options = presentationOptions; + final warnOnly = argResults!['warn-only'] as bool? ?? false; + + // Resolve baseline (first positional arg). + if (argResults!.rest.isEmpty) { + usageException( + 'The regress command requires at least a baseline target.\n' + 'Examples:\n' + ' ${runner!.executableName} regress path/to/baseline-session\n' + ' ${runner!.executableName} regress 0712060003-8c410', + ); + } + + final baselinePath = await resolveSessionOrPath(argResults!.rest.first); + + // Resolve current path: second positional arg or latest stored session. + String currentPath; + if (argResults!.rest.length >= 2) { + currentPath = await resolveSessionOrPath(argResults!.rest[1]); + } else { + final sessionsDir = defaultSessionsDirectory(); + final sessions = await discoverSessions(sessionsDir); + if (sessions.isEmpty) { + throw ArgumentError( + 'No stored profiling sessions found and no current target was ' + 'provided. Pass a current target path or session id.', + ); + } + currentPath = sessions.first.directory.path; + } + + final memoryClassLimitStr = argResults!['memory-class-limit'] as String?; + final memoryClassLimitSpecified = memoryClassLimitStr != null; + + final comparison = await prepareProfileComparison( + profileRunner, + baselinePath: baselinePath, + currentPath: currentPath, + 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, ); + + final hasRegressions = comparison.regressions.insights.isNotEmpty; + + if (printJson) { + final json = comparisonPresentationJson(comparison); + if (!warnOnly) { + json['regressionExitCode'] = regressionExitCode; + } + writeJson(json); + } else if (printCsv) { + writeCsvComparisonFrames(line, comparison.comparison); + } else { + if (hasRegressions) { + line('Regression check: REGRESSIONS DETECTED'); + } else { + line('Regression check: PASSED'); + } + writeComparisonSummary(io, comparison, options: options); + } + + if (hasRegressions && !warnOnly) { + return regressionExitCode; + } + return successExitCode; } } @@ -146,10 +298,17 @@ class CompareCommand extends ProfilerCommand with ProfileSessionResolution { class TrendsCommand extends ProfilerCommand with ProfileSessionResolution { /// Creates a trends command. TrendsCommand(super.profileRunner) { - argParser.addOption( - 'profile-id', - help: 'Profile id to select from each session directory.', - ); + argParser + ..addOption( + 'profile-id', + help: 'Profile id to select from each session directory.', + ) + ..addOption( + 'last', + help: + 'Use the N most recent stored sessions. ' + 'Ignored when explicit paths are provided.', + ); } @override @@ -168,6 +327,7 @@ class TrendsCommand extends ProfilerCommand with ProfileSessionResolution { const [ 'devtools-profiler trends session-a session-b session-c', 'devtools-profiler trends --json --profile-id overall', + 'devtools-profiler trends --last 5', ], ); @@ -188,6 +348,8 @@ class TrendsCommand extends ProfilerCommand with ProfileSessionResolution { if (printJson) { writeJson(trendPresentationJson(trends)); + } else if (printCsv) { + writeCsvTrendSeries(line, trends.trends); } else { writeTrendSummary(io, trends, options: options); } @@ -196,8 +358,15 @@ class TrendsCommand extends ProfilerCommand with ProfileSessionResolution { } Future> _resolveTrendTargetPaths() async { + final last = argResults!['last'] as String?; + final requestedCount = last == null ? null : int.tryParse(last); + if (last != null && (requestedCount == null || requestedCount <= 0)) { + usageException('The --last option must be a positive integer.'); + } if (argResults!.rest.isNotEmpty) { - return argResults!.rest.toList(); + return [ + for (final arg in argResults!.rest) await resolveSessionOrPath(arg), + ]; } final sessionsDirectory = defaultSessionsDirectory(); @@ -208,7 +377,15 @@ class TrendsCommand extends ProfilerCommand with ProfileSessionResolution { 'sessions were found under "${sessionsDirectory.path}".', ); } - return sessions.take(2).map((session) => session.directory.path).toList(); + + if (requestedCount != null) { + final count = requestedCount < sessions.length + ? requestedCount + : sessions.length; + return sessions.take(count).map((s) => s.directory.path).toList(); + } + + return sessions.take(2).map((s) => s.directory.path).toList(); } } @@ -227,7 +404,8 @@ class InspectCommand extends ProfileTargetCommand { 'path-limit', defaultsTo: '$defaultMethodPathLimit', help: - 'Maximum representative top-down and bottom-up paths to include. Use 0 for unlimited.', + 'Maximum representative top-down and bottom-up paths to include. ' + 'Use 0 for unlimited.', ); } @@ -309,7 +487,8 @@ class CompareMethodCommand extends ProfilerCommand 'path-limit', defaultsTo: '$defaultMethodPathLimit', help: - 'Maximum representative top-down and bottom-up paths to include. Use 0 for unlimited.', + 'Maximum representative top-down and bottom-up paths to include. ' + 'Use 0 for unlimited.', ); } @@ -365,9 +544,10 @@ class CompareMethodCommand extends ProfilerCommand Future _resolveComparisonTarget(String label) async { if (argResults!.rest.length == 2) { - return label == 'baseline' + final input = label == 'baseline' ? argResults!.rest.first : argResults!.rest.last; + return resolveSessionOrPath(input); } if (argResults!.rest.isNotEmpty) { @@ -383,11 +563,10 @@ class CompareMethodCommand extends ProfilerCommand 'No explicit profile paths were provided and no stored profiling sessions were found for $label.', ); } - if (label == 'baseline') return sessions.first.directory.path; - if (sessions.length >= 2) return sessions[1].directory.path; - throw ArgumentError( - 'A second profile target is required and only one stored session is available.', - ); + return selectComparisonSession( + sessions, + baseline: label == 'baseline', + ).directory.path; } } @@ -467,7 +646,8 @@ class InspectClassesCommand extends ProfileTargetCommand { ..addOption( 'class', help: - 'Filter to classes whose name contains this query (case-insensitive).', + 'Filter to classes whose name contains this query ' + '(case-insensitive).', ) ..addOption( 'min-live-bytes', diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/annotate_command.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/annotate_command.dart new file mode 100644 index 0000000..e9efb64 --- /dev/null +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/annotate_command.dart @@ -0,0 +1,368 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:path/path.dart' as path; +import 'package:vm_service/vm_service.dart'; + +import '../constants.dart'; +import '../options.dart'; +import 'profile_target_command.dart'; + +/// Command that annotates function definition lines with self sample counts. +/// +/// Uses the function's [lineForFunction] location, not the sampled statement. +/// This does not provide statement-level execution counts. +class AnnotateCommand extends ProfileTargetCommand { + /// Creates an annotate command. + AnnotateCommand(super.profileRunner) { + argParser + ..addOption( + 'file', + help: 'Only annotate this specific source file. Matches file name.', + ) + ..addOption( + 'top', + defaultsTo: '30', + help: 'Maximum source lines to show. Use 0 for unlimited.', + ) + ..addOption( + 'min-samples', + defaultsTo: '1', + help: 'Minimum sample count to show a line.', + ) + ..addOption( + 'context', + defaultsTo: '2', + help: 'Number of non-annotated context lines to show around each hit.', + ); + } + + @override + String get name => 'annotate'; + + @override + String get description => + 'Annotate function definition lines with self sample counts.'; + + @override + String get invocation => '${runner!.executableName} annotate [path]'; + + @override + String formatUsage({bool includeDescription = true}) => usageWithExamples( + super.formatUsage(includeDescription: includeDescription), + const [ + 'devtools-profiler annotate /path/to/session', + 'devtools-profiler annotate --file vm.dart 0712060003-8c410', + 'devtools-profiler annotate --top 50 --min-samples 5 --context 3 /path/to/session', + ], + ); + + @override + Future run() async { + final targetPath = await resolveTargetPath(); + final fileFilter = argResults!['file'] as String?; + final topLimit = parseLimit( + argResults!['top'] as String?, + optionName: 'top', + ); + final minSamples = parseNonNegativeInt( + argResults!['min-samples'] as String?, + optionName: 'min-samples', + ); + final context = parseNonNegativeInt( + argResults!['context'] as String?, + optionName: 'context', + ); + + // Read the artifact and extract topSelfFrames + raw CPU samples. + final summary = await profileRunner.summarizeArtifact(targetPath); + + final List topSelfFrames; + final int totalSampleCount; + CpuSamples? cpuSamples; + + if (summary case {'regions': final Object? _}) { + final session = ProfileRunResult.fromJson(summary); + final profile = + session.overallProfile ?? + (session.regions.isNotEmpty ? session.regions.first : null); + if (profile == null) { + throw ArgumentError('No profile data found at "$targetPath".'); + } + topSelfFrames = profile.topSelfFrames; + totalSampleCount = profile.sampleCount; + if (profile.rawProfilePath != null) { + cpuSamples = await profileRunner.readCpuSamples( + profile.rawProfilePath!, + ); + } + } else if (summary case {'topSelfFrames': final Object? _}) { + final region = ProfileRegionResult.fromJson(summary); + topSelfFrames = region.topSelfFrames; + totalSampleCount = region.sampleCount; + if (region.rawProfilePath != null) { + cpuSamples = await profileRunner.readCpuSamples(region.rawProfilePath!); + } + } else { + throw ArgumentError( + 'Unsupported target at "$targetPath". ' + 'Use a session directory or a profile summary artifact.', + ); + } + + if (topSelfFrames.isEmpty) { + warn('No profile samples available for annotation.'); + return successExitCode; + } + + // Build line-level and function-level counts from raw CPU samples. + // lineCounts: file -> line -> sample count + final lineCounts = >{}; + // functionCounts: file -> function name -> sample count (fallback) + final functionCounts = >{}; + + if (cpuSamples != null && + cpuSamples.functions != null && + cpuSamples.samples != null) { + final functions = cpuSamples.functions!; + for (final sample in cpuSamples.samples!) { + final stack = sample.stack ?? const []; + if (stack.isEmpty) continue; + final funcIdx = stack.first; + if (funcIdx < 0 || funcIdx >= functions.length) continue; + final func = functions[funcIdx]; + final loc = locationForFunction(func); + if (loc == null || loc.isEmpty) continue; + if (loc.startsWith('dart:') || loc.startsWith('org-dartlang-sdk://')) { + continue; + } + final resolved = _resolveSourceFile(loc); + if (resolved == null) continue; + + final funcName = displayNameForFunction(func); + functionCounts.putIfAbsent(resolved, () => {}); + functionCounts[resolved]![funcName] = + (functionCounts[resolved]![funcName] ?? 0) + 1; + + // Line-level: use the function's definition line. + final line = lineForFunction(func); + if (line != null) { + lineCounts.putIfAbsent(resolved, () => {}); + lineCounts[resolved]![line] = (lineCounts[resolved]![line] ?? 0) + 1; + } + } + } else { + // Fallback: use topSelfFrames (function-level only). + for (final frame in topSelfFrames) { + final loc = frame.location; + if (loc == null || loc.isEmpty) continue; + if (loc.startsWith('dart:') || loc.startsWith('org-dartlang-sdk://')) { + continue; + } + final resolved = _resolveSourceFile(loc); + if (resolved == null) continue; + functionCounts.putIfAbsent(resolved, () => {}); + functionCounts[resolved]![frame.name] = + (functionCounts[resolved]![frame.name] ?? 0) + frame.selfSamples; + } + } + + if (functionCounts.isEmpty) { + warn('No source files could be resolved from profile data.'); + return successExitCode; + } + + // Sort files by total sample count. + final sortedFiles = functionCounts.entries.toList() + ..sort( + (a, b) => b.value.values + .fold(0, (s, v) => s + v) + .compareTo(a.value.values.fold(0, (s, v) => s + v)), + ); + + // Apply file filter. + final filesToShow = fileFilter != null + ? sortedFiles.where((e) => path.basename(e.key).contains(fileFilter)) + : sortedFiles.take(5); + + final filesList = filesToShow.toList(); + if (fileFilter == null && sortedFiles.length > filesList.length) { + line( + 'Showing ${filesList.length} of ${sortedFiles.length} files. ' + 'Use --file to select omitted files.', + ); + } + if (filesList.isEmpty) { + warn('No files matched filter "$fileFilter".'); + return successExitCode; + } + + final divisor = totalSampleCount == 0 ? 1 : totalSampleCount; + + for (final fileEntry in filesList) { + final filePath = fileEntry.key; + if (!File(filePath).existsSync()) { + warn('Source file not found: $filePath'); + continue; + } + + final sourceLines = File(filePath).readAsLinesSync(); + final fileLineCounts = lineCounts[filePath] ?? {}; + final fileFuncCounts = functionCounts[filePath] ?? {}; + + line(''); + line( + '╔══ ${path.basename(filePath)} ' + '(${fileFuncCounts.values.fold(0, (s, v) => s + v)} total samples)', + ); + line(''); + + if (fileLineCounts.isNotEmpty) { + // Line-level annotation. + final sortedLines = fileLineCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + final eligibleLines = sortedLines.where( + (e) => e.value >= (minSamples ?? 1), + ); + final shownLines = + (topLimit == null ? eligibleLines : eligibleLines.take(topLimit)) + .toList(); + + if (shownLines.isEmpty) { + line(' (no lines meet minimum sample threshold)'); + } else { + final maxCount = shownLines.first.value; + + // Collect hit line numbers for context window. + final hitLines = shownLines.map((e) => e.key).toSet(); + + // Print each hit line with surrounding context. + for (final (entryIndex, entry) in shownLines.indexed) { + final lineNum = entry.key; + final count = entry.value; + final pct = (count / divisor) * 100; + final barLen = ((count / maxCount) * 20).round().clamp(1, 20); + final bar = '█' * barLen; + final srcLine = lineNum > 0 && lineNum <= sourceLines.length + ? sourceLines[lineNum - 1] + : ''; + + line( + '${count.toString().padLeft(5)} (${pct.toStringAsFixed(1)}%) ' + '$bar │ ${lineNum.toString().padRight(5)} $srcLine', + ); + + // Show context lines after (if not the last hit). + if (entryIndex < shownLines.length - 1) { + final nextLine = shownLines[entryIndex + 1].key; + for ( + var ctx = lineNum + 1; + ctx < nextLine && ctx <= sourceLines.length; + ctx++ + ) { + if (hitLines.contains(ctx)) break; + if (ctx - lineNum > (context ?? 2)) break; + line( + '${' '.padLeft(5)} ${' '.padLeft(7)} │ ' + '${ctx.toString().padRight(5)} ${sourceLines[ctx - 1]}', + ); + } + } + } + } + } else { + // Function-level fallback annotation. + final sorted = fileFuncCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final maxCount = sorted.isEmpty ? 1 : sorted.first.value; + final eligible = sorted.where((e) => e.value >= (minSamples ?? 1)); + final shown = topLimit == null ? eligible : eligible.take(topLimit); + + for (final entry in shown) { + final pct = (entry.value / divisor) * 100; + final barLen = ((entry.value / maxCount) * 20).round().clamp(1, 20); + final bar = '█' * barLen; + line( + '${entry.value.toString().padLeft(5)} (${pct.toStringAsFixed(1)}%) ' + '$bar │ ${entry.key}', + ); + } + if (shown.isEmpty) { + line(' (no functions meet minimum sample threshold)'); + } + } + line(''); + } + + return successExitCode; + } + + /// Resolves package/file URIs or existing local paths; otherwise returns null. + String? _resolveSourceFile(String location) { + if (location.startsWith('package:')) { + return _resolvePackageUri(location); + } + if (location.startsWith('file://')) { + final filePath = Uri.parse(location).toFilePath(); + if (File(filePath).existsSync()) return filePath; + return null; + } + if (File(location).existsSync()) return location; + return null; + } + + /// Searches at most eight ancestors for the first package configuration. + /// + /// Returns null when no configuration or matching source file is found. + String? _resolvePackageUri(String packageUri) { + var dir = Directory.current; + for (var i = 0; i < 8; i++) { + final configFile = File( + path.join(dir.path, '.dart_tool', 'package_config.json'), + ); + if (configFile.existsSync()) { + return _resolveFromPackageConfig(configFile, packageUri); + } + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + return null; + } + + /// Resolves a package root using rootUri, trying lib/ then root-relative paths. + /// + /// Custom packageUri mappings are not supported. Missing files or invalid + /// configurations return null. + String? _resolveFromPackageConfig(File configFile, String packageUri) { + try { + final configDir = configFile.parent; + final config = + jsonDecode(configFile.readAsStringSync()) as Map; + final packages = config['packages'] as List? ?? []; + for (final pkg in packages) { + final pkgMap = pkg as Map; + final name = pkgMap['name'] as String?; + final rootUri = pkgMap['rootUri'] as String?; + if (name == null || rootUri == null) continue; + + final prefix = 'package:$name/'; + if (packageUri.startsWith(prefix)) { + final relativePath = packageUri.substring(prefix.length); + final pkgRoot = rootUri.startsWith('file://') + ? Uri.parse(rootUri).toFilePath() + : path.normalize(path.join(configDir.path, rootUri)); + var resolved = path.normalize( + path.join(pkgRoot, 'lib', relativePath), + ); + if (File(resolved).existsSync()) return resolved; + resolved = path.normalize(path.join(pkgRoot, relativePath)); + if (File(resolved).existsSync()) return resolved; + } + } + } catch (_) {} + return null; + } +} 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 90b3643..f6f3245 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 @@ -51,8 +51,17 @@ class SummarizeCommand extends ProfileTargetCommand { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ), ); + } else if (printCsv) { + final overall = prepared.session.overallProfile; + if (overall != null) { + writeCsvRegionFrames(line, overall); + } + for (final region in prepared.session.regions) { + writeCsvRegionFrames(line, region); + } } else { writeSessionSummary( io, @@ -63,6 +72,7 @@ class SummarizeCommand extends ProfileTargetCommand { regionTrees: prepared.regionTrees, regionBottomUpTrees: prepared.regionBottomUpTrees, regionMethodTables: prepared.regionMethodTables, + allocAttribution: prepared.overallAllocAttribution, options: options, ); } @@ -83,8 +93,11 @@ class SummarizeCommand extends ProfileTargetCommand { prepared.bottomUpTree, prepared.methodTable, warnings: prepared.warnings, + allocAttribution: prepared.allocAttribution, ), ); + } else if (printCsv) { + writeCsvRegionFrames(line, prepared.region); } else { writeRegionSummary( io, @@ -94,6 +107,7 @@ class SummarizeCommand extends ProfileTargetCommand { methodTable: prepared.methodTable, workingDirectory: workingDirectoryFromRegionPath(prepared.region), warnings: prepared.warnings, + allocAttribution: prepared.allocAttribution, options: options, ); } diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/browse_command.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/browse_command.dart new file mode 100644 index 0000000..eb37101 --- /dev/null +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/browse_command.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:artisanal/runtime.dart' as tui; + +import '../../browser/session_browser.dart'; +import '../constants.dart'; +import 'profile_session_resolution.dart'; +import 'profiler_command.dart'; + +/// Opens an explicit, read-only browser of stored session summaries. +final class BrowseCommand extends ProfilerCommand + with ProfileSessionResolution { + /// Creates a browser; redirected hosts must disable terminal access. + BrowseCommand(super.profileRunner, {required this.terminalAllowed}) + : super(includePresentationOptions: false) { + argParser.addOption('cwd', help: 'Project root or sessions directory.'); + } + + /// Whether the host permits exclusive ownership of terminal I/O. + final bool terminalAllowed; + + @override + String get name => 'browse'; + + @override + String get description => + 'Select stored baseline/current profiles in a read-only terminal browser.'; + + @override + Future run() async { + if (argResults!.rest.isNotEmpty || + !terminalAllowed || + !stdin.hasTerminal || + !stdout.hasTerminal) { + usageException( + 'browse requires an interactive terminal and no positional arguments. ' + 'Use profiles --extended or compare instead.', + ); + } + final directory = resolveSessionsDirectory( + cwd: argResults!['cwd'] as String?, + ); + final sessions = await discoverSessions(directory); + final browser = SessionBrowser(sessions); + if (browser.entries.isEmpty) { + warn('No stored profiles found under "${directory.path}".'); + return successExitCode; + } + final result = await tui.runProgramWithResult( + browser, + options: const tui.ProgramOptions(altScreen: true), + ); + if (result.exportedCommand case final command?) line(command); + return successExitCode; + } +} 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 4c7b212..e8e64b6 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 @@ -30,23 +30,26 @@ class RunCommand extends ProfilerCommand { ..addOption( 'duration', help: - 'Stop the launched process after this profiling duration. Supports raw seconds, "10s", "500ms", or "2m".', + 'Stop the launched process after this profiling duration. ' + 'Supports raw seconds, "10s", "500ms", or "2m".', ) ..addOption( 'vm-service-timeout', help: - 'How long to wait for the launched process to expose a Dart VM service URI. Supports raw seconds, "180s", or "3m".', + 'How long to wait for the launched process to expose a Dart VM ' + 'service URI. Supports raw seconds, "180s", or "3m".', ) ..addFlag( 'forward-output', defaultsTo: true, - help: 'Echo stdout and stderr from the launched process.', + help: 'Echo target output. With --json, send both streams to stderr.', ) ..addFlag( 'terminal', negatable: false, help: - 'Give the launched process direct terminal access for TUI and alternate-screen apps.', + 'Give the launched process direct terminal access for TUI and ' + 'alternate-screen apps.', ) ..addOption( 'warm-up', @@ -112,6 +115,7 @@ class RunCommand extends ProfilerCommand { artifactDirectory: argResults!['artifact-dir'] as String?, command: commandArguments, forwardOutput: argResults!['forward-output'] as bool, + forwardOutputToStderr: printJson, handleInterruptSignals: true, processIoMode: terminalMode ? ProfileProcessIoMode.inheritStdio @@ -153,6 +157,7 @@ class RunCommand extends ProfilerCommand { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ), ); } else { @@ -166,6 +171,7 @@ class RunCommand extends ProfilerCommand { regionBottomUpTrees: prepared.regionBottomUpTrees, regionMethodTables: prepared.regionMethodTables, options: options, + allocAttribution: prepared.overallAllocAttribution, ); } @@ -278,6 +284,7 @@ class AttachCommand extends ProfilerCommand with VmServiceDiscovery { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ), ); } else { @@ -291,6 +298,7 @@ class AttachCommand extends ProfilerCommand with VmServiceDiscovery { regionBottomUpTrees: prepared.regionBottomUpTrees, regionMethodTables: prepared.regionMethodTables, options: options, + allocAttribution: prepared.overallAllocAttribution, ); } diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/flutter_commands.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/flutter_commands.dart index fd8371c..8a6ab6a 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/flutter_commands.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/flutter_commands.dart @@ -4,6 +4,7 @@ import 'package:devtools_profiler_core/devtools_profiler_core.dart'; import 'package:path/path.dart' as path; import 'package:vm_service/vm_service.dart'; import 'package:vm_service/vm_service_io.dart'; + import 'vm_service_discovery.dart'; import '../constants.dart'; diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/profile_session_resolution.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/profile_session_resolution.dart index c962765..200d374 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/profile_session_resolution.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/profile_session_resolution.dart @@ -18,6 +18,21 @@ import 'profiler_command.dart'; /// These methods are shared between CLI commands (via [ProfilerCommand]) and /// the MCP tool handlers to avoid duplicating session discovery logic. mixin ProfileSessionResolution on ProfilerCommand { + /// Resolves a project root or sessions directory to an absolute directory. + /// + /// Throws [ArgumentError] if an explicitly supplied directory does not exist. + Directory resolveSessionsDirectory({String? cwd}) { + if (cwd == null) return defaultSessionsDirectory(); + final directory = Directory(path.normalize(path.absolute(cwd))); + if (!directory.existsSync()) { + throw ArgumentError('Directory not found: $cwd'); + } + final nested = Directory( + path.join(directory.path, '.dart_tool', 'devtools_profiler', 'sessions'), + ); + return nested.existsSync() ? nested : directory; + } + /// Locates the default sessions directory under the current working /// directory. /// @@ -39,6 +54,36 @@ mixin ProfileSessionResolution on ProfilerCommand { return Directory.current; } + /// Resolves [input] as a stored session id when possible; otherwise + /// returns the normalized absolute path. + /// + /// Session ids are matched against stored sessions under + /// [sessionsDirectory] (or [defaultSessionsDirectory] when omitted). + /// When no stored sessions are available or the input does not match + /// any session id, the input is treated as a file path and normalized + /// to an absolute path. + Future resolveSessionOrPath( + String input, { + Directory? sessionsDirectory, + }) async { + final absolutePath = path.normalize(path.absolute(input)); + if (FileSystemEntity.typeSync(absolutePath) != + FileSystemEntityType.notFound) { + return absolutePath; + } + final dir = sessionsDirectory ?? defaultSessionsDirectory(); + final sessions = await discoverSessions(dir); + if (sessions.isNotEmpty) { + try { + final selected = selectSession(sessions, sessionId: input); + return selected.directory.path; + } on ArgumentError { + // Not a matching session id — fall through to file path. + } + } + return absolutePath; + } + /// Lists all stored profiling sessions, sorted newest first. /// /// Reads each subdirectory of [directory] that contains a `session.json` @@ -71,6 +116,19 @@ mixin ProfileSessionResolution on ProfilerCommand { return sessions; } + /// Selects the previous baseline or newest current run from discovery order. + StoredSession selectComparisonSession( + List sessions, { + required bool baseline, + }) { + if (sessions.length < 2) { + throw ArgumentError( + 'A comparison requires at least two stored sessions.', + ); + } + return selectSession(sessions, sessionId: baseline ? 'previous' : 'latest'); + } + /// Returns the single stored session identified by [sessionId]. /// /// When [sessionId] is `null` or empty, returns the first (newest) session. diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/profile_target_command.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/profile_target_command.dart index 993ec98..a663553 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/profile_target_command.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/profile_target_command.dart @@ -1,7 +1,5 @@ import 'dart:io'; -import 'package:path/path.dart' as path; - import 'profiler_command.dart'; import 'profile_session_resolution.dart'; @@ -48,12 +46,21 @@ abstract class ProfileTargetCommand extends ProfilerCommand /// and returns the path to the selected session (newest by default, or /// whatever `--session-id` specifies). /// + /// When an explicit positional argument is given, [resolveSessionOrPath] + /// first tries to match it against stored session ids using the + /// `--cwd`-aware sessions directory. Only when no stored session matches + /// does it normalize the input as a file path. + /// /// Throws [ArgumentError] when no stored sessions are found and no /// explicit path was provided. Future resolveTargetPath() async { final explicit = explicitTargetPath; if (explicit != null) { - return path.normalize(path.absolute(explicit)); + final sessionsDirectory = _resolveSessionsDirectory(); + return resolveSessionOrPath( + explicit, + sessionsDirectory: sessionsDirectory, + ); } final sessionsDirectory = _resolveSessionsDirectory(); @@ -72,17 +79,6 @@ abstract class ProfileTargetCommand extends ProfilerCommand /// Resolves the sessions directory, checking --cwd first. Directory _resolveSessionsDirectory() { - final cwd = argResults!['cwd'] as String?; - if (cwd != null) { - final normalized = path.normalize(path.absolute(cwd)); - final dartTool = Directory( - path.join(normalized, '.dart_tool', 'devtools_profiler', 'sessions'), - ); - if (dartTool.existsSync()) return dartTool; - final dir = Directory(normalized); - if (dir.existsSync()) return dir; - throw ArgumentError('Directory not found: $cwd'); - } - return defaultSessionsDirectory(); + return resolveSessionsDirectory(cwd: argResults!['cwd'] as String?); } } diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/profiler_command.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/profiler_command.dart index daaac67..a61d806 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/profiler_command.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/profiler_command.dart @@ -8,13 +8,35 @@ import '../options.dart'; /// Base class for profiler commands that expose common presentation options. abstract class ProfilerCommand extends Command { /// Creates a profiler command backed by [profileRunner]. - ProfilerCommand(this.profileRunner) { - addPresentationOptions(argParser); + /// + /// When [includePresentationOptions] is false, presentation arguments are + /// omitted. Subclasses must not read [presentationOptions], [printJson], + /// or [printCsv]. + ProfilerCommand( + this.profileRunner, { + bool includePresentationOptions = true, + }) { + if (includePresentationOptions) addPresentationOptions(argParser); } /// The profiler backend used by this command. final ProfileRunner profileRunner; + /// Rejects ambiguous formats and formats without an implemented renderer. + void validateOutputFormat(ArgResults arguments) { + if (!argParser.options.containsKey('json')) return; + final json = arguments['json'] as bool; + final csv = arguments['csv'] as bool; + if (json && csv) usageException('Pass either --json or --csv, not both.'); + if (csv && + !const {'summarize', 'compare', 'regress', 'trends'}.contains(name)) { + usageException('$name does not support --csv.'); + } + if (json && const {'replay', 'annotate', 'mcp'}.contains(name)) { + usageException('$name does not support --json.'); + } + } + /// Returns presentation options parsed from the current [argResults]. ProfilePresentationOptions get presentationOptions => presentationOptionsFrom(argResults!); @@ -22,6 +44,9 @@ abstract class ProfilerCommand extends Command { /// Whether to print output as JSON. bool get printJson => argResults!['json'] as bool? ?? false; + /// Whether to print output as compact CSV. + bool get printCsv => argResults!['csv'] as bool? ?? false; + /// Writes [value] as indented JSON to the command output. void writeJson(Object? value) { line(jsonEncoder.convert(value)); diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/profiles_command.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/profiles_command.dart index 7ba481c..05bd104 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/commands/profiles_command.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/profiles_command.dart @@ -1,7 +1,5 @@ import 'dart:io'; -import 'package:path/path.dart' as path; - import '../constants.dart'; import '../options.dart'; import 'profiler_command.dart'; @@ -22,7 +20,9 @@ class ProfilesCommand extends ProfilerCommand with ProfileSessionResolution { argParser.addOption( 'cwd', help: - 'The working directory containing .dart_tool/devtools_profiler/sessions. Defaults to the current directory.', + 'The working directory containing ' + '.dart_tool/devtools_profiler/sessions. ' + 'Defaults to the current directory.', ); argParser.addFlag( 'extended', @@ -57,8 +57,35 @@ class ProfilesCommand extends ProfilerCommand with ProfileSessionResolution { @override Future run() async { + final limit = parseLimit( + argResults!['limit'] as String?, + optionName: 'limit', + ); final sessionsDirectory = _resolveSessionsDirectory(); final sessions = await discoverSessions(sessionsDirectory); + final listed = limit == null + ? sessions + : sessions.take(limit).toList(growable: false); + final truncated = limit != null && sessions.length > limit; + + if (printJson) { + writeJson({ + 'kind': 'sessions', + 'sessionsDirectory': sessionsDirectory.path, + 'totalCount': sessions.length, + 'returnedCount': listed.length, + 'truncated': truncated, + 'sessions': [ + for (final session in listed) + { + 'path': session.directory.path, + 'modifiedTime': session.modifiedTime.toUtc().toIso8601String(), + 'session': session.result.toJson(), + }, + ], + }); + return successExitCode; + } if (sessions.isEmpty) { warn( @@ -67,15 +94,6 @@ class ProfilesCommand extends ProfilerCommand with ProfileSessionResolution { return successExitCode; } - final limit = parseLimit( - argResults!['limit'] as String?, - optionName: 'limit', - ); - final listed = limit == null - ? sessions - : sessions.take(limit).toList(growable: false); - final truncated = limit != null && sessions.length > limit; - line('Profiling Sessions (${listed.length} of ${sessions.length}):'); if (argResults!['extended'] as bool? ?? false) { @@ -124,19 +142,7 @@ class ProfilesCommand extends ProfilerCommand with ProfileSessionResolution { /// Locates the sessions directory, using --cwd when provided. Directory _resolveSessionsDirectory() { - final cwd = argResults!['cwd'] as String?; - if (cwd != null) { - // Check for .dart_tool/devtools_profiler/sessions under the given path - final dartToolDir = Directory( - path.join(cwd, '.dart_tool', 'devtools_profiler', 'sessions'), - ); - if (dartToolDir.existsSync()) return dartToolDir; - // Fall back to the raw path - final dir = Directory(cwd); - if (dir.existsSync()) return dir; - throw ArgumentError('Directory not found: $cwd'); - } - return defaultSessionsDirectory(); + return resolveSessionsDirectory(cwd: argResults!['cwd'] as String?); } String _formatTime(DateTime time) { diff --git a/packages/devtools_profiler_cli/lib/src/cli/commands/replay_command.dart b/packages/devtools_profiler_cli/lib/src/cli/commands/replay_command.dart new file mode 100644 index 0000000..ccdf74b --- /dev/null +++ b/packages/devtools_profiler_cli/lib/src/cli/commands/replay_command.dart @@ -0,0 +1,279 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../constants.dart'; +import 'profile_target_command.dart'; + +/// Command that replays a stored profiling session as a live flame chart. +/// +/// Reads the CPU samples from a stored session and animates through them +/// in time-order, showing a flame-chart-style bar for each time window. +/// Uses carriage return to update the bar in place on the terminal. +class ReplayCommand extends ProfileTargetCommand { + /// Creates a replay command. + ReplayCommand(super.profileRunner, {this.terminalAllowed = false}) { + argParser + ..addOption( + 'window', + defaultsTo: '500', + help: 'Time window in milliseconds for each flame chart frame.', + ) + ..addOption( + 'speed', + defaultsTo: '1.0', + help: 'Replay speed multiplier. 0.5 = half speed (slower).', + ) + ..addOption( + 'top', + defaultsTo: '5', + help: 'Number of top frames to show in each bar.', + ); + } + + /// Whether output is connected directly to the user's terminal. + final bool terminalAllowed; + + @override + String get name => 'replay'; + + @override + String get description => + 'Replay a stored profiling session as an animated flame chart.'; + + @override + String get invocation => '${runner!.executableName} replay [options] '; + + @override + String formatUsage({bool includeDescription = true}) => usageWithExamples( + super.formatUsage(includeDescription: includeDescription), + const [ + 'devtools-profiler replay path/to/session', + 'devtools-profiler replay --window 200 0712060003-8c410', + 'devtools-profiler replay --speed 0.5 path/to/session', + ], + ); + + @override + Future run() async { + const maxDelayMillis = 2_147_483_647; + final windowMs = int.tryParse(argResults!['window'] as String); + final speed = double.tryParse(argResults!['speed'] as String); + final topCount = int.tryParse(argResults!['top'] as String); + if (windowMs == null || windowMs <= 0 || windowMs > maxDelayMillis) { + usageException('--window must be between 1 and $maxDelayMillis.'); + } + if (speed == null || !speed.isFinite || speed <= 0) { + usageException('--speed must be positive and finite.'); + } + if (topCount == null || topCount <= 0) { + usageException('--top must be a positive integer.'); + } + final delay = windowMs / speed; + if (!delay.isFinite || delay > maxDelayMillis) { + usageException('The replay delay is too large; increase --speed.'); + } + final displayDelay = delay.round(); + final targetPath = await resolveTargetPath(); + + // Load the session data. + final summary = await profileRunner.summarizeArtifact(targetPath); + + final CpuSamples? cpuSamples; + final int timeOrigin; + final int timeExtent; + final String? sessionLabel; + + if (summary case {'regions': final Object? _}) { + final session = ProfileRunResult.fromJson(summary); + sessionLabel = session.sessionId; + final profile = + session.overallProfile ?? + (session.regions.isNotEmpty ? session.regions.first : null); + if (profile?.rawProfilePath == null) { + throw ArgumentError( + 'No raw CPU profile data available in this session. ' + 'Re-run with CPU profiling enabled.', + ); + } + cpuSamples = await profileRunner.readCpuSamples(profile!.rawProfilePath!); + timeOrigin = profile.startTimestampMicros; + timeExtent = profile.durationMicros; + } else if (summary case {'topSelfFrames': final Object? _}) { + final region = ProfileRegionResult.fromJson(summary); + sessionLabel = region.name; + if (region.rawProfilePath == null) { + throw ArgumentError('No raw CPU profile data available.'); + } + cpuSamples = await profileRunner.readCpuSamples(region.rawProfilePath!); + timeOrigin = region.startTimestampMicros; + timeExtent = region.durationMicros; + } else { + throw ArgumentError('Unsupported target. Use a session or region.'); + } + + if (cpuSamples.samples == null || cpuSamples.samples!.isEmpty) { + warn('No CPU samples found in this session.'); + return successExitCode; + } + + final functions = cpuSamples.functions ?? const []; + final samples = cpuSamples.samples!; + final windowMicros = windowMs * 1_000; + final totalDuration = timeExtent > 0 ? timeExtent : 1; + + line('Replay: $sessionLabel'); + line('Duration: ${_formatDuration(totalDuration)}'); + line('Samples: ${samples.length}'); + line('Window: ${windowMs}ms (display delay: ${displayDelay}ms)'); + line(''); + line('Flame Chart:'); + line(''); + + // Sort samples by timestamp. + final sortedSamples = samples.toList() + ..sort((a, b) => (a.timestamp ?? 0).compareTo(b.timestamp ?? 0)); + + final sampleStart = sortedSamples.first.timestamp ?? timeOrigin; + final sampleEnd = sortedSamples.last.timestamp ?? (timeOrigin + timeExtent); + final totalSpan = sampleEnd - sampleStart; + + // Group samples into windows and render each window. + final animate = terminalAllowed && stdout.hasTerminal; + var windowStart = sampleStart; + var windowIndex = 0; + var totalFramesRendered = 0; + var sampleIndex = 0; + + while (animate && windowStart <= sampleEnd) { + final windowEnd = windowStart + windowMicros; + final firstIndex = sampleIndex; + while (sampleIndex < sortedSamples.length && + (sortedSamples[sampleIndex].timestamp ?? 0) < windowEnd) { + sampleIndex++; + } + final windowSamples = sortedSamples.getRange(firstIndex, sampleIndex); + + // Count top frames in this window. + final frameCounts = {}; + for (final sample in windowSamples) { + final stack = sample.stack ?? const []; + if (stack.isEmpty) continue; + final funcIdx = stack.first; + if (funcIdx < 0 || funcIdx >= functions.length) continue; + final func = functions[funcIdx]; + final name = _resolveFrameLabel(func); + frameCounts[name] = (frameCounts[name] ?? 0) + 1; + } + + // Build the flame chart bar. + final elapsedPct = totalSpan > 0 + ? ((windowStart - sampleStart) / totalSpan * 100).toStringAsFixed(0) + : '0'; + final sorted = frameCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + final bar = StringBuffer('[$elapsedPct%] '); + final maxCount = sorted.isEmpty || sorted.first.value == 0 + ? 1 + : sorted.first.value; + var remaining = 40; + + for (var i = 0; i < sorted.length && i < topCount; i++) { + final entry = sorted[i]; + final barLen = ((entry.value / maxCount) * remaining).round().clamp( + 1, + remaining, + ); + final label = '${entry.key}(${entry.value})'; + if (bar.length + label.length > 80) break; + bar.write('█' * barLen); + bar.write('$label '); + remaining -= + barLen + entry.key.length + entry.value.toString().length + 3; + if (remaining <= 5) break; + } + + if (sorted.isEmpty) { + bar.write('(idle)'); + } + + // Keep animation on the same output route as the aggregate summary. + line('${windowIndex > 0 ? '\x1b[1A\x1b[2K' : ''}$bar'); + + windowStart = windowEnd; + windowIndex++; + totalFramesRendered++; + + // Delay for replay effect. + if (displayDelay > 0 && windowStart <= sampleEnd) { + await Future.delayed(Duration(milliseconds: displayDelay)); + } + } + + if (!animate) { + line('Animation skipped: non-interactive output.'); + } + + // Print summary. + line(''); + line('=== Replay Complete ==='); + line('Frames rendered: $totalFramesRendered'); + line('Time span: ${_formatDuration(totalSpan)}'); + line(''); + + // Aggregate summary. + final totalFrameCounts = {}; + for (final sample in sortedSamples) { + final stack = sample.stack ?? const []; + if (stack.isEmpty) continue; + final idx = stack.first; + if (idx < 0 || idx >= functions.length) continue; + final name = _resolveFrameLabel(functions[idx]); + totalFrameCounts[name] = (totalFrameCounts[name] ?? 0) + 1; + } + final sortedTotal = totalFrameCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final maxTotal = sortedTotal.isEmpty ? 1 : sortedTotal.first.value; + + for (final entry in sortedTotal.take(10)) { + final pct = (entry.value / sortedSamples.length * 100).toStringAsFixed(1); + final barLen = ((entry.value / maxTotal) * 20).round().clamp(1, 20); + line( + ' ${entry.value.toString().padLeft(5)} ($pct%) ' + '${'█' * barLen} ${entry.key}', + ); + } + + line(''); + return successExitCode; + } + + /// Resolves a frame label from a [ProfileFunction], showing the function + /// kind (Native, Stub, etc.) when the Dart name is unavailable. + String _resolveFrameLabel(ProfileFunction func) { + final name = displayNameForFunction(func); + if (name.isNotEmpty && name != 'unknown' && !name.startsWith('dart::')) { + return name; + } + // Fall back to the function kind when no name is available. + final kind = func.kind; + if (kind != null && kind.isNotEmpty && kind != 'unknown') { + return '[$kind]'; + } + return '[native]'; + } + + String _formatDuration(int micros) { + final ms = micros ~/ 1_000; + if (ms >= 60_000) { + return '${ms ~/ 60_000}m${(ms % 60_000) ~/ 1_000}s'; + } + if (ms >= 1_000) { + return '${(ms / 1_000).toStringAsFixed(1)}s'; + } + return '${ms}ms'; + } +} diff --git a/packages/devtools_profiler_cli/lib/src/cli/options.dart b/packages/devtools_profiler_cli/lib/src/cli/options.dart index 71eb98f..4149694 100644 --- a/packages/devtools_profiler_cli/lib/src/cli/options.dart +++ b/packages/devtools_profiler_cli/lib/src/cli/options.dart @@ -6,6 +6,13 @@ import '../presentation.dart'; void addPresentationOptions(ArgParser parser) { parser ..addFlag('json', negatable: false, help: 'Print the result as JSON.') + ..addFlag( + 'csv', + negatable: false, + help: + 'Output compact CSV tables instead of formatted terminal output.' + ' Combines with --hide-sdk for a focused view.', + ) ..addFlag( 'call-tree', negatable: false, @@ -32,10 +39,18 @@ void addPresentationOptions(ArgParser parser) { negatable: false, help: 'Hide common profiler/runtime helper packages from summaries.', ) + ..addFlag( + 'collapse-async', + negatable: false, + help: + 'Collapse all dart:async frames into a single "async overhead" entry' + ' in summary tables.', + ) ..addMultiOption( 'include-package', help: - 'Only include package prefixes that match these values. May be repeated.', + 'Only include package prefixes that match these values. ' + 'May be repeated.', ) ..addMultiOption( 'exclude-package', @@ -51,25 +66,29 @@ void addPresentationOptions(ArgParser parser) { 'frame-limit', defaultsTo: '$defaultFrameLimit', help: - 'Maximum rows per self / total table. Use 0 to show every matching frame.', + 'Maximum rows per self / total table. ' + 'Use 0 to show every matching frame.', ) ..addOption( 'tree-depth', defaultsTo: '$defaultTreeDepth', help: - 'Maximum call tree depth when --call-tree is used. Use 0 for unlimited.', + 'Maximum call tree depth when --call-tree is used. ' + 'Use 0 for unlimited.', ) ..addOption( 'tree-children', defaultsTo: '$defaultTreeChildren', help: - 'Maximum children per call tree node when --call-tree is used. Use 0 for unlimited.', + 'Maximum children per call tree node when --call-tree is used. ' + 'Use 0 for unlimited.', ) ..addOption( 'method-limit', defaultsTo: '$defaultFrameLimit', help: - 'Maximum methods to include when --method-table is used. Use 0 for unlimited.', + 'Maximum methods to include when --method-table is used. ' + 'Use 0 for unlimited.', ); } @@ -84,6 +103,7 @@ ProfilePresentationOptions presentationOptionsFrom(ArgResults results) { includeMethodTable: results['method-table'] as bool? ?? false, hideSdk: results['hide-sdk'] as bool? ?? false, hideRuntimeHelpers: results['hide-runtime-helpers'] as bool? ?? false, + collapseAsync: results['collapse-async'] as bool? ?? false, fullLocations: results['full-locations'] as bool? ?? false, includePackages: (results['include-package'] as List? ?? const []) .where((value) => value.isNotEmpty) diff --git a/packages/devtools_profiler_cli/lib/src/mcp/server.dart b/packages/devtools_profiler_cli/lib/src/mcp/server.dart index 3ebd4e6..504a40e 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/server.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/server.dart @@ -10,7 +10,7 @@ import 'tool_definitions.dart'; import 'tool_handlers.dart'; const _serverName = 'devtools-profiler'; -const _serverVersion = '0.1.0'; +const _serverVersion = '0.6.0'; /// Serves the profiler backend over the MCP stdio transport. Future serveMcp({ @@ -66,6 +66,7 @@ base class ProfilerMcpServer extends MCPServer with ToolsSupport { registerTool(profileAnalyzeTrendsTool, handlers.profileAnalyzeTrends); registerTool(profileFindRegressionsTool, handlers.profileFindRegressions); registerTool(profileInspectClassesTool, handlers.profileInspectClasses); + registerTool(profileRegressTool, handlers.profileRegress); registerTool(profileDiscoverAppsTool, handlers.profileDiscoverApps); registerTool(profileFrameProfileTool, handlers.profileFrameProfile); registerTool(profileTimelineTool, handlers.profileFrameProfile); 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 8933fa1..f02d185 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/tool_handlers.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/tool_handlers.dart @@ -15,8 +15,11 @@ const _sessionFileName = 'session.json'; const _sessionsDirectoryName = 'sessions'; const _defaultSessionListLimit = 20; -typedef _ProgressReporter = - void Function(num progress, num total, String message); +typedef _ProgressReporter = void Function( + num progress, + num total, + String message, +); /// Handles MCP profiler tool calls. class McpToolHandlers { @@ -76,6 +79,7 @@ class McpToolHandlers { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ); progress(3, 3, 'Profile run completed.'); return response; @@ -133,6 +137,7 @@ class McpToolHandlers { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ); progress(3, 3, 'Attach profiling completed.'); return response; @@ -265,6 +270,7 @@ class McpToolHandlers { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ), }; progress(3, 3, 'Latest session prepared.'); @@ -299,6 +305,7 @@ class McpToolHandlers { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ), }; progress(3, 3, 'Session prepared.'); @@ -340,6 +347,7 @@ class McpToolHandlers { prepared.bottomUpTree, prepared.methodTable, warnings: prepared.warnings, + allocAttribution: prepared.allocAttribution, ), }; progress(3, 3, 'Region prepared.'); @@ -489,6 +497,24 @@ class McpToolHandlers { action: (progress) async { final arguments = request.arguments ?? const {}; final treeOptions = _treeOptionsFromArguments(arguments); + if (arguments.containsKey('paths')) { + final paths = _stringListArgument(arguments, key: 'paths'); + if (paths.length < 2) { + throw ArgumentError('paths requires at least two profile targets.'); + } + progress(0, 2, 'Preparing cross-run frames.'); + final prepared = await prepareProfileFrameColumns( + runner, + paths: paths, + options: treeOptions, + ); + progress(2, 2, 'Cross-run frames prepared.'); + return frameColumnsJson( + prepared.columns, + warnings: prepared.warnings, + frameLimit: treeOptions.frameLimit, + ); + } progress(0, 3, 'Resolving comparison targets.'); final baselinePath = await _resolveComparisonTargetPath( arguments, @@ -628,6 +654,59 @@ class McpToolHandlers { ); } + Future profileRegress(CallToolRequest request) { + return _runTool( + request: request, + successMessage: 'Regression check completed.', + action: (progress) async { + final arguments = request.arguments ?? const {}; + final warnOnly = arguments['warnOnly'] as bool? ?? false; + final treeOptions = _treeOptionsFromArguments(arguments); + progress(0, 3, 'Resolving regression targets.'); + + final baselinePath = await _resolveComparisonTargetPath( + arguments, + pathKey: 'baselinePath', + sessionPathKey: 'baselineSessionPath', + sessionIdKey: 'baselineSessionId', + ); + final currentPath = await _resolveComparisonTargetPath( + arguments, + pathKey: 'currentPath', + sessionPathKey: 'currentSessionPath', + sessionIdKey: 'currentSessionId', + ); + progress(1, 3, 'Preparing regression comparison.'); + + final comparison = await prepareProfileComparison( + runner, + baselinePath: baselinePath, + currentPath: currentPath, + baselineProfileId: _optionalStringArgument( + arguments, + key: 'baselineProfileId', + ), + currentProfileId: _optionalStringArgument( + arguments, + key: 'currentProfileId', + ), + options: treeOptions, + ); + + final hasRegressions = comparison.regressions.insights.isNotEmpty; + progress(3, 3, 'Regression check completed.'); + + final json = comparisonPresentationJson(comparison); + if (!warnOnly && hasRegressions) { + json['regressionExitCode'] = 1; + } + json['kind'] = 'regressionCheck'; + json['hasRegressions'] = hasRegressions; + return json; + }, + ); + } + Future profileInspectClasses(CallToolRequest request) { return _runTool( request: request, @@ -1044,6 +1123,7 @@ class McpToolHandlers { prepared.regionTrees, prepared.regionBottomUpTrees, prepared.regionMethodTables, + prepared.overallAllocAttribution, ); } if (summary case {'topSelfFrames': final Object? _}) { @@ -1058,6 +1138,7 @@ class McpToolHandlers { prepared.bottomUpTree, prepared.methodTable, warnings: prepared.warnings, + allocAttribution: prepared.allocAttribution, ); } return summary; @@ -1581,6 +1662,7 @@ ProfilePresentationOptions _treeOptionsFromArguments( includeMethodTable: arguments['includeMethodTable'] as bool? ?? false, hideSdk: arguments['hideSdk'] as bool? ?? false, hideRuntimeHelpers: arguments['hideRuntimeHelpers'] as bool? ?? false, + collapseAsync: arguments['collapseAsync'] as bool? ?? false, includePackages: _stringListOrEmpty(arguments['includePackages']), excludePackages: _stringListOrEmpty(arguments['excludePackages']), frameLimit: _treeLimitFromArgument( 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 ed99bec..dbf63c6 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 @@ -1,19 +1,26 @@ import 'package:dart_mcp/server.dart'; +const _collapseAsyncDescription = + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.'; + final Tool profileExplainHotspotsTool = Tool( name: 'profile_explain_hotspots', title: 'Profile Explain Hotspots', description: - 'Explain the main hotspots in a stored session profile or a direct profile artifact.', + 'Explain the main hotspots in a stored session profile or a ' + 'direct profile artifact.', inputSchema: Schema.object( properties: { 'path': Schema.string( description: - 'A session directory or a direct profile artifact path. When omitted, use session selectors instead.', + 'A session directory or a direct profile artifact path. When ' + 'omitted, use session selectors instead.', ), 'profileId': Schema.string( description: - 'Optional profile id to select from a session directory path. Use "overall" for the whole-session profile.', + 'Optional profile id to select from a session directory path. ' + 'Use "overall" for the whole-session profile.', ), 'rootDirectory': Schema.string( description: @@ -24,14 +31,16 @@ final Tool profileExplainHotspotsTool = Tool( ), 'sessionId': Schema.string( description: - 'Session id to resolve under the sessions directory. Also accepts "latest".', + 'Session id to resolve under the sessions directory. Also ' + 'accepts "latest".', ), 'sessionPath': Schema.string( description: 'Direct path to a session directory.', ), 'regionId': Schema.string( description: - 'Optional explicit region id to explain. Defaults to "overall" when available.', + 'Optional explicit region id to explain. Defaults to "overall" ' + 'when available.', ), 'includeCallTree': Schema.bool( description: 'Whether to attach a top-down call tree.', @@ -41,7 +50,8 @@ final Tool profileExplainHotspotsTool = Tool( ), 'includeMethodTable': Schema.bool( description: - 'Whether to include the DevTools-style method table in the returned profile.', + 'Whether to include the DevTools-style method table in the ' + 'returned profile.', ), 'hideSdk': Schema.bool( description: 'Whether to hide Dart and Flutter SDK frames.', @@ -49,9 +59,11 @@ final Tool profileExplainHotspotsTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -64,15 +76,18 @@ final Tool profileExplainHotspotsTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. Use 0 for ' + 'unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. Use 0 ' + 'for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is ' + 'true. Use 0 for unlimited.', ), }, additionalProperties: false, @@ -94,16 +109,20 @@ final Tool profileInspectMethodTool = Tool( name: 'profile_inspect_method', title: 'Profile Inspect Method', description: - 'Inspect one method in a stored session profile or direct profile artifact and return callers, callees, and representative paths.', + 'Inspect one method in a stored session profile or direct ' + 'profile artifact and return callers, callees, and ' + 'representative paths.', inputSchema: Schema.object( properties: { 'path': Schema.string( description: - 'A session directory or a direct profile artifact path. When omitted, use session selectors instead.', + 'A session directory or a direct profile artifact path. When ' + 'omitted, use session selectors instead.', ), 'profileId': Schema.string( description: - 'Optional profile id to select from a session directory path. Use "overall" for the whole-session profile.', + 'Optional profile id to select from a session directory path. ' + 'Use "overall" for the whole-session profile.', ), 'rootDirectory': Schema.string( description: @@ -114,20 +133,23 @@ final Tool profileInspectMethodTool = Tool( ), 'sessionId': Schema.string( description: - 'Session id to resolve under the sessions directory. Also accepts "latest".', + 'Session id to resolve under the sessions directory. Also ' + 'accepts "latest".', ), 'sessionPath': Schema.string( description: 'Direct path to a session directory.', ), 'regionId': Schema.string( description: - 'Optional explicit region id to inspect. Defaults to "overall" when available.', + 'Optional explicit region id to inspect. Defaults to "overall" ' + 'when available.', ), 'methodId': Schema.string(description: 'Exact method id to inspect.'), 'methodName': Schema.string(description: 'Method name query to inspect.'), 'pathLimit': Schema.int( description: - 'Maximum representative top-down and bottom-up paths to include. Use 0 for unlimited.', + 'Maximum representative top-down and bottom-up paths to ' + 'include. Use 0 for unlimited.', ), 'hideSdk': Schema.bool( description: 'Whether to hide Dart and Flutter SDK frames.', @@ -135,9 +157,11 @@ final Tool profileInspectMethodTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -150,7 +174,8 @@ final Tool profileInspectMethodTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. Use 0 for ' + 'unlimited.', ), }, additionalProperties: false, @@ -172,16 +197,19 @@ final Tool profileSearchMethodsTool = Tool( name: 'profile_search_methods', title: 'Profile Search Methods', description: - 'Search a stored session profile or direct profile artifact for matching methods and return ranked candidates.', + 'Search a stored session profile or direct profile artifact ' + 'for matching methods and return ranked candidates.', inputSchema: Schema.object( properties: { 'path': Schema.string( description: - 'A session directory or a direct profile artifact path. When omitted, use session selectors instead.', + 'A session directory or a direct profile artifact path. When ' + 'omitted, use session selectors instead.', ), 'profileId': Schema.string( description: - 'Optional profile id to select from a session directory path. Use "overall" for the whole-session profile.', + 'Optional profile id to select from a session directory path. ' + 'Use "overall" for the whole-session profile.', ), 'rootDirectory': Schema.string( description: @@ -192,18 +220,21 @@ final Tool profileSearchMethodsTool = Tool( ), 'sessionId': Schema.string( description: - 'Session id to resolve under the sessions directory. Also accepts "latest".', + 'Session id to resolve under the sessions directory. Also ' + 'accepts "latest".', ), 'sessionPath': Schema.string( description: 'Direct path to a session directory.', ), 'regionId': Schema.string( description: - 'Optional explicit region id to search. Defaults to "overall" when available.', + 'Optional explicit region id to search. Defaults to "overall" ' + 'when available.', ), 'query': Schema.string( description: - 'Optional method query matched against method name, id, and source location.', + 'Optional method query matched against method name, id, and ' + 'source location.', ), 'sortBy': Schema.string( description: 'Sort mode for matches: "total" or "self".', @@ -217,9 +248,11 @@ final Tool profileSearchMethodsTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -232,7 +265,8 @@ final Tool profileSearchMethodsTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. Use 0 for ' + 'unlimited.', ), }, additionalProperties: false, @@ -254,7 +288,8 @@ final Tool profileCompareMethodTool = Tool( name: 'profile_compare_method', title: 'Profile Compare Method', description: - 'Compare one method across two session/profile targets and return method, caller, and callee deltas.', + 'Compare one method across two session/profile targets and ' + 'return method, caller, and callee deltas.', inputSchema: Schema.object( properties: { 'baselinePath': Schema.string( @@ -272,7 +307,8 @@ final Tool profileCompareMethodTool = Tool( ), 'baselineSessionId': Schema.string( description: - 'Optional baseline session id. Also accepts "latest" or "previous".', + 'Optional baseline session id. Also accepts "latest" or ' + '"previous".', ), 'currentSessionId': Schema.string( description: @@ -286,17 +322,20 @@ final Tool profileCompareMethodTool = Tool( ), 'baselineProfileId': Schema.string( description: - 'Optional profile id to select from the baseline session. Use "overall" for the whole-session profile.', + 'Optional profile id to select from the baseline session. Use ' + '"overall" for the whole-session profile.', ), 'currentProfileId': Schema.string( description: - 'Optional profile id to select from the current session. Use "overall" for the whole-session profile.', + 'Optional profile id to select from the current session. Use ' + '"overall" for the whole-session profile.', ), 'methodId': Schema.string(description: 'Exact method id to compare.'), 'methodName': Schema.string(description: 'Method name query to compare.'), 'pathLimit': Schema.int( description: - 'Maximum representative top-down and bottom-up paths to include. Use 0 for unlimited.', + 'Maximum representative top-down and bottom-up paths to ' + 'include. Use 0 for unlimited.', ), 'hideSdk': Schema.bool( description: 'Whether to hide Dart and Flutter SDK frames.', @@ -304,9 +343,11 @@ final Tool profileCompareMethodTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -319,7 +360,8 @@ final Tool profileCompareMethodTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum method relations to include in the comparison. Use 0 for unlimited.', + 'Maximum method relations to include in the comparison. Use 0 ' + 'for unlimited.', ), }, additionalProperties: false, @@ -341,9 +383,17 @@ final Tool profileCompareTool = Tool( name: 'profile_compare', title: 'Profile Compare', description: - 'Compare two session/profile targets and return structured deltas plus the prepared baseline/current views.', + 'Compare two targets with structured deltas, or supply paths ' + 'for aligned cross-run CPU frames.', inputSchema: Schema.object( properties: { + 'paths': Schema.list( + description: + 'Two or more explicit session/profile paths for CPU frame ' + 'alignment. Selects whole-session profiles by default; use ' + 'region artifact paths for regions. Replaces pairwise selectors.', + items: Schema.string(), + ), 'baselinePath': Schema.string( description: 'Baseline session directory or profile artifact path.', ), @@ -359,7 +409,8 @@ final Tool profileCompareTool = Tool( ), 'baselineSessionId': Schema.string( description: - 'Optional baseline session id. Also accepts "latest" or "previous".', + 'Optional baseline session id. Also accepts "latest" or ' + '"previous".', ), 'currentSessionId': Schema.string( description: @@ -373,11 +424,13 @@ final Tool profileCompareTool = Tool( ), 'baselineProfileId': Schema.string( description: - 'Optional profile id to select from the baseline session. Use "overall" for the whole-session profile.', + 'Optional profile id to select from the baseline session. Use ' + '"overall" for the whole-session profile.', ), 'currentProfileId': Schema.string( description: - 'Optional profile id to select from the current session. Use "overall" for the whole-session profile.', + 'Optional profile id to select from the current session. Use ' + '"overall" for the whole-session profile.', ), 'includeCallTree': Schema.bool( description: 'Whether to attach top-down trees for both sides.', @@ -395,9 +448,11 @@ final Tool profileCompareTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -406,11 +461,13 @@ final Tool profileCompareTool = Tool( ), 'frameLimit': Schema.int( description: - 'Maximum rows per self / total comparison table. Use 0 for unlimited.', + 'Maximum rows per self / total comparison table. Use 0 for ' + 'unlimited.', ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method comparison. Use 0 for unlimited.', + 'Maximum methods to include in the method comparison. Use 0 ' + 'for unlimited.', ), 'minLiveBytes': Schema.int( description: @@ -425,11 +482,13 @@ final Tool profileCompareTool = Tool( ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when trees are included. Use 0 for unlimited.', + 'Maximum call tree depth when trees are included. Use 0 for ' + 'unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when trees are included. Use 0 for unlimited.', + 'Maximum children per call tree node when trees are included. ' + 'Use 0 for unlimited.', ), }, additionalProperties: false, @@ -451,12 +510,14 @@ final Tool profileAnalyzeTrendsTool = Tool( name: 'profile_analyze_trends', title: 'Profile Analyze Trends', description: - 'Analyze a sequence of stored profiling sessions and return first-to-last deltas plus recurring regressions.', + 'Analyze a sequence of stored profiling sessions and return ' + 'first-to-last deltas plus recurring regressions.', inputSchema: Schema.object( properties: { 'paths': Schema.list( description: - 'Explicit session directories or profile artifact paths in chronological order.', + 'Explicit session directories or profile artifact paths in ' + 'chronological order.', items: Schema.string(), ), 'rootDirectory': Schema.string( @@ -468,16 +529,19 @@ final Tool profileAnalyzeTrendsTool = Tool( ), 'sessionIds': Schema.list( description: - 'Optional explicit session ids to analyze in order. When omitted, the newest sessions are used.', + 'Optional explicit session ids to analyze in order. When ' + 'omitted, the newest sessions are used.', items: Schema.string(), ), 'profileId': Schema.string( description: - 'Optional profile id to select from each session. Use "overall" for the whole-session profile.', + 'Optional profile id to select from each session. Use ' + '"overall" for the whole-session profile.', ), 'limit': Schema.int( description: - 'Maximum newest stored sessions to analyze when paths/sessionIds are omitted. Use 0 for all.', + 'Maximum newest stored sessions to analyze when ' + 'paths/sessionIds are omitted. Use 0 for all.', ), 'hideSdk': Schema.bool( description: 'Whether to hide Dart and Flutter SDK frames.', @@ -485,9 +549,11 @@ final Tool profileAnalyzeTrendsTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -496,11 +562,13 @@ final Tool profileAnalyzeTrendsTool = Tool( ), 'frameLimit': Schema.int( description: - 'Maximum rows per self / total comparison table. Use 0 for unlimited.', + 'Maximum rows per self / total comparison table. Use 0 for ' + 'unlimited.', ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in trend comparisons. Use 0 for unlimited.', + 'Maximum methods to include in trend comparisons. Use 0 for ' + 'unlimited.', ), }, additionalProperties: false, @@ -522,7 +590,9 @@ final Tool profileFindRegressionsTool = Tool( name: 'profile_find_regressions', title: 'Profile Find Regressions', description: - 'Compare stored profiling sessions, defaulting to the newest run versus the previous run, and return prioritized regression insights.', + 'Compare stored profiling sessions, defaulting to the newest ' + 'run versus the previous run, and return prioritized ' + 'regression insights.', inputSchema: Schema.object( properties: { 'rootDirectory': Schema.string( @@ -534,19 +604,23 @@ final Tool profileFindRegressionsTool = Tool( ), 'baselineSessionId': Schema.string( description: - 'Optional baseline session id. Defaults to "previous". Also accepts "latest" or "previous".', + 'Optional baseline session id. Defaults to "previous". Also ' + 'accepts "latest" or "previous".', ), 'currentSessionId': Schema.string( description: - 'Optional current session id. Defaults to "latest". Also accepts "latest" or "previous".', + 'Optional current session id. Defaults to "latest". Also ' + 'accepts "latest" or "previous".', ), 'baselineProfileId': Schema.string( description: - 'Optional baseline profile id within the session. Use "overall" for the whole-session profile.', + 'Optional baseline profile id within the session. Use ' + '"overall" for the whole-session profile.', ), 'currentProfileId': Schema.string( description: - 'Optional current profile id within the session. Use "overall" for the whole-session profile.', + 'Optional current profile id within the session. Use "overall" ' + 'for the whole-session profile.', ), 'includeCallTree': Schema.bool( description: 'Whether to attach top-down trees for both sides.', @@ -564,9 +638,11 @@ final Tool profileFindRegressionsTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -575,26 +651,31 @@ final Tool profileFindRegressionsTool = Tool( ), 'frameLimit': Schema.int( description: - 'Maximum rows per self / total comparison table. Use 0 for unlimited.', + 'Maximum rows per self / total comparison table. Use 0 for ' + 'unlimited.', ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method comparison. Use 0 for unlimited.', + 'Maximum methods to include in the method comparison. Use 0 ' + 'for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when trees are included. Use 0 for unlimited.', + 'Maximum call tree depth when trees are included. Use 0 for ' + 'unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when trees are included. Use 0 for unlimited.', + 'Maximum children per call tree node when trees are included. ' + 'Use 0 for unlimited.', ), }, additionalProperties: false, ), outputSchema: Schema.object( description: - 'Structured comparison and prioritized regression summary for stored sessions.', + 'Structured comparison and prioritized regression summary for ' + 'stored sessions.', additionalProperties: true, ), annotations: ToolAnnotations( @@ -617,11 +698,13 @@ final Tool profileInspectClassesTool = Tool( properties: { 'path': Schema.string( description: - 'A session directory, region summary.json, or raw memory_profile.json.', + '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).', + 'Filter to classes whose name contains this query ' + '(case-insensitive).', ), 'minLiveBytes': Schema.int( description: @@ -647,3 +730,115 @@ final Tool profileInspectClassesTool = Tool( title: 'Profile Inspect Classes', ), ); + +final Tool profileRegressTool = Tool( + name: 'profile_regress', + title: 'Profile Regress', + description: + 'Compare the current profile against a known-good baseline and report ' + 'regressions. Returns regressionExitCode: 1 when regressions are found ' + 'unless warnOnly is true; the MCP server remains running. Use in CI ' + 'workflows to detect performance regressions.', + inputSchema: Schema.object( + properties: { + 'baselinePath': Schema.string( + description: 'Baseline session directory or profile artifact path.', + ), + 'currentPath': Schema.string( + description: + 'Current session directory or profile artifact path. ' + 'Defaults to the latest stored session.', + ), + 'rootDirectory': Schema.string( + description: + 'Project root containing .dart_tool/devtools_profiler/sessions.', + ), + 'sessionsDirectory': Schema.string( + description: 'A direct path to a devtools_profiler sessions directory.', + ), + 'baselineSessionId': Schema.string( + description: + 'Optional baseline session id. Also accepts "latest" or ' + '"previous".', + ), + 'currentSessionId': Schema.string( + description: + 'Optional current session id. Also accepts "latest" or "previous".', + ), + 'baselineProfileId': Schema.string( + description: + 'Optional profile id to select from the baseline session. ' + 'Use "overall" for the whole-session profile.', + ), + 'currentProfileId': Schema.string( + description: + 'Optional profile id to select from the current session. ' + 'Use "overall" for the whole-session profile.', + ), + 'includeCallTree': Schema.bool( + description: 'Whether to attach top-down trees for both sides.', + ), + 'includeBottomUpTree': Schema.bool( + description: 'Whether to attach bottom-up trees for both sides.', + ), + 'includeMethodTable': Schema.bool( + description: + 'Whether to attach method tables and include method deltas.', + ), + 'hideSdk': Schema.bool( + description: 'Whether to hide Dart and Flutter SDK frames.', + ), + 'hideRuntimeHelpers': Schema.bool( + description: 'Whether to hide common profiler/runtime helper packages.', + ), + 'collapseAsync': Schema.bool(description: _collapseAsyncDescription), + 'includePackages': Schema.list( + description: + 'Optional package prefixes to keep. Frames outside these packages ' + 'are hidden.', + items: Schema.string(), + ), + 'excludePackages': Schema.list( + description: 'Optional package prefixes to exclude.', + items: Schema.string(), + ), + 'warnOnly': Schema.bool( + description: + 'Report regressions as warnings and omit regressionExitCode.', + ), + 'frameLimit': Schema.int( + description: + 'Maximum rows per self / total comparison table. Use 0 for ' + 'unlimited.', + ), + 'methodLimit': Schema.int( + description: + 'Maximum methods to include in the method comparison. ' + 'Use 0 for unlimited.', + ), + 'treeDepth': Schema.int( + description: + 'Maximum call tree depth when trees are included. Use 0 for ' + 'unlimited.', + ), + 'treeChildren': Schema.int( + description: + 'Maximum children per call tree node when trees are included. ' + 'Use 0 for unlimited.', + ), + }, + additionalProperties: false, + ), + outputSchema: Schema.object( + description: + 'Prepared baseline/current comparison with regression insights.', + additionalProperties: true, + ), + annotations: ToolAnnotations( + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + title: 'Profile Regress', + ), +); diff --git a/packages/devtools_profiler_cli/lib/src/mcp/tools/artifact_tools.dart b/packages/devtools_profiler_cli/lib/src/mcp/tools/artifact_tools.dart index 36c4d2b..0d6f5f8 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/tools/artifact_tools.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/tools/artifact_tools.dart @@ -24,9 +24,15 @@ final Tool profileSummarizeTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool( + description: + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.', + ), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these packages ' + 'are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -39,15 +45,18 @@ final Tool profileSummarizeTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. ' + 'Use 0 for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. ' + 'Use 0 for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is true. ' + 'Use 0 for unlimited.', ), }, required: ['path'], @@ -96,7 +105,8 @@ final Tool profileListSessionsTool = Tool( name: 'profile_list_sessions', title: 'Profile List Sessions', description: - 'List stored profiling sessions under a project root or sessions directory.', + 'List stored profiling sessions under a project root or sessions ' + 'directory.', inputSchema: Schema.object( properties: { 'rootDirectory': Schema.string( @@ -129,7 +139,8 @@ final Tool profileListRegionsTool = Tool( name: 'profile_list_regions', title: 'Profile List Regions', description: - 'List the whole-session profile and explicit regions stored in a session.', + 'List the whole-session profile and explicit regions ' + 'stored in a session.', inputSchema: Schema.object( properties: { 'rootDirectory': Schema.string( @@ -165,7 +176,8 @@ final Tool profileLatestSessionTool = Tool( name: 'profile_latest_session', title: 'Profile Latest Session', description: - 'Resolve the newest stored profiling session and return its prepared summary.', + 'Resolve the newest stored profiling session and return its prepared ' + 'summary.', inputSchema: Schema.object( properties: { 'rootDirectory': Schema.string( @@ -190,9 +202,15 @@ final Tool profileLatestSessionTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool( + description: + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.', + ), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these packages ' + 'are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -205,15 +223,18 @@ final Tool profileLatestSessionTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. ' + 'Use 0 for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. ' + 'Use 0 for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is true. ' + 'Use 0 for unlimited.', ), }, additionalProperties: false, @@ -235,7 +256,8 @@ final Tool profileGetSessionTool = Tool( name: 'profile_get_session', title: 'Profile Get Session', description: - 'Resolve a stored profiling session by id or path and return its prepared summary.', + 'Resolve a stored profiling session by id or path and return its ' + 'prepared summary.', inputSchema: Schema.object( properties: { 'rootDirectory': Schema.string( @@ -247,7 +269,8 @@ final Tool profileGetSessionTool = Tool( ), 'sessionId': Schema.string( description: - 'Session id to resolve under the sessions directory. Also accepts "latest" or "previous".', + 'Session id to resolve under the sessions directory. Also accepts ' + '"latest" or "previous".', ), 'sessionPath': Schema.string( description: 'Direct path to a session directory.', @@ -267,9 +290,15 @@ final Tool profileGetSessionTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool( + description: + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.', + ), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these packages ' + 'are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -282,15 +311,18 @@ final Tool profileGetSessionTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. ' + 'Use 0 for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. ' + 'Use 0 for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is true. ' + 'Use 0 for unlimited.', ), }, additionalProperties: false, @@ -312,7 +344,8 @@ final Tool profileGetRegionTool = Tool( name: 'profile_get_region', title: 'Profile Get Region', description: - 'Read a stored whole-session profile or explicit region by id and return the prepared summary.', + 'Read a stored whole-session profile or explicit region by id and return ' + 'the prepared summary.', inputSchema: Schema.object( properties: { 'rootDirectory': Schema.string( @@ -330,7 +363,8 @@ final Tool profileGetRegionTool = Tool( ), 'regionId': Schema.string( description: - 'The explicit region id to load, or "overall" for the whole-session profile.', + 'The explicit region id to load, or "overall" for the ' + 'whole-session profile.', ), 'includeCallTree': Schema.bool( description: 'Whether to attach a top-down call tree.', @@ -347,9 +381,15 @@ final Tool profileGetRegionTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool( + description: + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.', + ), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these packages ' + 'are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -362,15 +402,18 @@ final Tool profileGetRegionTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. ' + 'Use 0 for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. ' + 'Use 0 for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is true. ' + 'Use 0 for unlimited.', ), }, required: ['regionId'], 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 2121ffe..cd9fe2f 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 @@ -10,7 +10,8 @@ final Tool profileRunTool = Tool( properties: { 'command': Schema.list( description: - 'A command such as ["dart", "run", "bin/main.dart"] or ["flutter", "test"].', + 'A command such as ["dart", "run", "bin/main.dart"] or ' + '["flutter", "test"].', items: Schema.string(), ), 'workingDirectory': Schema.string( @@ -21,11 +22,13 @@ final Tool profileRunTool = Tool( ), 'durationSeconds': Schema.int( description: - 'Optional duration in seconds to profile before terminating the launched process.', + 'Optional duration in seconds to profile before terminating the ' + 'launched process.', ), 'vmServiceTimeoutSeconds': Schema.int( description: - 'Optional timeout in seconds for waiting for the launched process to expose a Dart VM service URI.', + 'Optional timeout in seconds for waiting for the launched ' + 'process to expose a Dart VM service URI.', ), 'warmUpSeconds': Schema.int( description: @@ -51,9 +54,15 @@ final Tool profileRunTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool( + description: + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.', + ), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -66,15 +75,18 @@ final Tool profileRunTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. ' + 'Use 0 for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. ' + 'Use 0 for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is ' + 'true. Use 0 for unlimited.', ), }, required: ['command'], @@ -97,12 +109,14 @@ final Tool profileAttachTool = Tool( name: 'profile_attach', title: 'Profile Attach', description: - 'Attach to an existing Dart or Flutter VM service URI and return structured CPU summaries for a fixed profiling window.', + 'Attach to an existing Dart or Flutter VM service URI and return ' + 'structured CPU summaries for a fixed profiling window.', inputSchema: Schema.object( properties: { 'vmServiceUri': Schema.string( description: - 'The HTTP VM service URI printed by dart or flutter, for example "http://127.0.0.1:8181/abcd/".', + 'The HTTP VM service URI printed by dart or flutter, for example ' + '"http://127.0.0.1:8181/abcd/".', ), 'durationSeconds': Schema.int( description: @@ -136,9 +150,15 @@ final Tool profileAttachTool = Tool( 'hideRuntimeHelpers': Schema.bool( description: 'Whether to hide common profiler/runtime helper packages.', ), + 'collapseAsync': Schema.bool( + description: + 'Whether to collapse dart:async frames into a single "async overhead" ' + 'entry in summary tables.', + ), 'includePackages': Schema.list( description: - 'Optional package prefixes to keep. Frames outside these packages are hidden.', + 'Optional package prefixes to keep. Frames outside these ' + 'packages are hidden.', items: Schema.string(), ), 'excludePackages': Schema.list( @@ -151,15 +171,18 @@ final Tool profileAttachTool = Tool( ), 'methodLimit': Schema.int( description: - 'Maximum methods to include in the method table. Use 0 for unlimited.', + 'Maximum methods to include in the method table. ' + 'Use 0 for unlimited.', ), 'treeDepth': Schema.int( description: - 'Maximum call tree depth when includeCallTree is true. Use 0 for unlimited.', + 'Maximum call tree depth when includeCallTree is true. ' + 'Use 0 for unlimited.', ), 'treeChildren': Schema.int( description: - 'Maximum children per call tree node when includeCallTree is true. Use 0 for unlimited.', + 'Maximum children per call tree node when includeCallTree is ' + 'true. Use 0 for unlimited.', ), }, required: ['vmServiceUri'], diff --git a/packages/devtools_profiler_cli/lib/src/mcp/tools/flutter_tools.dart b/packages/devtools_profiler_cli/lib/src/mcp/tools/flutter_tools.dart index 8f7e70f..e89e248 100644 --- a/packages/devtools_profiler_cli/lib/src/mcp/tools/flutter_tools.dart +++ b/packages/devtools_profiler_cli/lib/src/mcp/tools/flutter_tools.dart @@ -314,8 +314,7 @@ final Tool profileDebugDumpTool = Tool( 'ws://127.0.0.1:8181/abc123/ws).', ), 'kind': Schema.string( - description: - 'What to dump: app, render, layer, focus, semantics (default: app).', + description: 'What to dump: app, render, layer, focus, semantics (default: app).', ), }, required: ['vmServiceUri'], diff --git a/packages/devtools_profiler_cli/lib/src/presentation/json.dart b/packages/devtools_profiler_cli/lib/src/presentation/json.dart index 2080a85..16fe191 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/json.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/json.dart @@ -4,6 +4,33 @@ import 'cli_command.dart'; import 'models.dart'; import 'options.dart'; +const _missingFrameMeaning = 'not listed; not evidence of elimination'; + +/// Converts aligned cross-run frames to the shared CLI/MCP response. +Map frameColumnsJson( + List columns, { + List warnings = const [], + int? frameLimit, +}) { + final rows = alignProfileFrames(columns, limit: frameLimit); + return { + 'kind': 'multi-compare', + 'warnings': warnings, + 'missingFrameMeaning': _missingFrameMeaning, + 'rows': [for (final row in rows) row.toJson()], + 'columns': [ + for (var i = 0; i < columns.length; i++) + { + 'label': columns[i].label, + 'frames': [ + for (final row in rows) + if (row.frames[i] case final frame?) frame.toJson(), + ], + }, + ], + }; +} + /// Converts a prepared session to structured JSON. Map sessionPresentationJson( ProfileRunResult session, @@ -13,6 +40,7 @@ Map sessionPresentationJson( Map regionTrees, Map regionBottomUpTrees, Map regionMethodTables, + List overallAllocAttribution, ) { return { ...session.toJson(), @@ -33,6 +61,10 @@ Map sessionPresentationJson( regionMethodTables[region.regionId], ), ], + if (overallAllocAttribution.isNotEmpty) + 'overallAllocAttribution': [ + for (final attr in overallAllocAttribution) attr.toJson(), + ], }; } @@ -43,6 +75,7 @@ Map regionPresentationJson( ProfileCallTree? bottomUpTree, ProfileMethodTable? methodTable, { List warnings = const [], + List allocAttribution = const [], }) { return { ...region.toJson(), @@ -52,6 +85,8 @@ Map regionPresentationJson( if (bottomUpTree != null) 'bottomUpTree': bottomUpTree.toJson(), if (methodTable != null) 'methodTable': methodTable.toJson(), if (warnings.isNotEmpty) 'preparationWarnings': warnings, + if (allocAttribution.isNotEmpty) + 'allocAttribution': [for (final attr in allocAttribution) attr.toJson()], }; } @@ -144,6 +179,19 @@ Map trendPresentationJson(PreparedProfileTrends trends) { 'kind': 'profileTrends', 'cliCommand': _trendsCliCommand(trends), 'targets': [for (final target in trends.targets) _trendTargetJson(target)], + 'frameAlignment': { + 'missingFrameMeaning': _missingFrameMeaning, + 'rows': [ + for (final row in alignProfileFrames([ + for (final target in trends.targets) + ProfileFrameColumn( + label: target.path, + frames: target.presentation.region.topSelfFrames, + ), + ])) + row.toJson(), + ], + }, 'trends': trends.trends.toJson(), }; } @@ -161,6 +209,7 @@ Map _comparisonTargetJson(PreparedComparisonTarget target) { target.presentation.bottomUpTree, target.presentation.methodTable, warnings: target.presentation.warnings, + allocAttribution: target.presentation.allocAttribution, ), }; } diff --git a/packages/devtools_profiler_cli/lib/src/presentation/models.dart b/packages/devtools_profiler_cli/lib/src/presentation/models.dart index 79ce9de..a68e63d 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/models.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/models.dart @@ -153,6 +153,7 @@ class PreparedSessionPresentation { required this.regionTrees, required this.regionBottomUpTrees, required this.regionMethodTables, + this.overallAllocAttribution = const [], }); /// The session result with region summaries adjusted for the view options. @@ -167,6 +168,9 @@ class PreparedSessionPresentation { /// The whole-session method table, when requested. final ProfileMethodTable? overallMethodTable; + /// Allocation call-site attribution for the overall profile. + final List overallAllocAttribution; + /// Call trees keyed by region id. final Map regionTrees; @@ -213,6 +217,7 @@ class PreparedRegionPresentation { this.bottomUpTree, this.methodTable, this.warnings = const [], + this.allocAttribution = const [], }); /// The region summary adjusted for the view options. @@ -231,4 +236,8 @@ class PreparedRegionPresentation { /// between the stored sample count and the count re-derived from the raw /// CPU profile artifact. final List warnings; + + /// Allocation call-site attribution, correlating memory class growth with + /// the CPU functions that were active during the allocation window. + final List allocAttribution; } diff --git a/packages/devtools_profiler_cli/lib/src/presentation/options.dart b/packages/devtools_profiler_cli/lib/src/presentation/options.dart index 9d859de..9c9a7c0 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/options.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/options.dart @@ -32,6 +32,7 @@ class ProfilePresentationOptions { this.maxChildren, this.hideSdk = false, this.hideRuntimeHelpers = false, + this.collapseAsync = false, this.frameLimit = defaultFrameLimit, this.methodLimit = defaultFrameLimit, this.fullLocations = false, @@ -60,6 +61,10 @@ class ProfilePresentationOptions { /// Whether common profiler/runtime helper packages should be hidden. final bool hideRuntimeHelpers; + /// Whether `dart:async` frames should be collapsed into a single + /// "async overhead" entry in summary tables. + final bool collapseAsync; + /// Maximum rows in the self / total tables, or `null` for unlimited. final int? frameLimit; @@ -75,10 +80,11 @@ class ProfilePresentationOptions { /// Optional package prefixes to exclude. final List excludePackages; - /// Whether any frame-level filters are active. + /// Whether any frame-level filters or transformations are active. bool get hasActiveFrameFilters => hideSdk || hideRuntimeHelpers || + collapseAsync || includePackages.isNotEmpty || excludePackages.isNotEmpty; @@ -86,6 +92,7 @@ class ProfilePresentationOptions { List get activeFrameFilterDescriptions => [ if (hideSdk) '--hide-sdk', if (hideRuntimeHelpers) '--hide-runtime-helpers', + if (collapseAsync) '--collapse-async', for (final package in includePackages) '--include-package $package', for (final package in excludePackages) '--exclude-package $package', ]; @@ -133,6 +140,7 @@ class ProfilePresentationOptions { int? maxChildren, bool? hideSdk, bool? hideRuntimeHelpers, + bool? collapseAsync, int? frameLimit, int? methodLimit, bool? fullLocations, @@ -147,6 +155,7 @@ class ProfilePresentationOptions { maxChildren: maxChildren ?? this.maxChildren, hideSdk: hideSdk ?? this.hideSdk, hideRuntimeHelpers: hideRuntimeHelpers ?? this.hideRuntimeHelpers, + collapseAsync: collapseAsync ?? this.collapseAsync, frameLimit: frameLimit ?? this.frameLimit, methodLimit: methodLimit ?? this.methodLimit, fullLocations: fullLocations ?? this.fullLocations, diff --git a/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart b/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart index 32e7f01..7e9d288 100644 --- a/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart +++ b/packages/devtools_profiler_cli/lib/src/presentation/preparation.dart @@ -4,6 +4,48 @@ import 'package:vm_service/vm_service.dart'; import 'models.dart'; import 'options.dart'; +/// Prepares complete available frame lists for cross-run alignment. +/// +/// Rebuilds from raw samples when available and honors the shared frame filters. +/// Stored-summary fallbacks remain explicitly incomplete. Output row limits +/// should be applied after alignment, not independently to each source. +Future<({List columns, List warnings})> +prepareProfileFrameColumns( + ProfileRunner runner, { + required List paths, + required ProfilePresentationOptions options, +}) async { + final columns = []; + final warnings = []; + for (final path in paths) { + final target = await _resolveComparisonTarget( + runner, + path, + includeAllocationCorrelation: false, + options: options.copyWith( + frameLimit: 0, + includeCallTree: false, + includeBottomUpTree: false, + includeMethodTable: false, + ), + ); + final region = target.presentation.region; + columns.add( + ProfileFrameColumn( + label: target.sessionId ?? path, + frames: region.topSelfFrames, + ), + ); + warnings.addAll(target.presentation.warnings); + if (!region.succeeded || + region.rawProfilePath == null || + region.rawProfilePath!.isEmpty) { + warnings.add('$path: Only the stored top-frame list is available.'); + } + } + return (columns: columns, warnings: warnings); +} + /// Rebuilds session summaries and trees to match [options]. Future prepareSessionPresentation( ProfileRunner runner, @@ -14,6 +56,7 @@ Future prepareSessionPresentation( ProfileCallTree? overallTree; ProfileCallTree? overallBottomUpTree; ProfileMethodTable? overallMethodTable; + List overallAllocAttribution = const []; final preparationWarnings = []; final storedOverall = session.overallProfile; if (storedOverall != null) { @@ -26,6 +69,7 @@ Future prepareSessionPresentation( overallTree = prepared.callTree; overallBottomUpTree = prepared.bottomUpTree; overallMethodTable = prepared.methodTable; + overallAllocAttribution = prepared.allocAttribution; preparationWarnings.addAll(prepared.warnings); } @@ -75,6 +119,7 @@ Future prepareSessionPresentation( regionTrees: regionTrees, regionBottomUpTrees: regionBottomUpTrees, regionMethodTables: regionMethodTables, + overallAllocAttribution: overallAllocAttribution, ); } @@ -509,16 +554,32 @@ Future prepareMemoryClassInspection( } /// Rebuilds a single region summary and tree to match [options]. +/// +/// Frame-only consumers can disable [includeAllocationCorrelation] to avoid +/// scanning CPU samples for memory correlation data they do not render. Future prepareRegionPresentation( ProfileRunner runner, ProfileRegionResult region, { required ProfilePresentationOptions options, + bool includeAllocationCorrelation = true, }) async { final rawProfilePath = region.rawProfilePath; if (!region.succeeded || rawProfilePath == null || rawProfilePath.isEmpty) { - return PreparedRegionPresentation( - region: _filterStoredRegion(region, options), - ); + final storedRegion = _filterStoredRegion(region, options); + if (options.collapseAsync) { + final preCollapseSelfFrames = storedRegion.topSelfFrames; + final collapsed = _collapseAsyncFramesInRegion(storedRegion); + final breakWarnings = _buildAsyncBreakdownWarnings( + preCollapseSelfFrames, + null, + collapsed.sampleCount, + ); + return PreparedRegionPresentation( + region: collapsed, + warnings: breakWarnings, + ); + } + return PreparedRegionPresentation(region: storedRegion); } final cpuSamples = await runner.readCpuSamples(rawProfilePath); @@ -570,8 +631,8 @@ Future prepareRegionPresentation( 'has finished or increase --vm-service-timeout.', ); } - final ProfileRegionResult regionForSummary; - if (rebuiltRegion.sampleCount == 0 && + var regionForSummary = rebuiltRegion; + if (regionForSummary.sampleCount == 0 && region.sampleCount > 0 && !options.hasActiveFrameFilters) { warnings.add( @@ -582,28 +643,63 @@ Future prepareRegionPresentation( '--method-table to inspect whether the artifact can be parsed.', ); regionForSummary = _filterStoredRegion(region, options); - } else { - regionForSummary = rebuiltRegion; } - final callTree = options.includeCallTree + // Collapse dart:async frames and add structured breakdown warnings. + if (options.collapseAsync) { + // Save pre-collapse self frames for the category breakdown — they + // contain individual dart:async entries that _buildAsyncBreakdownWarnings + // uses to compute normal vs error completions. + final preCollapseSelfFrames = regionForSummary.topSelfFrames; + + regionForSummary = _collapseAsyncFramesInRegion( + regionForSummary, + cpuSamples: cpuSamples, + ); + + warnings.addAll( + _buildAsyncBreakdownWarnings( + preCollapseSelfFrames, + cpuSamples, + regionForSummary.sampleCount, + ), + ); + } + + // Allocation call-site attribution: cross-reference memory classes with + // CPU samples to show which functions were allocating. + final allocAttribution = + includeAllocationCorrelation && + memory != null && + cpuSamples.samples != null + ? attributeAllocationsToCallers(memory, cpuSamples) + : const []; + + // Derive every view from the same complete filtered paths. Limit only the + // output trees; limiting first would lose method totals and caller edges. + final completeCallTree = + options.includeCallTree || + options.includeBottomUpTree || + options.includeMethodTable ? buildCallTree( cpuSamples: cpuSamples, includeFrame: options.framePredicate, - ).limited(maxDepth: options.maxDepth, maxChildren: options.maxChildren) + ) + : null; + final callTree = options.includeCallTree + ? completeCallTree!.limited( + maxDepth: options.maxDepth, + maxChildren: options.maxChildren, + ) : null; final bottomUpTree = options.includeBottomUpTree - ? buildBottomUpTree( - cpuSamples: cpuSamples, - includeFrame: options.framePredicate, + ? buildBottomUpTreeFromCallTree( + completeCallTree!, ).limited(maxDepth: options.maxDepth, maxChildren: options.maxChildren) : null; final methodTable = options.includeMethodTable ? _limitMethodTable( - buildMethodTable( - cpuSamples: cpuSamples, - includeFrame: options.framePredicate, - ), + buildMethodTableFromCallTree(completeCallTree!), options, ) : null; @@ -614,6 +710,7 @@ Future prepareRegionPresentation( bottomUpTree: bottomUpTree, methodTable: methodTable, warnings: warnings, + allocAttribution: allocAttribution, ); } @@ -655,6 +752,7 @@ ProfileRegionResult _filterStoredRegion( summaryPath: region.summaryPath, rawProfilePath: region.rawProfilePath, error: region.error, + extra: region.extra, ); } @@ -735,6 +833,7 @@ Future _resolveComparisonTarget( String targetPath, { String? requestedProfileId, required ProfilePresentationOptions options, + bool includeAllocationCorrelation = true, }) async { final summary = await runner.summarizeArtifact(targetPath); if (summary case {'regions': final Object? _}) { @@ -752,6 +851,7 @@ Future _resolveComparisonTarget( runner, region, options: options, + includeAllocationCorrelation: includeAllocationCorrelation, ), ); } @@ -771,6 +871,7 @@ Future _resolveComparisonTarget( runner, region, options: options, + includeAllocationCorrelation: includeAllocationCorrelation, ), ); } @@ -850,3 +951,400 @@ String _trendTargetLabel(PreparedComparisonTarget target, int index) { } return 'target-${index + 1}'; } + +/// Names of `dart:async` functions that complete futures normally (no error). +const _asyncNormalCompletionNames = { + '_Future._completeWithValue', + '_Future._setPendingComplete', + '_Future._complete', + '_setPendingComplete', + '_completeWithValue', +}; + +/// Names of `dart:async` functions that complete futures with an error. +const _asyncErrorCompletionNames = { + '_Future._completeError', + '_Future._completeErrorObject', + '_completeError', + '_completeErrorObject', + '_asyncErrorWrapper', +}; + +/// Names of `dart:async` functions that dispatch to listeners. +const _asyncListenerDispatchNames = { + '_Future._propagateToListeners', + '_FutureListener.handleValue', + '_FutureListener.handleError', + 'handleValueCallback', + 'handleError', + '_propagateToListeners', +}; + +/// Names of `dart:async` functions for microtask scheduling. +const _asyncMicrotaskNames = { + '_microtaskLoop', + '_startMicrotaskLoop', + '_runPendingImmediateCallback', +}; + +/// Names of `dart:async` zone overhead functions. +const _asyncZoneNames = { + '_RootZone.run', + '_RootZone.runUnary', + '_RootZone.runBinary', +}; + +/// Returns a display label for a recognized async function name. +/// +/// Uses substring matching because VM function names may include the owner +/// class prefix multiple times (e.g. `_Future._Future._completeErrorObject`). +String? _asyncCategoryLabel(ProfileFrameSummary frame) { + return switch (_asyncCategoryFromName(frame.name)) { + 'normal' => 'async (normal completions)', + 'error' => 'async (error completions)', + 'listener' => 'async (listener dispatch)', + 'microtask' => 'async (microtask scheduling)', + 'zone' => 'async (zone overhead)', + _ => null, + }; +} + +/// Classifies async names using substring matching for VM owner prefixes. +String _asyncCategoryFromName(String name) { + for (final entry in _asyncNormalCompletionNames) { + if (name.contains(entry)) return 'normal'; + } + for (final entry in _asyncErrorCompletionNames) { + if (name.contains(entry)) return 'error'; + } + for (final entry in _asyncListenerDispatchNames) { + if (name.contains(entry)) return 'listener'; + } + for (final entry in _asyncMicrotaskNames) { + if (name.contains(entry)) return 'microtask'; + } + for (final entry in _asyncZoneNames) { + if (name.contains(entry)) return 'zone'; + } + return 'other'; +} + +/// Categorizes async frames into explicit groups and replaces the individual +/// dart:async frame summaries in [region] with categorized entries. +/// +/// When [cpuSamples] is provided, async samples are also attributed to the +/// first non-async caller in the stack, producing entries like +/// "async (await _runFrame)" that show which instruction triggered the async +/// cost. +ProfileRegionResult _collapseAsyncFramesInRegion( + ProfileRegionResult region, { + CpuSamples? cpuSamples, +}) { + 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, + startTimestampMicros: region.startTimestampMicros, + endTimestampMicros: region.endTimestampMicros, + durationMicros: region.durationMicros, + sampleCount: region.sampleCount, + samplePeriodMicros: region.samplePeriodMicros, + topSelfFrames: _collapseAsyncFrameList( + region.topSelfFrames, + region.sampleCount, + cpuSamples: cpuSamples, + ), + topTotalFrames: _collapseAsyncFrameList( + region.topTotalFrames, + region.sampleCount, + ), + memory: region.memory, + rawProfilePath: region.rawProfilePath, + summaryPath: region.summaryPath, + error: region.error, + extra: region.extra, + ); +} + +/// Replaces individual `dart:async` frames in [frames] with a single +/// "async overhead" entry and returns the filtered list. +/// +/// Detailed category breakdown (normal vs error completions) and call-site +/// attribution are added to warnings by [_buildAsyncBreakdownWarnings] instead +/// of cluttering the main table. +List _collapseAsyncFrameList( + List frames, + int totalSampleCount, { + CpuSamples? cpuSamples, +}) { + final divisor = totalSampleCount == 0 ? 1 : totalSampleCount; + var asyncSelfSamples = 0; + var asyncTotalSamples = 0; + final filtered = []; + + for (final frame in frames) { + final profileFrame = ProfileFrame( + name: frame.name, + kind: frame.kind, + location: frame.location, + ); + if (profileFrame.isAsyncOverhead) { + asyncSelfSamples += frame.selfSamples; + asyncTotalSamples += frame.totalSamples; + } else { + filtered.add(frame); + } + } + + if (asyncSelfSamples == 0 && asyncTotalSamples == 0) { + return frames; + } + + filtered.add( + ProfileFrameSummary( + name: 'async overhead', + kind: 'Dart', + location: 'dart:async', + selfSamples: asyncSelfSamples, + totalSamples: asyncTotalSamples, + selfPercent: asyncSelfSamples / divisor, + totalPercent: asyncTotalSamples / divisor, + ), + ); + + filtered.sort(_compareSelfDescending); + return filtered; +} + +/// Builds structured warnings describing the async overhead breakdown by +/// category (normal vs error completions, listener dispatch, etc.) and, when +/// raw [cpuSamples] are available, the top caller sites for error completions. +List _buildAsyncBreakdownWarnings( + List frames, + CpuSamples? cpuSamples, + int totalSampleCount, +) { + if (frames.isEmpty) return const []; + + // Aggregate stored frames by category. + final categories = {}; + for (final frame in frames) { + final profileFrame = ProfileFrame( + name: frame.name, + kind: frame.kind, + location: frame.location, + ); + if (profileFrame.isAsyncOverhead) { + final label = _asyncCategoryLabel(frame) ?? 'other'; + categories.putIfAbsent(label, () => _AsyncCategoryAccumulator()) + ..add(frame); + } + } + + if (categories.isEmpty) return const []; + + final divisor = totalSampleCount == 0 ? 1 : totalSampleCount; + final totalAsyncSamples = categories.values.fold( + 0, + (s, c) => s + c.selfSamples, + ); + final totalPct = (totalAsyncSamples / divisor) * 100; + final parts = [ + 'Async overhead breakdown: ${totalPct.toStringAsFixed(1)}% of samples ' + 'represented by the available top-frame subset', + ]; + + // Add category lines. + for (final entry in categories.entries) { + final pct = (entry.value.selfSamples / divisor) * 100; + final label = switch (entry.key) { + 'async (normal completions)' => 'normal completions', + 'async (error completions)' => 'error completions', + 'async (listener dispatch)' => 'listener dispatch', + 'async (microtask scheduling)' => 'microtask scheduling', + 'async (zone overhead)' => 'zone overhead', + _ => entry.key, + }; + parts.add(' $label: ${pct.toStringAsFixed(1)}%'); + } + + // When raw CPU samples are available, add caller attribution for error + // completions (the most actionable category). + if (cpuSamples?.samples != null) { + final attributed = _attributeAsyncByCaller(cpuSamples!); + if (attributed.isNotEmpty && totalSampleCount > 0) { + // Find entries with error completions. + final errorCallers = + attributed.where((e) => e.errorSelfSamples > 0).toList() + ..sort((a, b) => b.errorSelfSamples.compareTo(a.errorSelfSamples)); + + if (errorCallers.isNotEmpty) { + final callerDesc = errorCallers + .take(3) + .map((e) { + final callerPct = (e.errorSelfSamples / divisor) * 100; + return '${e.callerName} (${callerPct.toStringAsFixed(1)}%)'; + }) + .join(', '); + parts.add(' error completions from: $callerDesc'); + } + + // Also show normal completion callers. + final normalCallers = + attributed.where((e) => e.normalSelfSamples > 0).toList()..sort( + (a, b) => b.normalSelfSamples.compareTo(a.normalSelfSamples), + ); + + if (normalCallers.isNotEmpty) { + final callerDesc = normalCallers + .take(3) + .map((e) { + final callerPct = (e.normalSelfSamples / divisor) * 100; + return '${e.callerName} (${callerPct.toStringAsFixed(1)}%)'; + }) + .join(', '); + parts.add(' normal completions from: $callerDesc'); + } + } + } + + return [parts.join('\n')]; +} + +/// Accumulates sample counts for one async category. +class _AsyncCategoryAccumulator { + int selfSamples = 0; + int totalSamples = 0; + + void add(ProfileFrameSummary frame) { + selfSamples += frame.selfSamples; + totalSamples += frame.totalSamples; + } +} + +/// A single caller-attributed async cost entry with category breakdown. +final class _AsyncCallerEntry { + const _AsyncCallerEntry({ + required this.callerName, + required this.selfSamples, + required this.totalSamples, + this.normalSelfSamples = 0, + this.errorSelfSamples = 0, + this.listenerSelfSamples = 0, + this.otherSelfSamples = 0, + }); + + final String callerName; + final int selfSamples; + final int totalSamples; + + /// Self samples from normal completions (e.g. _completeWithValue). + /// These are the best candidates for sync conversion — the future completed + /// synchronously without yielding. + final int normalSelfSamples; + + /// Self samples from error completions (e.g. _completeErrorObject). + /// Harder to eliminate because errors inherently need stack traces. + final int errorSelfSamples; + + /// Self samples from listener dispatch (e.g. _propagateToListeners). + final int listenerSelfSamples; + + /// Self samples from other async categories (microtask, zone, etc.). + final int otherSelfSamples; +} + +/// Walks raw [cpuSamples] and attributes each async self-sample to the first +/// non-async caller in the stack trace. +/// +/// For each sample where the top (self) frame is a `dart:async` function, this +/// finds the first frame below it that is NOT `dart:async` and accumulates the +/// sample there. The result answers "which calling function triggered this +/// async cost?" +List<_AsyncCallerEntry> _attributeAsyncByCaller(CpuSamples cpuSamples) { + final functions = cpuSamples.functions ?? const []; + final samples = cpuSamples.samples ?? const []; + if (functions.isEmpty || samples.isEmpty) return const []; + + final callerCounts = {}; + final callerCategories = >{}; + var totalZeroCallerSamples = 0; + + for (final sample in samples) { + final stack = sample.stack ?? const []; + if (stack.isEmpty) continue; + + // Check if the top (self) frame is async. + final selfFrame = profileFrameFromFunction(functions, stack.first); + if (!selfFrame.isAsyncOverhead) continue; + + // Find the first non-async frame below the self frame. + String? callerName; + for (var i = 1; i < stack.length; i++) { + final frame = profileFrameFromFunction(functions, stack[i]); + if (!frame.isAsyncOverhead) { + callerName = frame.name; + break; + } + } + + // Determine async category for what-if analysis. + final category = _asyncCategoryFromName(selfFrame.name); + + if (callerName != null) { + callerCounts[callerName] = (callerCounts[callerName] ?? 0) + 1; + callerCategories.putIfAbsent(callerName, () => {}) + ..update(category, (v) => v + 1, ifAbsent: () => 1); + } else { + totalZeroCallerSamples++; + } + } + + if (callerCounts.isEmpty && totalZeroCallerSamples == 0) { + return const []; + } + + final sorted = callerCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + final result = <_AsyncCallerEntry>[ + for (final entry in sorted) + _AsyncCallerEntry( + callerName: entry.key, + selfSamples: entry.value, + totalSamples: entry.value, + normalSelfSamples: callerCategories[entry.key]?['normal'] ?? 0, + errorSelfSamples: callerCategories[entry.key]?['error'] ?? 0, + listenerSelfSamples: callerCategories[entry.key]?['listener'] ?? 0, + otherSelfSamples: + (entry.value) - + (callerCategories[entry.key]?['normal'] ?? 0) - + (callerCategories[entry.key]?['error'] ?? 0) - + (callerCategories[entry.key]?['listener'] ?? 0), + ), + ]; + + if (totalZeroCallerSamples > 0) { + result.add( + _AsyncCallerEntry( + callerName: 'no-caller', + selfSamples: totalZeroCallerSamples, + totalSamples: totalZeroCallerSamples, + ), + ); + } + + return result; +} + +/// Orders frames by descending self samples, then descending total samples. +int _compareSelfDescending(ProfileFrameSummary a, ProfileFrameSummary b) { + final c = b.selfSamples.compareTo(a.selfSamples); + if (c != 0) return c; + return b.totalSamples.compareTo(a.totalSamples); +} diff --git a/packages/devtools_profiler_cli/lib/src/rendering.dart b/packages/devtools_profiler_cli/lib/src/rendering.dart index b1f1ff5..d56d58d 100644 --- a/packages/devtools_profiler_cli/lib/src/rendering.dart +++ b/packages/devtools_profiler_cli/lib/src/rendering.dart @@ -1,3 +1,4 @@ +export 'rendering/csv.dart'; export 'rendering/helpers.dart'; export 'rendering/methods.dart'; export 'rendering/terminal.dart'; diff --git a/packages/devtools_profiler_cli/lib/src/rendering/csv.dart b/packages/devtools_profiler_cli/lib/src/rendering/csv.dart new file mode 100644 index 0000000..34a1799 --- /dev/null +++ b/packages/devtools_profiler_cli/lib/src/rendering/csv.dart @@ -0,0 +1,153 @@ +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; + +import 'terminal.dart'; + +/// Writes top self and total frame tables from [region] as CSV. +/// +/// Each section starts with a `#` comment header, followed by a header row, +/// then one data row per frame. Uses [writeLine] for each output line. +void writeCsvRegionFrames( + void Function(String line) writeLine, + ProfileRegionResult region, +) { + if (region.topSelfFrames.isNotEmpty) { + _writeCsvFrameTable(writeLine, 'Top Self Frames', region.topSelfFrames); + } + + if (region.topTotalFrames.isNotEmpty) { + _writeCsvFrameTable(writeLine, 'Top Total Frames', region.topTotalFrames); + } +} + +/// Writes top self and total frame delta tables from [comparison] as CSV. +void writeCsvComparisonFrames( + void Function(String line) writeLine, + ProfileRegionComparison comparison, +) { + if (comparison.topSelfFrames.isNotEmpty) { + _writeCsvDeltaTable( + writeLine, + 'Top Self Frame Deltas', + comparison.topSelfFrames, + ); + } + + if (comparison.topTotalFrames.isNotEmpty) { + _writeCsvDeltaTable( + writeLine, + 'Top Total Frame Deltas', + comparison.topTotalFrames, + ); + } +} + +void _writeCsvDeltaTable( + void Function(String line) writeLine, + String title, + List frames, +) { + writeLine('# $title'); + writeLine( + 'method,base_self,current_self,self_delta,' + 'base_total,current_total,total_delta', + ); + for (final frame in frames) { + writeLine( + '${_csvEscape(frame.name)},' + '${frame.selfSamples.baseline},' + '${frame.selfSamples.current},' + '${frame.selfSamples.delta},' + '${frame.totalSamples.baseline},' + '${frame.totalSamples.current},' + '${frame.totalSamples.delta}', + ); + } +} + +/// Writes the trends series table as CSV. +void writeCsvTrendSeries( + void Function(String line) writeLine, + ProfileTrendSummary summary, +) { + if (summary.points.isEmpty) { + return; + } + + writeLine('# Series'); + writeLine( + 'target,duration_micros,samples,heap_delta_bytes,top_self,top_method', + ); + for (final point in summary.points) { + writeLine( + '${_csvEscape(point.id)},' + '${point.durationMicros},' + '${point.sampleCount},' + '${point.deltaHeapBytes ?? ''},' + '${_csvEscape(point.topSelfFrame ?? '')},' + '${_csvEscape(point.topMethod ?? '')}', + ); + } +} + +void _writeCsvFrameTable( + void Function(String line) writeLine, + String title, + List frames, +) { + writeLine('# $title'); + writeLine('method,self_samples,self_percent,total_samples,total_percent'); + for (final frame in frames) { + writeLine( + '${_csvEscape(frame.name)},' + '${frame.selfSamples},' + '${_formatPercentCsv(frame.selfPercent)},' + '${frame.totalSamples},' + '${_formatPercentCsv(frame.totalPercent)}', + ); + } +} + +String _csvEscape(String value) { + if (value.contains(',') || + value.contains('"') || + value.contains('\n') || + value.contains('\r')) { + return '"${value.replaceAll('"', '""')}"'; + } + return value; +} + +/// Writes a multi-compare column table as CSV. +void writeCsvMultiCompare( + void Function(String line) writeLine, + List columns, { + int? frameLimit, +}) { + if (columns.isEmpty) return; + + // Header + final header = StringBuffer('method,kind,location'); + for (final column in columns) { + header.write(',${_csvEscape(column.label)}'); + } + writeLine(header.toString()); + + // Rows + for (final aligned in alignProfileFrames(columns, limit: frameLimit)) { + final row = StringBuffer( + '${_csvEscape(aligned.name)},${_csvEscape(aligned.kind)},' + '${_csvEscape(aligned.location ?? '')}', + ); + for (final frame in aligned.frames) { + row.write(','); + row.write(frame != null ? frame.selfPercent.toStringAsFixed(4) : ''); + } + writeLine(row.toString()); + } +} + +String _formatPercentCsv(double percent) { + // Output as a raw decimal so it's easy to consume programmatically. + // E.g. 0.42 instead of 42.0%. + return percent.toStringAsFixed(4); +} diff --git a/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart b/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart index 830d7e3..36d4fbf 100644 --- a/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart +++ b/packages/devtools_profiler_cli/lib/src/rendering/helpers.dart @@ -267,9 +267,8 @@ String shortFilePath(String filePath, String? workingDirectory) { } String packageNameFromFolder(String folder) { - final versionMatch = RegExp( - r'^(.+)-(\d+\.\d+\.\d+(?:[-+].*)?)$', - ).firstMatch(folder); + final versionMatch = RegExp(r'^(.+)-(\d+\.\d+\.\d+(?:[-+].*)?)$') + .firstMatch(folder); return versionMatch?.group(1) ?? folder; } diff --git a/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart b/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart index bd76d82..9345143 100644 --- a/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart +++ b/packages/devtools_profiler_cli/lib/src/rendering/terminal.dart @@ -13,6 +13,7 @@ void writeSessionSummary( Map regionTrees = const {}, Map regionBottomUpTrees = const {}, Map regionMethodTables = const {}, + List allocAttribution = const [], required ProfilePresentationOptions options, }) { console.title('Profiler Session'); @@ -94,6 +95,8 @@ void writeSessionSummary( console.components.bulletList(session.warnings); } + _writeAllocationAttribution(console, allocAttribution); + if (session.regions.isNotEmpty) { console.section('Region Details'); for (final region in session.regions) { @@ -120,6 +123,7 @@ void writeRegionSummary( ProfileMethodTable? methodTable, String? workingDirectory, List warnings = const [], + List allocAttribution = const [], required ProfilePresentationOptions options, }) { console.title('Region Summary'); @@ -132,12 +136,50 @@ void writeRegionSummary( workingDirectory: workingDirectory, options: options, ); + _writeAllocationAttribution(console, allocAttribution); if (warnings.isNotEmpty) { console.section('Warnings'); console.components.bulletList(warnings); } } +/// Renders profile-wide CPU correlation, not allocation-site attribution. +void _writeAllocationAttribution( + Console console, + List attributions, +) { + if (attributions.isEmpty) return; + + console.section('Allocation / CPU Correlation (not allocation sites)'); + console.table( + headers: const ['Class', 'Allocated', 'Profile-wide CPU functions'], + rows: [ + for (final attr in attributions) + [ + attr.className, + _formatBytesShort(attr.allocatedBytes), + attr.callSiteFractions + .take(3) + .map((e) { + final pct = (e.$2 * 100).toStringAsFixed(0); + return '${e.$1} ($pct%)'; + }) + .join(', '), + ], + ], + ); +} + +String _formatBytesShort(int bytes) { + if (bytes >= 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MiB'; + } + if (bytes >= 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KiB'; + } + return '$bytes B'; +} + void writeComparisonSummary( Console console, PreparedProfileComparison comparison, { @@ -256,6 +298,56 @@ void writeComparisonSummary( } } +/// A single column in a multi-compare table. +/// +/// Each column corresponds to one session or profile artifact in a +/// multi-session comparison. +typedef MultiCompareColumn = ProfileFrameColumn; + +/// Writes an aligned multi-column hotspot comparison table. +/// +/// Collects the union of all top-self frames across [columns] and renders +/// each method's self-percentage for every column. Missing entries are not +/// interpreted as zero cost, since the supplied lists may be limited. +void writeMultiCompareSummary( + Console console, + List columns, { + required ProfilePresentationOptions options, +}) { + if (columns.isEmpty) { + return; + } + + final headers = [ + 'Method', + 'Kind', + 'Location', + for (final column in columns) column.label, + ]; + final rows = [ + for (final row in alignProfileFrames(columns, limit: options.frameLimit)) + [ + row.name, + row.kind, + row.location ?? '(unknown)', + for (final frame in row.frames) + frame != null ? formatPercent(frame.selfPercent) : 'not listed', + ], + ]; + + if (rows.isEmpty) { + console.warn('No profile frames available for comparison.'); + return; + } + + console.title('Multi-Session Comparison (top self frames)'); + console.comment( + 'Self share of samples; not elapsed time. ' + '"not listed" does not mean eliminated.', + ); + console.table(headers: headers, rows: rows); +} + void writeHotspotExplanation( Console console, PreparedProfileExplanation explanation, { diff --git a/packages/devtools_profiler_cli/pubspec.yaml b/packages/devtools_profiler_cli/pubspec.yaml index d2e091d..a875178 100644 --- a/packages/devtools_profiler_cli/pubspec.yaml +++ b/packages/devtools_profiler_cli/pubspec.yaml @@ -2,10 +2,10 @@ name: devtools_profiler_cli description: CLI and local stdio MCP server for automated Dart and Flutter CPU profiling. -version: 0.4.0 +version: 0.6.0 environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' resolution: workspace @@ -15,13 +15,13 @@ executables: devtools-profiler: devtools_profiler dependencies: - artisanal: ^0.3.0 + artisanal: '>=0.6.0 <1.0.0' dart_mcp: ^0.5.0 - devtools_profiler_core: ^0.4.0 - path: ^1.9.0 + devtools_profiler_core: ^0.6.0 + path: ^1.9.1 stream_channel: ^2.1.4 - vm_service: ^15.0.2 + vm_service: ^15.3.0 dev_dependencies: - devtools_region_profiler: ^0.1.0 - test: ^1.25.8 + devtools_region_profiler: ^0.3.0 + test: ^1.32.0 diff --git a/packages/devtools_profiler_cli/test/cli_test.dart b/packages/devtools_profiler_cli/test/cli_test.dart index 1907102..5122fc1 100644 --- a/packages/devtools_profiler_cli/test/cli_test.dart +++ b/packages/devtools_profiler_cli/test/cli_test.dart @@ -10,6 +10,208 @@ import 'package:test/test.dart'; import 'package:vm_service/vm_service.dart'; void main() { + group('profiles JSON', () { + late Directory directory; + + setUp(() async { + directory = await Directory.systemTemp.createTemp('profiles_json.'); + }); + tearDown(() => directory.delete(recursive: true)); + + test('empty discovery is a JSON result, not a warning', () async { + final result = await _runCliCommand([ + 'profiles', + '--json', + '--cwd', + directory.path, + ]); + expect(result.exitCode, 0, reason: result.stderr); + expect(result.stderr, isEmpty); + final json = jsonDecode(result.stdout) as Map; + expect(json['kind'], 'sessions'); + expect(json['sessions'], isEmpty); + expect(json['totalCount'], 0); + expect(json['returnedCount'], 0); + expect(json['truncated'], isFalse); + }); + + for (final limit in [1, 0]) { + test( + 'lists newest first with limit $limit and complete metadata', + () async { + for (final day in [1, 2]) { + final sessionDirectory = await Directory( + '${directory.path}/session-$day', + ).create(); + final session = ProfileRunResult.fromJson({ + 'sessionId': 'session-$day', + 'command': ['dart', 'run', 'app.dart'], + 'workingDirectory': directory.path, + }); + final file = await File('${sessionDirectory.path}/session.json') + .writeAsString(jsonEncode(session.toJson())); + await file.setLastModified(DateTime.utc(2026, 1, day)); + } + final result = await _runCliCommand([ + 'profiles', + '--json', + '--extended', + '--limit', + '$limit', + '--cwd', + directory.path, + ]); + expect(result.exitCode, 0, reason: result.stderr); + expect(result.stderr, isEmpty); + final json = jsonDecode(result.stdout) as Map; + final sessions = json['sessions'] as List; + expect(json['totalCount'], 2); + expect(json['returnedCount'], limit == 0 ? 2 : 1); + expect(json['truncated'], limit != 0); + expect(sessions, hasLength(limit == 0 ? 2 : 1)); + final newest = sessions.first as Map; + expect((newest['session'] as Map)['sessionId'], 'session-2'); + expect(newest['path'], '${directory.path}/session-2'); + expect( + newest['modifiedTime'], + DateTime.utc(2026, 1, 2).toIso8601String(), + ); + expect((newest['session'] as Map)['command'], [ + 'dart', + 'run', + 'app.dart', + ]); + }, + ); + } + }); + + for (final value in ['abc', '0', '-1']) { + test('trends rejects invalid --last before discovery: $value', () async { + final result = await _runCliCommand(['trends', '--last', value]); + expect(result.exitCode, 64); + expect(result.stderr, contains('--last')); + expect(result.stdout, isEmpty); + }); + } + + for (final (option, value) in [ + ('window', '0'), + ('window', '-1'), + ('window', 'invalid'), + ('speed', '0'), + ('speed', 'NaN'), + ('speed', 'Infinity'), + ('speed', '1e-300'), + ('top', '0'), + ]) { + test('replay rejects --$option $value before artifact lookup', () async { + final result = await _runCliCommand(['replay', '--$option', value]); + expect(result.exitCode, 64); + expect(result.stdout, isEmpty); + expect(result.stderr, contains('--')); + }); + } + + for (final arguments in [ + ['compare', '--json', '--csv'], + ['inspect', '--csv'], + ['replay', '--json'], + ]) { + test('rejects unsupported output format: $arguments', () async { + final result = await _runCliCommand(arguments); + expect(result.exitCode, 64); + expect(result.stdout, isEmpty); + expect(result.stderr, contains(arguments.last)); + }); + } + + test('browse refuses redirected hosts without terminal output', () async { + final result = await _runCliCommand(const ['browse']); + expect(result.exitCode, 64); + expect(result.stdout, isEmpty); + expect(result.stderr, contains('requires an interactive terminal')); + expect(result.stderr, isNot(contains('\x1b'))); + }); + + test( + 'multi-compare JSON aligns before limiting and honors filters', + () async { + final result = await _runCliCommand(const [ + 'compare', + '--json', + '--hide-sdk', + '--frame-limit', + '1', + '/tmp/sessions/base', + '/tmp/sessions/mid', + '/tmp/sessions/current', + ], runner: _FakeRunnerWithFrames()); + expect(result.exitCode, 0, reason: result.stderr); + final json = jsonDecode(result.stdout) as Map; + expect(json['kind'], 'multi-compare'); + expect(json['rows'], hasLength(1)); + final row = (json['rows'] as List).single as Map; + expect(row['location'], isA()); + expect(row['location'], 'package:lualike/src/lua_bytecode/vm.dart'); + expect(row['frames'], hasLength(3)); + expect( + json['missingFrameMeaning'], + contains('not evidence of elimination'), + ); + }, + ); + + test( + 'shared views apply tree limits after deriving overall and region data', + () async { + final combined = await _runJsonCommand([ + 'run', + '--json', + '--call-tree', + '--bottom-up', + '--method-table', + '--tree-depth', + '1', + '--tree-children', + '1', + '--method-limit', + '0', + '--', + 'dart', + 'run', + 'bin/main.dart', + ]); + final methodsOnly = await _runJsonCommand([ + 'run', + '--json', + '--method-table', + '--method-limit', + '0', + '--', + 'dart', + 'run', + 'bin/main.dart', + ]); + List> profiles(Map json) => [ + json['overallProfile'] as Map, + ...(json['regions'] as List).cast>(), + ]; + + final combinedProfiles = profiles(combined); + final methodProfiles = profiles(methodsOnly); + expect(combinedProfiles.length, greaterThan(1)); + for (var i = 0; i < combinedProfiles.length; i++) { + expect( + combinedProfiles[i]['methodTable'], + methodProfiles[i]['methodTable'], + ); + expect(combinedProfiles[i]['callTree'], isNotNull); + expect(combinedProfiles[i]['bottomUpTree'], isNotNull); + } + }, + ); + test('run help shows the target command separator and examples', () async { final stdoutCapture = _OutputCapture(); final stderrCapture = _OutputCapture(); @@ -412,6 +614,8 @@ void main() { expect(exitCode, 0); expect(runner.lastRunRequest?.command, ['dart', 'run', 'bin/main.dart']); + expect(runner.lastRunRequest?.forwardOutput, isTrue); + expect(runner.lastRunRequest?.forwardOutputToStderr, isTrue); final json = jsonDecode(stdoutCapture.text) as Map; expect(json['sessionId'], 'session-1'); expect(json['overallProfile'], isA>()); @@ -501,6 +705,7 @@ void main() { ProfileProcessIoMode.inheritStdio, ); expect(runner.lastRunRequest?.handleInterruptSignals, isTrue); + expect(runner.lastRunRequest?.forwardOutputToStderr, isFalse); expect(runner.lastRunRequest?.command, ['dart', 'run', 'bin/tui.dart']); expect( stdoutCapture.text, @@ -1670,39 +1875,37 @@ void main() { }); 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(); - }); + 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(); + 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); - }, - ); + 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', @@ -1733,6 +1936,130 @@ void main() { }, ); }); + + // --------------------------------------------------------------------------- + // New feature tests + // --------------------------------------------------------------------------- + + test('summarize --csv outputs compact frame tables', () async { + final result = await _runCliCommand(const [ + 'summarize', + '--csv', + '/tmp/helper_profile.json', + ]); + expect(result.exitCode, 0); + expect(result.stdout, contains('Top Self Frames')); + expect(result.stdout, contains('method,self_samples')); + expect(result.stderr, isEmpty); + }); + + test('compare --csv outputs compact delta tables', () async { + final result = await _runCliCommand(const [ + 'compare', + '--csv', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + ]); + expect(result.exitCode, 0); + expect(result.stdout, contains('Top Self Frame Deltas')); + expect(result.stdout, contains('method,base_self,current_self')); + expect(result.stderr, isEmpty); + }); + + test('trends --csv outputs compact series table', () async { + final result = await _runCliCommand(const [ + 'trends', + '--csv', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + '/tmp/artifacts/session-3', + ], runner: _FakeProfileRunnerWithSharedTrendRegion()); + expect(result.exitCode, 0); + expect(result.stdout, contains('# Series')); + expect(result.stdout, contains('target,duration_micros')); + expect(result.stderr, isEmpty); + }); + + test( + 'summarize --collapse-async shows single async overhead entry', + () async { + final json = await _runJsonCommand(const [ + 'summarize', + '--json', + '--collapse-async', + '/tmp/regions/cpu-burn/summary.json', + ], runner: _FakeRunnerWithFrames()); + final topSelf = json['topSelfFrames'] as List; + // Should have a single "async overhead" entry. + expect( + topSelf.any( + (f) => (f as Map)['name'] == 'async overhead', + ), + isTrue, + ); + // And a breakdown warning with category details. + final warnings = + json['preparationWarnings'] as List? ?? const []; + expect(warnings.isNotEmpty, isTrue); + final warningText = warnings.first as String; + expect(warningText, contains('Async overhead breakdown')); + expect(warningText, contains('normal completions')); + }, + ); + + test('regress detects regressions and exits non-zero', () async { + final result = await _runCliCommand(const [ + 'regress', + '--json', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + ]); + expect(result.exitCode, 1); + final json = jsonDecode(result.stdout) as Map; + expect(json['regressionExitCode'], 1); + expect(result.stderr, isEmpty); + }); + + test('regress --warn-only exits 0 with regressions', () async { + final result = await _runCliCommand(const [ + 'regress', + '--json', + '--warn-only', + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + ]); + expect(result.exitCode, 0); + final json = jsonDecode(result.stdout) as Map; + expect(json.containsKey('regressionExitCode'), isFalse); + expect(result.stderr, isEmpty); + }); + + test('compare with 3+ args prints multi-compare table', () async { + final result = await _runCliCommand(const [ + 'compare', + '/tmp/sessions/base', + '/tmp/sessions/mid', + '/tmp/sessions/current', + ], runner: _FakeRunnerWithFrames()); + expect(result.exitCode, 0); + expect(result.stdout, contains('Multi-Session Comparison')); + expect(result.stderr, isEmpty); + }); + + test('compare with 3+ args --csv outputs multi-compare csv', () async { + final result = await _runCliCommand(const [ + 'compare', + '--csv', + '/tmp/sessions/base', + '/tmp/sessions/mid', + '/tmp/sessions/current', + ], runner: _FakeRunnerWithFrames()); + expect(result.exitCode, 0); + expect(result.stdout, contains('method')); + expect(result.stdout, contains('base')); + expect(result.stdout, contains('current')); + expect(result.stderr, isEmpty); + }); } Future> _runJsonCommand( @@ -2447,6 +2774,252 @@ class _FakeRunnerWithZeroSamples extends _FakeProfileRunner { } } +/// A fake runner that returns session/region JSON with non-empty top frames +/// for testing --collapse-async and multi-compare features. +class _FakeRunnerWithFrames extends _FakeProfileRunner { + static final _frameHot = ProfileFrameSummary( + name: 'hotLeaf', + kind: 'Dart', + location: 'dart:async/zone.dart', + selfSamples: 42, + totalSamples: 100, + selfPercent: 0.42, + totalPercent: 1.0, + ); + + static final _frameOther = ProfileFrameSummary( + name: 'run', + kind: 'Dart', + location: 'package:fixture/run.dart', + selfSamples: 10, + totalSamples: 50, + selfPercent: 0.10, + totalPercent: 0.50, + ); + + static final _frameAsync = ProfileFrameSummary( + name: '_completeWithValue', + kind: 'Dart', + location: 'org-dartlang-sdk:///sdk/lib/async/future_impl.dart', + selfSamples: 30, + totalSamples: 80, + selfPercent: 0.30, + totalPercent: 0.80, + ); + + static final _frames = [_frameHot, _frameOther, _frameAsync]; + + static final _frameRegion = ProfileRegionResult( + regionId: 'cpu-burn', + name: 'cpu-burn', + attributes: const {'phase': 'fixture'}, + isolateId: 'isolates/123', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 100, + endTimestampMicros: 2_200, + durationMicros: 2_100, + sampleCount: 11, + samplePeriodMicros: 50, + topSelfFrames: _frames, + topTotalFrames: _frames, + summaryPath: '/tmp/regions/cpu-burn/summary.json', + rawProfilePath: '/tmp/regions/cpu-burn/cpu_profile.json', + ); + + static final _sessionBase = ProfileRunResult( + sessionId: 'base', + command: ['dart', 'run', 'bin/main.dart'], + workingDirectory: '/workspace', + exitCode: 0, + artifactDirectory: '/tmp/sessions/base', + overallProfile: ProfileRegionResult( + regionId: 'overall', + name: 'whole-session', + attributes: const {'scope': 'session'}, + isolateId: 'isolates/123', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 0, + endTimestampMicros: 2_000, + durationMicros: 2_000, + sampleCount: 10, + samplePeriodMicros: 50, + topSelfFrames: _frames, + topTotalFrames: _frames, + summaryPath: '/tmp/sessions/base/overall/summary.json', + rawProfilePath: '/tmp/sessions/base/overall/cpu_profile.json', + ), + regions: const [], + warnings: const [], + ); + + static final _sessionMid = ProfileRunResult( + sessionId: 'mid', + command: ['dart', 'run', 'bin/main.dart'], + workingDirectory: '/workspace', + exitCode: 0, + artifactDirectory: '/tmp/sessions/mid', + overallProfile: ProfileRegionResult( + regionId: 'overall', + name: 'whole-session', + attributes: const {'scope': 'session'}, + isolateId: 'isolates/123', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 0, + endTimestampMicros: 2_500, + durationMicros: 2_500, + sampleCount: 14, + samplePeriodMicros: 50, + topSelfFrames: _frames, + topTotalFrames: _frames, + summaryPath: '/tmp/sessions/mid/overall/summary.json', + rawProfilePath: '/tmp/sessions/mid/overall/cpu_profile.json', + ), + regions: const [], + warnings: const [], + ); + + static final _sessionCurrent = ProfileRunResult( + sessionId: 'current', + command: ['dart', 'run', 'bin/main.dart'], + workingDirectory: '/workspace', + exitCode: 0, + artifactDirectory: '/tmp/sessions/current', + overallProfile: ProfileRegionResult( + regionId: 'overall', + name: 'whole-session', + attributes: const {'scope': 'session'}, + isolateId: 'isolates/123', + captureKinds: const [ProfileCaptureKind.cpu], + startTimestampMicros: 0, + endTimestampMicros: 2_800, + durationMicros: 2_800, + sampleCount: 16, + samplePeriodMicros: 50, + topSelfFrames: _frames, + topTotalFrames: _frames, + summaryPath: '/tmp/sessions/current/overall/summary.json', + rawProfilePath: '/tmp/sessions/current/overall/cpu_profile.json', + ), + regions: const [], + warnings: const [], + ); + + /// CPU samples where async frames appear as self frames with known callers. + /// The stack format is [self, caller1, caller2] where index 0 is top. + static final _cpuSamplesWithAsyncSelf = CpuSamples( + sampleCount: 3, + samplePeriod: 50, + timeOriginMicros: 100, + timeExtentMicros: 150, + functions: [ + // 0 — async self frame + ProfileFunction( + kind: 'Dart', + function: FuncRef( + id: 'functions/complete_error', + name: '_Future._completeErrorObject', + owner: ClassRef(id: 'classes/future', name: '_Future'), + ), + resolvedUrl: 'org-dartlang-sdk:///sdk/lib/async/future_impl.dart', + ), + // 1 — caller 1 + ProfileFunction( + kind: 'Dart', + function: FuncRef( + id: 'functions/caller_one', + name: 'hotFunction', + owner: ClassRef(id: 'classes/vm', name: 'LuaBytecodeVm'), + ), + resolvedUrl: 'package:lualike/src/lua_bytecode/vm.dart', + ), + // 2 — caller 2 (deeper in stack) + ProfileFunction( + kind: 'Dart', + function: FuncRef( + id: 'functions/caller_two', + name: 'run', + owner: ClassRef(id: 'classes/vm', name: 'LuaBytecodeVm'), + ), + resolvedUrl: 'package:lualike/src/lua_bytecode/vm.dart', + ), + // 3 — async normal completion + ProfileFunction( + kind: 'Dart', + function: FuncRef( + id: 'functions/complete_with', + name: '_completeWithValue', + owner: ClassRef(id: 'classes/future', name: '_Future'), + ), + resolvedUrl: 'org-dartlang-sdk:///sdk/lib/async/future_impl.dart', + ), + // 4 — caller for normal completion + ProfileFunction( + kind: 'Dart', + function: FuncRef( + id: 'functions/caller_three', + name: 'tableLookup', + owner: ClassRef(id: 'classes/vm', name: 'LuaBytecodeVm'), + ), + resolvedUrl: 'package:lualike/src/lua_bytecode/vm.dart', + ), + ], + samples: [ + // Two samples: _Future._completeErrorObject called from hotFunction + CpuSample(timestamp: 100, stack: const [0, 1, 2]), + CpuSample(timestamp: 101, stack: const [0, 1, 2]), + // One sample: _completeWithValue called from tableLookup + CpuSample(timestamp: 102, stack: const [3, 4, 2]), + ], + ); + + /// Same samples for current/mid variants (differences mostly in IDs). + static final _cpuSamplesWithAsyncSelfMid = CpuSamples( + sampleCount: 2, + samplePeriod: 50, + timeOriginMicros: 100, + timeExtentMicros: 100, + functions: _cpuSamplesWithAsyncSelf.functions, + samples: [ + CpuSample(timestamp: 100, stack: const [0, 1, 2]), + CpuSample(timestamp: 101, stack: const [0, 1, 2]), + ], + ); + + @override + Future> summarizeArtifact(String path) async { + if (path == '/tmp/regions/cpu-burn/summary.json') { + return _frameRegion.toJson(); + } + if (path == '/tmp/sessions/base') { + return _sessionBase.toJson(); + } + if (path == '/tmp/sessions/mid') { + return _sessionMid.toJson(); + } + if (path == '/tmp/sessions/current') { + return _sessionCurrent.toJson(); + } + return super.summarizeArtifact(path); + } + + @override + Future readCpuSamples(String targetPath) async { + if (targetPath.contains('/tmp/regions/')) { + return _cpuSamplesWithAsyncSelf; + } + if (targetPath.contains('/tmp/sessions/base')) { + return _cpuSamplesWithAsyncSelf; + } + if (targetPath.contains('/tmp/sessions/mid')) { + return _cpuSamplesWithAsyncSelfMid; + } + if (targetPath.contains('/tmp/sessions/current')) { + return _cpuSamplesWithAsyncSelf; + } + return super.readCpuSamples(targetPath); + } +} + class _OutputCapture { _OutputCapture() { sink = IOSink(_controller.sink); diff --git a/packages/devtools_profiler_cli/test/comparison_defaults_test.dart b/packages/devtools_profiler_cli/test/comparison_defaults_test.dart new file mode 100644 index 0000000..518a3ce --- /dev/null +++ b/packages/devtools_profiler_cli/test/comparison_defaults_test.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:artisanal/args.dart'; +import 'package:devtools_profiler_cli/src/cli/commands/analysis_commands.dart'; +import 'package:devtools_profiler_cli/src/cli/commands/profile_session_resolution.dart'; +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'default comparison uses previous baseline and newest current', + () async { + final output = []; + final backend = _Runner(); + final runner = CommandRunner('profiler', 'test', out: output.add) + ..addCommand(_CompareCommand(backend)); + expect(await runner.run(['compare', '--json']), 0); + expect(backend.paths, ['/previous', '/latest']); + final json = jsonDecode(output.join('\n')) as Map; + expect((json['baseline'] as Map)['path'], '/previous'); + expect((json['current'] as Map)['path'], '/latest'); + }, + ); +} + +class _CompareCommand extends CompareCommand { + _CompareCommand(super.profileRunner); + + @override + Future> discoverSessions([Directory? directory]) async => + [ + for (final id in ['latest', 'previous']) + StoredSession( + directory: Directory('/$id'), + modifiedTime: DateTime.utc(2026, 1, id == 'latest' ? 2 : 1), + result: ProfileRunResult.fromJson({'sessionId': id}), + ), + ]; +} + +class _Runner extends ProfileRunner { + final paths = []; + + @override + Future> summarizeArtifact(String path) async { + paths.add(path); + return ProfileRegionResult.fromJson({ + 'regionId': 'overall', + 'name': 'whole-session', + }).toJson(); + } +} diff --git a/packages/devtools_profiler_cli/test/mcp_server_test.dart b/packages/devtools_profiler_cli/test/mcp_server_test.dart index 3485aff..1d40ab3 100644 --- a/packages/devtools_profiler_cli/test/mcp_server_test.dart +++ b/packages/devtools_profiler_cli/test/mcp_server_test.dart @@ -11,6 +11,79 @@ import 'package:test/test.dart'; import 'package:vm_service/vm_service.dart'; void main() { + for (final warnOnly in [false, true]) { + test( + 'profile_regress preserves MCP transport (warnOnly=$warnOnly)', + () async { + final environment = _McpTestEnvironment(_FakeProfileRunner()); + addTearDown(environment.shutdown); + await _initializeServer(environment); + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_regress', + arguments: { + 'baselinePath': '/tmp/artifacts/session-1', + 'currentPath': '/tmp/artifacts/session-2', + 'warnOnly': warnOnly, + }, + ), + ); + expect(result.isError, isNot(true), reason: result.content.toString()); + final json = result.structuredContent!; + expect(json['kind'], 'regressionCheck'); + expect(json['hasRegressions'], isTrue); + if (warnOnly) { + expect(json, isNot(contains('regressionExitCode'))); + } else { + expect(json['regressionExitCode'], 1); + } + expect( + (await environment.serverConnection.listTools()).tools, + isNotEmpty, + ); + }, + ); + } + + test('multi-path compare exposes aligned rows through MCP', () async { + final environment = _McpTestEnvironment(_FakeProfileRunner()); + addTearDown(environment.shutdown); + await _initializeServer(environment); + final result = await environment.serverConnection.callTool( + CallToolRequest( + name: 'profile_compare', + arguments: { + 'paths': [ + '/tmp/artifacts/session-1', + '/tmp/artifacts/session-2', + '/tmp/artifacts/session-3', + ], + 'frameLimit': 1, + }, + ), + ); + expect(result.isError, isNot(true), reason: result.content.toString()); + expect(result.structuredContent!['kind'], 'multi-compare'); + expect(result.structuredContent!['rows'], hasLength(1)); + final columns = result.structuredContent!['columns'] as List; + expect(columns.map((column) => (column as Map)['label']), [ + 'session-1', + 'session-2', + 'session-3', + ]); + final row = (result.structuredContent!['rows'] as List).single as Map; + final frames = row['frames'] as List; + expect(frames, hasLength(3)); + expect( + frames.map((frame) => (frame as Map)['selfSamples']).toSet().length, + greaterThan(1), + ); + expect( + result.structuredContent!['missingFrameMeaning'], + contains('not evidence of elimination'), + ); + }); + test('lists tools and forwards summarize calls to the runner', () async { final environment = _McpTestEnvironment(_FakeProfileRunner()); addTearDown(environment.shutdown); @@ -36,6 +109,7 @@ void main() { 'profile_search_methods', 'profile_compare_method', 'profile_compare', + 'profile_regress', 'profile_analyze_trends', 'profile_find_regressions', 'profile_inspect_classes', diff --git a/packages/devtools_profiler_cli/test/output_routing_test.dart b/packages/devtools_profiler_cli/test/output_routing_test.dart new file mode 100644 index 0000000..ae7e268 --- /dev/null +++ b/packages/devtools_profiler_cli/test/output_routing_test.dart @@ -0,0 +1,75 @@ +@Timeout(Duration(minutes: 5)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + late Directory directory; + late File executable; + late File target; + + setUpAll(() async { + directory = await Directory.systemTemp.createTemp('profiler_output.'); + addTearDown(() => directory.delete(recursive: true)); + executable = File('${directory.path}/profiler.exe'); + target = File('${directory.path}/target.dart'); + await target.writeAsString(''' +import 'dart:io'; + +void main() { + stdout.writeln('target stdout marker'); + stderr.writeln('target stderr marker'); +} +'''); + + // Resolve the entrypoint from either the workspace or package directory. + final entrypoint = File('bin/devtools_profiler.dart').existsSync() + ? 'bin/devtools_profiler.dart' + : 'packages/devtools_profiler_cli/bin/devtools_profiler.dart'; + final result = await Process.run(Platform.resolvedExecutable, [ + 'compile', + 'exe', + entrypoint, + '-o', + executable.path, + ]); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + }); + + for (final forwardOutput in [true, false]) { + test( + 'native JSON capture keeps stdout clean with forwarding=$forwardOutput', + () async { + final result = await Process.run(executable.path, [ + 'run', + '--json', + if (!forwardOutput) '--no-forward-output', + '--artifact-dir', + '${directory.path}/session-$forwardOutput', + '--', + 'dart', + target.path, + ]); + expect( + result.exitCode, + 0, + reason: '${result.stdout}\n${result.stderr}', + ); + final json = + jsonDecode(result.stdout as String) as Map; + expect(json['exitCode'], 0); + expect(json['overallProfile'], isA>()); + for (final marker in ['target stdout marker', 'target stderr marker']) { + expect(result.stdout, isNot(contains(marker))); + expect( + result.stderr, + forwardOutput ? contains(marker) : isNot(contains(marker)), + ); + } + }, + ); + } +} diff --git a/packages/devtools_profiler_cli/test/session_browser_test.dart b/packages/devtools_profiler_cli/test/session_browser_test.dart new file mode 100644 index 0000000..789ad5b --- /dev/null +++ b/packages/devtools_profiler_cli/test/session_browser_test.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:artisanal/runtime.dart' as tui; +import 'package:artisanal/style.dart'; +import 'package:devtools_profiler_cli/src/browser/session_browser.dart'; +import 'package:devtools_profiler_cli/src/cli/commands/profile_session_resolution.dart'; +import 'package:devtools_profiler_cli/src/rendering/csv.dart'; +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('handles Artisanal signal interrupts even while searching', () { + final browser = SessionBrowser([_session('base')]); + browser.update(_key('/')); + final (_, command) = browser.update(const tui.InterruptMsg()); + expect(command, isNotNull); + expect(browser.exportedCommand, isNull); + }); + + test( + 'selects explicit region IDs and preserves baseline through filtering', + () { + final browser = SessionBrowser([_session('base'), _session('current')]); + browser.update(_key('b')); + browser.update(_key('/')); + browser.update(_key('current')); + browser.update(tui.KeyMsg(tui.Key(tui.KeyType.enter))); + browser.update(_key('c')); + expect(browser.baseline!.session.result.sessionId, 'base'); + expect(browser.current!.session.result.sessionId, 'current'); + expect( + browser.comparisonCommand, + contains('--baseline-profile-id overall --current-profile-id overall'), + ); + browser.update(_key('e')); + expect(browser.exportedCommand, browser.comparisonCommand); + }, + ); + + test( + 'search input cannot trigger export/quit or move beyond empty results', + () { + final browser = SessionBrowser([_session('base')]); + browser.update(_key('/')); + browser.update(_key('qeb')); + expect(browser.query, 'qeb'); + expect(browser.visible, isEmpty); + expect(browser.exportedCommand, isNull); + browser.update(tui.KeyMsg(tui.Key(tui.KeyType.enter))); + browser.update(_key('j')); + browser.update(_key('b')); + expect(browser.cursor, 0); + expect(browser.baseline, isNull); + }, + ); + + test('narrow view is bounded and artifact controls cannot escape', () { + final browser = SessionBrowser([_session('base\x1b[2J')]); + browser.update(const tui.WindowSizeMsg(32, 12)); + final view = browser.view(); + expect(view, isNot(contains('\x1b[2J'))); + final lines = view.split('\n'); + expect(lines.length, lessThanOrEqualTo(12)); + for (final line in lines) { + expect(Style.visibleLength(line), lessThanOrEqualTo(32)); + } + }); + + test('details preserve unknown observations and percentage-point units', () { + final browser = SessionBrowser([_session('base'), _session('current')]); + browser.update(const tui.WindowSizeMsg(160, 50)); + browser.update(_key('b')); + browser.update(_key('j')); + browser.update(_key('c')); + browser.update(_key('d')); + expect(browser.view(), contains('NOT eliminated')); + expect(browser.view(), contains('+0.00 pp')); + expect(browser.view(), contains('unavailable')); + }); + + test('CSV retains locations and leaves missing observations blank', () { + final output = []; + writeCsvMultiCompare(output.add, [ + ProfileFrameColumn(label: 'base', frames: [_frame('package:a/a.dart')]), + ProfileFrameColumn(label: 'next', frames: [_frame('package:b/b.dart')]), + ]); + expect(output, hasLength(3)); + expect(output.first, 'method,kind,location,base,next'); + expect(output[1], 'work,Dart,package:a/a.dart,0.1000,'); + expect(output[2], 'work,Dart,package:b/b.dart,,0.1000'); + }); +} + +tui.KeyMsg _key(String text) => + tui.KeyMsg(tui.Key(tui.KeyType.runes, runes: text.runes.toList())); + +StoredSession _session(String id) => StoredSession( + directory: Directory('/tmp/$id with spaces'), + modifiedTime: DateTime.utc(2026), + result: ProfileRunResult.fromJson({ + 'sessionId': id, + 'command': ['dart', 'run', 'main.dart'], + 'workingDirectory': '/project', + 'overallProfile': { + 'regionId': 'overall', + 'name': 'overall', + 'sampleCount': 100, + 'topSelfFrames': [_frame('package:a/a.dart').toJson()], + }, + }), +); + +ProfileFrameSummary _frame(String location) => ProfileFrameSummary( + name: 'work', + kind: 'Dart', + location: location, + selfSamples: 10, + totalSamples: 10, + selfPercent: 0.1, + totalPercent: 0.1, +); diff --git a/packages/devtools_profiler_cli/test/session_resolution_test.dart b/packages/devtools_profiler_cli/test/session_resolution_test.dart new file mode 100644 index 0000000..df8f0ec --- /dev/null +++ b/packages/devtools_profiler_cli/test/session_resolution_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:devtools_profiler_cli/src/cli/commands/profile_session_resolution.dart'; +import 'package:devtools_profiler_cli/src/cli/commands/profiles_command.dart'; +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + late Directory root; + late _Command command; + + setUp(() async { + root = await Directory.systemTemp.createTemp('session_resolution.'); + command = _Command(); + }); + tearDown(() => root.delete(recursive: true)); + + test('normalizes direct directories and prefers nested sessions', () async { + final relative = path.relative(root.path); + expect(command.resolveSessionsDirectory(cwd: relative).path, root.path); + final nested = await Directory( + path.join(root.path, '.dart_tool', 'devtools_profiler', 'sessions'), + ).create(recursive: true); + expect(command.resolveSessionsDirectory(cwd: relative).path, nested.path); + }); + + test('rejects missing directories', () { + expect( + () => command.resolveSessionsDirectory( + cwd: path.join(root.path, 'missing'), + ), + throwsArgumentError, + ); + }); + + test('existing paths never trigger session parsing', () async { + final file = await File(path.join(root.path, 'latest')).create(); + expect(await command.resolveSessionOrPath(file.path), file.path); + expect(await command.resolveSessionOrPath(root.path), root.path); + }); +} + +class _Command extends ProfilesCommand { + _Command() : super(ProfileRunner()); + + @override + Future> discoverSessions([Directory? directory]) { + throw StateError('Explicit paths must not discover sessions.'); + } +} diff --git a/packages/devtools_profiler_core/CHANGELOG.md b/packages/devtools_profiler_core/CHANGELOG.md index 5d320e3..09fa64d 100644 --- a/packages/devtools_profiler_core/CHANGELOG.md +++ b/packages/devtools_profiler_core/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 0.6.0 + +- Allowed newer pre-1.0 Artisanal and Artisanal Widgets releases in test and + fixture dependencies, retaining minimum versions 0.6.0 and 0.4.0. +- Added shared cross-run frame alignment with explicit missing observations, + exact name/kind/location identity, and post-alignment row limits. +- Restored named native/stub/tag functions when reading saved CPU artifacts. +- Preserved per-sample isolate provenance, OS thread ids, tags and truncation + metadata while merging isolates; invalid local stack indices cannot alias + functions in another isolate. +- Compacted capture function tables and bounded retention of earlier snapshots + for observed isolates that exit or become unavailable. +- Captured CPU before allocation data and tolerated busier Flutter metadata + requests. Final whole-session capture now attempts fresh data. +- Waited for in-flight region stops before disposing the VM connection, and + finalized duration-limited runs before terminating their targets. +- Added a driven Flutter desktop stress fixture and repeatable attach validation. +- Added profile-local frame resolution caching for CPU summaries and trees. +- Added `buildBottomUpTreeFromCallTree` and `buildMethodTableFromCallTree` to + derive additional views without resolving and rebuilding sample paths. +- Added `ProfileRunRequest.forwardOutputToStderr` for structured-output hosts. +- Required Dart 3.13 or later and upgraded to devtools_shared 14 and + vm_service 15.3. +- Reduced method-table traversal allocations and recursive-total bookkeeping + by tracking only method ids on the active call path. +- Migrated the terminal widget fixture to artisanal 0.6 / widgets 0.4. + +## 0.5.2 + +- Fixed installed AOT CLI bundles launching themselves instead of the Dart VM + when starting the tooling daemon or a bare `dart` target. +- Added `DEVTOOLS_PROFILER_DART_EXECUTABLE` for environments that need to + select a specific Dart executable. + +## 0.5.1 + +- Fixed interrupted profiling finalization so complete CPU and memory profiles + are written to `session.json` before the target process is stopped. +- Added bounded VM-service timeouts for large CPU and allocation profile + responses. +- Fixed terminal interrupt races so SIGINT/SIGTERM-style exits are classified + as profiler interruptions after finalization completes. + +## 0.5.0 + +- Added `AllocationAttribution` class and `attributeAllocationsToCallers()` + that cross-references CPU samples with memory class deltas to attribute + allocations to the functions on the stack during heap growth. +- Added `lineForFunction()` helper that extracts line numbers from VM + profile function data, enabling line-level source annotation. +- Added `isAsyncOverhead` getter to `ProfileFrame` for identifying + `dart:async` frames, supporting the `--collapse-async` feature. +- Added `extra` field to `ActiveProfileRegion` and `ProfileRegionResult` + for tool-specific metadata that persists in session artifacts. +- Memory class attribution now exposes `AllocationAttribution` data through + `PreparedRegionPresentation.allocAttribution`. + ## 0.4.0 - Added live Flutter frame timing analysis with jank attribution and timeline diff --git a/packages/devtools_profiler_core/README.md b/packages/devtools_profiler_core/README.md index aa36a4f..d0ca144 100644 --- a/packages/devtools_profiler_core/README.md +++ b/packages/devtools_profiler_core/README.md @@ -209,6 +209,29 @@ The CLI uses these APIs to present both readable terminal output and JSON output. Tool authors should prefer these higher-level summaries over parsing raw VM service payloads directly. +When requesting multiple CPU views, build the complete top-down tree once: + +```dart +final samples = await runner.readCpuSamples('/path/to/cpu_profile.json'); +final completeTree = buildCallTree(cpuSamples: samples); +final bottomUp = buildBottomUpTreeFromCallTree(completeTree); +final methods = buildMethodTableFromCallTree(completeTree); +final displayedTree = completeTree.limited(maxDepth: 8, maxChildren: 12); +``` + +Derive the bottom-up tree and method table **before** limiting the top-down +tree; otherwise hidden paths cannot contribute to totals or caller edges. +These helpers require a top-down input and do not mutate it. The CLI and MCP +presentation layer shares this work for both whole-session and region output. + +Frame resolution caches are local to a single profile and do not retain +per-sample stacks. For custom stack processing, reuse `ProfileFrameResolver` +with one unchanged function table, and create a new resolver for each profile. + +See [CPU view benchmarks](benchmark/README.md) for the repeatable performance +check. To keep structured stdout clean in a custom capture host, set both +`forwardOutput: true` and `forwardOutputToStderr: true` on `ProfileRunRequest`. + ## Relationship To DevTools Packages This package reuses `packages/devtools_shared` for shared VM and memory models. @@ -217,6 +240,13 @@ It intentionally does not depend on `packages/devtools_app`, ## Limits +- For a real Flutter desktop workload, repeated attach checks, and observed + isolate/thread behavior, see the + [stress validation recipe](test/fixtures/profiled_flutter_app/README.md). +- CPU sample `tid` values identify OS threads, not Dart isolates. New artifacts + retain `profilerIsolateId` separately. Core artifact readers preserve it. +- Exited-worker CPU capture is best-effort and bounded. An isolate must be seen + by a successful poll; cached samples cannot recover work after its last poll. - Attach mode captures a fixed whole-session VM-service window from an existing process, but explicit region markers normally require launch mode. - Dart and Flutter VM-service commands only. diff --git a/packages/devtools_profiler_core/benchmark/README.md b/packages/devtools_profiler_core/benchmark/README.md new file mode 100644 index 0000000..a1fda6a --- /dev/null +++ b/packages/devtools_profiler_core/benchmark/README.md @@ -0,0 +1,42 @@ +# CPU view benchmark + +From the workspace root: + +```bash +dart run packages/devtools_profiler_core/benchmark/cpu_views_benchmark.dart +``` + +The benchmark compares: + +- **independent**: build each of the three views from raw samples; +- **shared**: build the complete top-down tree once, then derive bottom-up and + method-table views from it, as CLI/MCP presentation does. + +Each case contains 20,000 samples, 128 functions, and either 8 or 32 frames per +sample. Each mode gets three warm-up iterations and seven measured iterations. +Output is JSON Lines containing median microseconds and a checksum of the +complete serialized views. Checksums should match between modes at each depth. +Serialization is outside the timed section. No timing threshold is asserted in +unit tests. + +## Local migration measurement + +On Linux x64 with Dart 3.13.1 in JIT mode: + +| Stack depth | Before this optimization | Updated independent builds | Updated shared build | +| --- | ---: | ---: | ---: | +| 8 | 441 ms | 296 ms | 80 ms | +| 32 | 1,885 ms | 882 ms | 403 ms | + +The "before" column used a saved copy of the CPU sources from immediately +before the frame-cache/shared-tree changes, with the same benchmark and +dependencies. All three paths produced matching checksums for each input. +The updated independent path includes frame caching but still builds the +top-down paths three times. Sharing eliminates those repeated builds. + +These are synthetic, local measurements, not end-to-end profiling speedups. +They exclude VM collection, artifact IO, JSON encoding, and terminal rendering. +The cases emphasize repeated function metadata and shared paths; profiles with +many distinct stacks, different filters, or more functions may behave +differently. Use a production artifact and measure peak memory as well as +elapsed time before drawing conclusions about a particular workload. diff --git a/packages/devtools_profiler_core/benchmark/cpu_views_benchmark.dart b/packages/devtools_profiler_core/benchmark/cpu_views_benchmark.dart new file mode 100644 index 0000000..fb7ffe2 --- /dev/null +++ b/packages/devtools_profiler_core/benchmark/cpu_views_benchmark.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:vm_service/vm_service.dart'; + +/// Compares independent view builds with one shared complete call tree. +/// +/// Run from the workspace root: +/// `dart run packages/devtools_profiler_core/benchmark/cpu_views_benchmark.dart` +/// Use `--independent` to measure only the independent-build path. +void main(List arguments) { + for (final depth in [8, 32]) { + final samples = CpuSamples( + samplePeriod: 1000, + functions: [ + for (var i = 0; i < 128; i++) + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: 'functions/$i', name: 'method$i'), + resolvedUrl: 'package:benchmark/method$i.dart', + ), + ], + samples: [ + for (var i = 0; i < 20_000; i++) + CpuSample( + timestamp: i * 1000, + stack: [for (var j = 0; j < depth; j++) (i + j) % 128], + ), + ], + ); + for (final shared in [ + false, + if (!arguments.contains('--independent')) true, + ]) { + final times = []; + var checksum = 0; + for (var iteration = 0; iteration < 10; iteration++) { + final stopwatch = Stopwatch()..start(); + final tree = buildCallTree(cpuSamples: samples); + final bottomUp = shared + ? buildBottomUpTreeFromCallTree(tree) + : buildBottomUpTree(cpuSamples: samples); + final table = shared + ? buildMethodTableFromCallTree(tree) + : buildMethodTable(cpuSamples: samples); + stopwatch.stop(); + // Consume the complete output outside the timed section, not only roots. + checksum = _checksum( + jsonEncode([tree.toJson(), bottomUp.toJson(), table.toJson()]), + ); + if (iteration >= 3) times.add(stopwatch.elapsedMicroseconds); + } + times.sort(); + print( + jsonEncode({ + 'mode': shared ? 'shared' : 'independent', + 'samples': samples.samples!.length, + 'depth': depth, + 'medianMicros': times[times.length ~/ 2], + 'checksum': checksum, + }), + ); + } + } +} + +int _checksum(String text) { + var hash = 0x811c9dc5; + for (final value in text.codeUnits) { + hash = ((hash ^ value) * 0x01000193) & 0xffffffff; + } + return hash; +} diff --git a/packages/devtools_profiler_core/lib/devtools_profiler_core.dart b/packages/devtools_profiler_core/lib/devtools_profiler_core.dart index 7eabcc1..b7239dc 100644 --- a/packages/devtools_profiler_core/lib/devtools_profiler_core.dart +++ b/packages/devtools_profiler_core/lib/devtools_profiler_core.dart @@ -34,6 +34,7 @@ library; export 'package:devtools_profiler_protocol/devtools_profiler_protocol.dart'; export 'src/analysis/profile_comparison.dart'; +export 'src/analysis/profile_frame_alignment.dart'; export 'src/analysis/profile_hotspots.dart'; export 'src/analysis/profile_method_comparison.dart'; export 'src/analysis/profile_method_inspector.dart'; diff --git a/packages/devtools_profiler_core/lib/src/analysis/profile_frame_alignment.dart b/packages/devtools_profiler_core/lib/src/analysis/profile_frame_alignment.dart new file mode 100644 index 0000000..976cdf1 --- /dev/null +++ b/packages/devtools_profiler_core/lib/src/analysis/profile_frame_alignment.dart @@ -0,0 +1,95 @@ +import '../capture/models.dart'; + +/// One source in a cross-run top-frame comparison. +final class ProfileFrameColumn { + /// Creates a labeled source. + const ProfileFrameColumn({required this.label, required this.frames}); + + /// The source label. + final String label; + + /// Available frames, which may be a limited stored summary. + final List frames; +} + +/// One aligned function across multiple sources. +final class ProfileFrameRow { + /// Creates a row with nullable observations. + const ProfileFrameRow({ + required this.name, + required this.kind, + required this.location, + required this.frames, + }); + + /// The function display name. + final String name; + + /// The VM function kind. + final String kind; + + /// The exact source location used for matching. + final String? location; + + /// Observations in source order; null means absent from the supplied list. + /// + /// Absence is not evidence of elimination or zero execution cost. + final List frames; + + /// Converts this row to structured output, retaining missing observations. + Map toJson() => { + 'name': name, + 'kind': kind, + 'location': location, + 'frames': [for (final frame in frames) frame?.toJson()], + }; +} + +/// Aligns top-frame lists by name, kind, and exact source location. +/// +/// Does not guess equivalence between different checkout roots or package +/// versions. Package URIs already match portably; unresolved locations remain +/// unresolved. Sorts by first-source self percentage with deterministic ties. +/// Throws [ArgumentError] if an input list contains duplicate identities. +/// A positive [limit] truncates rows after alignment; null or non-positive +/// values return all rows. +List alignProfileFrames( + List columns, { + int? limit, +}) { + final identities = <(String, String, String?)>{}; + final lookups = [ + for (final _ in columns) <(String, String, String?), ProfileFrameSummary>{}, + ]; + for (var i = 0; i < columns.length; i++) { + for (final frame in columns[i].frames) { + final key = (frame.name, frame.kind, frame.location); + if (lookups[i].containsKey(key)) { + throw ArgumentError('Duplicate frame identity in ${columns[i].label}'); + } + identities.add(key); + lookups[i][key] = frame; + } + } + final keys = identities.toList() + ..sort((a, b) { + final byPercent = (lookups.first[b]?.selfPercent ?? -1).compareTo( + lookups.first[a]?.selfPercent ?? -1, + ); + if (byPercent != 0) return byPercent; + final byName = a.$1.compareTo(b.$1); + if (byName != 0) return byName; + final byKind = a.$2.compareTo(b.$2); + if (byKind != 0) return byKind; + return (a.$3 ?? '').compareTo(b.$3 ?? ''); + }); + return [ + for (final key in limit != null && limit > 0 ? keys.take(limit) : keys) + ProfileFrameRow( + name: key.$1, + kind: key.$2, + location: key.$3, + frames: List.unmodifiable([for (final lookup in lookups) lookup[key]]), + ), + ]; +} diff --git a/packages/devtools_profiler_core/lib/src/analysis/profile_hotspots.dart b/packages/devtools_profiler_core/lib/src/analysis/profile_hotspots.dart index fe49cbb..ea98c66 100644 --- a/packages/devtools_profiler_core/lib/src/analysis/profile_hotspots.dart +++ b/packages/devtools_profiler_core/lib/src/analysis/profile_hotspots.dart @@ -424,8 +424,7 @@ ProfileHotspotSummary explainProfileHotspots( kind: 'distribution', subject: region.name, title: 'Work is spread across multiple frames', - summary: - 'No single self frame exceeds 20% of samples, so the bottleneck is likely distributed across a wider call path.', + summary: 'No single self frame exceeds 20% of samples, so the bottleneck is likely distributed across a wider call path.', severity: ProfileHotspotSeverity.low, ), ); diff --git a/packages/devtools_profiler_core/lib/src/analysis/profile_method_inspector.dart b/packages/devtools_profiler_core/lib/src/analysis/profile_method_inspector.dart index 84ddf4f..0e2a198 100644 --- a/packages/devtools_profiler_core/lib/src/analysis/profile_method_inspector.dart +++ b/packages/devtools_profiler_core/lib/src/analysis/profile_method_inspector.dart @@ -329,7 +329,8 @@ ProfileMethodInspection inspectProfileMethod({ queryKind: queryKind, status: ProfileMethodInspectionStatus.unavailable, message: - 'Method inspection requires a raw CPU profile artifact, but no method table was available.', + 'Method inspection requires a raw CPU profile artifact, ' + 'but no method table was available.', candidates: const [], topDownPaths: const [], bottomUpPaths: const [], diff --git a/packages/devtools_profiler_core/lib/src/analysis/profile_method_search.dart b/packages/devtools_profiler_core/lib/src/analysis/profile_method_search.dart index c91ce03..63e3df8 100644 --- a/packages/devtools_profiler_core/lib/src/analysis/profile_method_search.dart +++ b/packages/devtools_profiler_core/lib/src/analysis/profile_method_search.dart @@ -131,7 +131,8 @@ ProfileMethodSearchResult searchProfileMethods({ sortBy: sortBy, status: ProfileMethodSearchStatus.unavailable, message: - 'Method search requires a raw CPU profile artifact, but no method table was available.', + 'Method search requires a raw CPU profile artifact, ' + 'but no method table was available.', totalMatches: 0, truncated: false, methods: const [], diff --git a/packages/devtools_profiler_core/lib/src/capture/artifacts.dart b/packages/devtools_profiler_core/lib/src/capture/artifacts.dart index 2a55593..fbb9332 100644 --- a/packages/devtools_profiler_core/lib/src/capture/artifacts.dart +++ b/packages/devtools_profiler_core/lib/src/capture/artifacts.dart @@ -7,6 +7,7 @@ import 'package:vm_service/vm_service.dart'; import '../cpu/call_tree.dart'; import '../cpu/cpu_profile_summary.dart'; +import '../cpu/cpu_samples_merge.dart'; import '../memory/memory_models.dart'; import '../memory/memory_profile_summary.dart'; import 'models.dart'; @@ -127,7 +128,7 @@ class ProfileArtifacts { final json = jsonDecode(await File(targetPath).readAsString()) as Map; final map = json.cast(); if (map['type'] == 'CpuSamples') { - final cpuSamples = CpuSamples.parse( + final cpuSamples = parseProfileCpuSamples( map.map((key, value) => MapEntry(key, value as dynamic)), ); if (cpuSamples == null) { @@ -135,12 +136,17 @@ class ProfileArtifacts { 'Failed to parse CPU samples artifact at $targetPath.', ); } + final isolateIds = { + for (final sample in cpuSamples.samples ?? const []) + if (sample case ProfileCpuSample(isolateId: final id?)) id, + }.toList()..sort(); + if (isolateIds.isEmpty) isolateIds.add('unknown'); return summarizeCpuSamples( regionId: path.basenameWithoutExtension(targetPath), name: path.basenameWithoutExtension(targetPath), attributes: const {}, - isolateId: 'unknown', - isolateIds: const ['unknown'], + isolateId: isolateIds.first, + isolateIds: isolateIds, captureKinds: const [ProfileCaptureKind.cpu], startTimestampMicros: cpuSamples.timeOriginMicros ?? 0, endTimestampMicros: @@ -239,9 +245,9 @@ class ProfileArtifacts { throw ArgumentError.value(targetPath, 'targetPath', 'Artifact not found'); } - final json = - jsonDecode(await File(targetPath).readAsString()) - as Map; + final json = jsonDecode( + await File(targetPath).readAsString(), + ) as Map; final map = json.cast(); if (map['type'] == 'ProfileMemoryArtifact') { @@ -293,7 +299,7 @@ class ProfileArtifacts { required String targetPath, }) async { if (map['type'] == 'CpuSamples') { - final cpuSamples = CpuSamples.parse( + final cpuSamples = parseProfileCpuSamples( map.map((key, value) => MapEntry(key, value as dynamic)), ); if (cpuSamples == null) { @@ -433,6 +439,7 @@ class ProfileArtifactStore { CpuSamples? cpuSamples, ProfileMemoryResult? memory, Map? rawMemoryPayload, + Map extra = const {}, }) async { return _writeProfileSuccess( profileDirectory: _ensureRegionDirectory(regionId), @@ -449,6 +456,7 @@ class ProfileArtifactStore { cpuSamples: cpuSamples, memory: memory, rawMemoryPayload: rawMemoryPayload, + extra: extra, ); } @@ -465,6 +473,7 @@ class ProfileArtifactStore { required int startTimestampMicros, required int endTimestampMicros, required String error, + Map extra = const {}, }) async { return _writeProfileFailure( profileDirectory: _ensureRegionDirectory(regionId), @@ -479,6 +488,7 @@ class ProfileArtifactStore { startTimestampMicros: startTimestampMicros, endTimestampMicros: endTimestampMicros, error: error, + extra: extra, ); } @@ -521,6 +531,7 @@ class ProfileArtifactStore { CpuSamples? cpuSamples, ProfileMemoryResult? memory, Map? rawMemoryPayload, + Map extra = const {}, }) async { if (cpuSamples == null && memory == null) { throw ArgumentError( @@ -534,9 +545,8 @@ class ProfileArtifactStore { final rawProfileFile = File( path.join(directory.path, _rawProfileFileName), ); - final rawJson = const JsonEncoder.withIndent( - ' ', - ).convert(cpuSamples.toJson()); + final rawJson = const JsonEncoder.withIndent(' ') + .convert(cpuSamples.toJson()); await rawProfileFile.writeAsString(rawJson); rawProfilePath = rawProfileFile.path; } @@ -547,9 +557,8 @@ class ProfileArtifactStore { path.join(directory.path, _rawMemoryProfileFileName), ); await rawMemoryFile.writeAsString( - const JsonEncoder.withIndent( - ' ', - ).convert(rawMemoryPayload ?? memory.toJson()), + const JsonEncoder.withIndent(' ') + .convert(rawMemoryPayload ?? memory.toJson()), ); storedMemory = memory.copyWith(rawProfilePath: rawMemoryFile.path); } @@ -573,6 +582,7 @@ class ProfileArtifactStore { topSelfFrames: const [], topTotalFrames: const [], summaryPath: summaryFile.path, + extra: extra, ) : summarizeCpuSamples( regionId: regionId, @@ -589,6 +599,7 @@ class ProfileArtifactStore { cpuSamples: cpuSamples, summaryPath: summaryFile.path, rawProfilePath: rawProfilePath, + extra: extra, ); await summaryFile.writeAsString( const JsonEncoder.withIndent(' ').convert(summary.toJson()), @@ -609,6 +620,7 @@ class ProfileArtifactStore { required int startTimestampMicros, required int endTimestampMicros, required String error, + Map extra = const {}, }) async { final directory = await profileDirectory; final summaryFile = File(path.join(directory.path, _summaryFileName)); @@ -630,6 +642,7 @@ class ProfileArtifactStore { topTotalFrames: const [], summaryPath: summaryFile.path, error: error, + extra: extra, ); await summaryFile.writeAsString( const JsonEncoder.withIndent(' ').convert(summary.toJson()), diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_region_result.dart b/packages/devtools_profiler_core/lib/src/capture/profile_region_result.dart index de69cd1..8675103 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_region_result.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_region_result.dart @@ -27,6 +27,7 @@ class ProfileRegionResult { required this.summaryPath, this.memory, this.parentRegionId, + this.extra = const {}, List? isolateIds, List captureKinds = defaultProfileCaptureKinds, this.isolateScope = ProfileIsolateScope.current, @@ -84,6 +85,10 @@ class ProfileRegionResult { summaryPath: json['summaryPath'] as String? ?? '', rawProfilePath: json['rawProfilePath'] as String?, error: json['error'] as String?, + extra: switch (json['extra']) { + final Map m => m, + _ => {}, + }, ); } @@ -116,6 +121,13 @@ class ProfileRegionResult { /// The parent region id when this region was started inside another region. final String? parentRegionId; + /// Extra tool-specific metadata attached at region start. + /// + /// Tools like `lualike` can attach arbitrary key-value data here (e.g. + /// `{'luaFile': 'calls.lua', 'luaFunction': 'runBenchmark'}`). This data + /// is preserved in the session artifact and displayed in region summaries. + final Map extra; + /// The region start timestamp from `Timeline.now`. final int startTimestampMicros; @@ -176,5 +188,6 @@ class ProfileRegionResult { 'summaryPath': summaryPath, 'rawProfilePath': rawProfilePath, 'error': error, + if (extra.isNotEmpty) 'extra': Map.from(extra), }; } diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart b/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart index 21296e1..25ea563 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_run_request.dart @@ -29,6 +29,7 @@ class ProfileRunRequest { this.workingDirectory, this.artifactDirectory, this.forwardOutput = false, + this.forwardOutputToStderr = false, this.environment = const {}, this.processIoMode = ProfileProcessIoMode.pipe, this.handleInterruptSignals = false, @@ -53,6 +54,13 @@ class ProfileRunRequest { /// Whether stdout and stderr from the profiled process should be echoed. final bool forwardOutput; + /// Whether all forwarded output should go to stderr. + /// + /// Keeps stdout available for structured results such as JSON. Has no effect + /// when [forwardOutput] is false or [processIoMode] is + /// [ProfileProcessIoMode.inheritStdio]. + final bool forwardOutputToStderr; + /// Extra environment variables to inject into the launched process. final Map environment; 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 d9ab463..c67d4f3 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -9,6 +9,7 @@ import '../memory/memory_models.dart'; import 'artifacts.dart'; import 'models.dart'; import 'runner/dtd_process_session.dart'; +import 'runner/interrupt_finalization.dart'; import 'runner/process_launch.dart'; import 'runner/profile_runner_shared.dart'; import 'runner/profile_session_controller.dart'; @@ -143,13 +144,24 @@ class ProfileRunner { runDurationTimer = Timer(runDuration, () { terminatedByProfiler = true; sessionController.addWarning( - 'Profile run duration of ${runDuration.inMilliseconds}ms elapsed; terminating the target process.', + 'Profile run duration of ${runDuration.inMilliseconds}ms elapsed; ' + 'finalizing before terminating the target process.', ); - if (process != null && !process.kill()) { - sessionController.addWarning( - 'Failed to terminate the target process after the profile run duration elapsed.', - ); - } + unawaited(() async { + try { + await sessionController.handleProcessExit(); + } catch (error) { + sessionController.addWarning( + 'Failed to finalize profile data at the duration limit: $error', + ); + } finally { + if (process != null && !process.kill()) { + sessionController.addWarning( + 'Failed to terminate the target process after the profile run duration elapsed.', + ); + } + } + }()); }); } @@ -174,13 +186,17 @@ class ProfileRunner { 'profile data before stopping the target process.', ); try { - await sessionController.handleProcessExit().timeout( - _interruptFinalizationWait, - ); - } on TimeoutException { - sessionController.addWarning( - 'Timed out finalizing all profile data after interruption; ' - 'returning the diagnostics captured so far.', + final finalization = sessionController.handleProcessExit(); + await awaitInterruptedFinalization( + finalization: finalization, + warningTimeout: _interruptFinalizationWait, + onTimeout: () { + sessionController.addWarning( + 'Profile finalization exceeded ' + '${_interruptFinalizationWait.inSeconds}s; ' + 'continuing until the complete profile is written.', + ); + }, ); } catch (error) { sessionController.addWarning( @@ -211,6 +227,15 @@ class ProfileRunner { await sessionController.handleProcessExit(); } + // In pipe mode, a terminal SIGINT can reach the launched process at the + // same time as the profiler. The process may therefore win the + // completion race before the profiler's signal watcher does, even + // though this is still a user interruption rather than an application + // failure. + if (!terminatedByProfiler && _isInterruptExitCode(exitCode)) { + terminatedByProfiler = true; + } + final result = sessionController.buildResult( artifactDirectory: artifactDirectory.path, command: command, @@ -672,6 +697,8 @@ int _profileSignalExitCode(ProcessSignal signal) { return 1; } +bool _isInterruptExitCode(int exitCode) => exitCode == -2 || exitCode == 130; + /// Watches process-level interrupt signals while one run is active. final class _ProfileRunSignalWatcher { _ProfileRunSignalWatcher({ diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/capture_state.dart b/packages/devtools_profiler_core/lib/src/capture/runner/capture_state.dart index 171bb42..117046a 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/capture_state.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/capture_state.dart @@ -17,6 +17,7 @@ final class ActiveProfileRegion { required this.parentRegionId, required this.regionId, required this.startTimestampMicros, + this.extra = const {}, }); final Map attributes; @@ -27,6 +28,12 @@ final class ActiveProfileRegion { final String? parentRegionId; final String regionId; final int startTimestampMicros; + + /// Extra tool-specific metadata attached at region start. + /// + /// Tools like `lualike` can attach arbitrary key-value data here (e.g. + /// `{'luaFile': 'calls.lua', 'luaFunction': 'runBenchmark'}`). + final Map extra; } /// Aggregated CPU and memory snapshot data for one capture window. diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/cpu_snapshot_cache.dart b/packages/devtools_profiler_core/lib/src/capture/runner/cpu_snapshot_cache.dart new file mode 100644 index 0000000..1c36b50 --- /dev/null +++ b/packages/devtools_profiler_core/lib/src/capture/runner/cpu_snapshot_cache.dart @@ -0,0 +1,94 @@ +import 'package:vm_service/vm_service.dart'; + +/// Retains the latest full payload per observed isolate, not repeated polls. +/// +/// Exited isolates cannot be queried through getCpuSamples. Retaining a bounded +/// set preserves workers seen in earlier polls. This is best-effort: workers +/// that start and exit entirely between polls are still unobservable. +final class CpuSnapshotCache { + CpuSnapshotCache({this.capacity = 64, this.maxStackEntries = 2_000_000}) { + if (capacity < 1) throw ArgumentError.value(capacity, 'capacity'); + if (maxStackEntries < 1) { + throw ArgumentError.value(maxStackEntries, 'maxStackEntries'); + } + } + + final int capacity; + final int maxStackEntries; + final Map _samples = {}; + final Map _weights = {}; + int _stackEntries = 0; + + /// Replaces a cumulative payload and returns ids evicted by either bound. + List record(String isolateId, CpuSamples samples) { + _samples.remove(isolateId); + _stackEntries -= _weights.remove(isolateId) ?? 0; + _samples[isolateId] = samples; + final weight = (samples.samples ?? const []).fold( + 0, + (total, sample) => total + (sample.stack?.length ?? 0), + ); + _weights[isolateId] = weight; + _stackEntries += weight; + final evicted = []; + while (_samples.length > capacity || _stackEntries > maxStackEntries) { + final id = _samples.keys.first; + _samples.remove(id); + _stackEntries -= _weights.remove(id)!; + evicted.add(id); + } + return evicted; + } + + /// Adds cached, nonempty windows for isolates missing from [current]. + /// + /// Current responses always win. Window clipping prevents samples from an + /// earlier region from leaking into a later one. + Map withMissingIsolates( + Map current, { + required int startTimestampMicros, + required int timeExtentMicros, + }) { + final result = {...current}; + for (final entry in _samples.entries) { + if (result.containsKey(entry.key)) continue; + final clipped = clipCpuSnapshot( + entry.value, + startTimestampMicros: startTimestampMicros, + timeExtentMicros: timeExtentMicros, + ); + if (clipped.samples!.isNotEmpty) result[entry.key] = clipped; + } + return result; + } +} + +/// Clips current and retained snapshots with the same inclusive time bounds. +CpuSamples clipCpuSnapshot( + CpuSamples source, { + required int startTimestampMicros, + required int timeExtentMicros, +}) { + final end = startTimestampMicros + timeExtentMicros; + final samples = [ + for (final sample in source.samples ?? const []) + if (sample.timestamp case final timestamp? + when timestamp >= startTimestampMicros && timestamp <= end) + sample, + ]; + final times = samples.map((sample) => sample.timestamp!); + final first = samples.isEmpty + ? startTimestampMicros + : times.reduce((a, b) => a < b ? a : b); + final last = samples.isEmpty ? first : times.reduce((a, b) => a > b ? a : b); + return CpuSamples( + sampleCount: samples.length, + samplePeriod: source.samplePeriod, + maxStackDepth: source.maxStackDepth, + pid: source.pid, + timeOriginMicros: first, + timeExtentMicros: last - first, + functions: source.functions, + samples: samples, + ); +} diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/dart_executable.dart b/packages/devtools_profiler_core/lib/src/capture/runner/dart_executable.dart new file mode 100644 index 0000000..bc88784 --- /dev/null +++ b/packages/devtools_profiler_core/lib/src/capture/runner/dart_executable.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +/// Resolves the Dart VM executable used to launch helper processes. +/// +/// In a JIT invocation [Platform.resolvedExecutable] is the Dart VM. A CLI +/// installed with `dart install`, however, is an AOT executable, so resolving +/// the current executable would recursively launch the profiler itself. Use +/// the Dart executable from PATH for that case, with an explicit override for +/// environments where it is not discoverable there. +String resolveDartExecutable({ + String? resolvedExecutable, + Map? environment, +}) { + final env = environment ?? Platform.environment; + final override = env['DEVTOOLS_PROFILER_DART_EXECUTABLE']; + if (override != null && override.isNotEmpty) { + return override; + } + + final executable = resolvedExecutable ?? Platform.resolvedExecutable; + final name = executable.replaceAll('\\', '/').split('/').last.toLowerCase(); + if (name == 'dart' || name == 'dart.exe') { + return executable; + } + return 'dart'; +} diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/dtd_process_session.dart b/packages/devtools_profiler_core/lib/src/capture/runner/dtd_process_session.dart index 1f4825e..6cbc4ea 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/dtd_process_session.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/dtd_process_session.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:dtd/dtd.dart'; +import 'dart_executable.dart'; + /// A launched Dart Tooling Daemon process plus its active connection. final class DtdProcessSession { DtdProcessSession({ @@ -20,7 +22,7 @@ final class DtdProcessSession { /// Starts a local tooling-daemon process and connects to it. static Future start() async { - final process = await Process.start(Platform.resolvedExecutable, const [ + final process = await Process.start(resolveDartExecutable(), const [ 'tooling-daemon', '--machine', ]); diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/interrupt_finalization.dart b/packages/devtools_profiler_core/lib/src/capture/runner/interrupt_finalization.dart new file mode 100644 index 0000000..e230a3a --- /dev/null +++ b/packages/devtools_profiler_core/lib/src/capture/runner/interrupt_finalization.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +/// Waits for interrupted-session finalization without abandoning its result. +/// +/// [warningTimeout] is intentionally only a diagnostic threshold. A profile +/// capture may need longer than that threshold to serialize a large CPU or +/// memory response, so the returned future still waits for [finalization] +/// before the caller tears down the VM-service connection. +Future awaitInterruptedFinalization({ + required Future finalization, + required Duration warningTimeout, + required void Function() onTimeout, +}) async { + try { + await finalization.timeout(warningTimeout); + } on TimeoutException { + onTimeout(); + await finalization; + } +} diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart b/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart index 58fb728..1f45fa5 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/process_launch.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:path/path.dart' as path; import '../models.dart'; +import 'dart_executable.dart'; import 'profile_runner_shared.dart'; /// A launched target process plus the subscriptions needed to monitor it. @@ -88,7 +89,7 @@ Future launchProfiledProcess({ } if (request.forwardOutput) { - sink.writeln(line); + (request.forwardOutputToStderr ? stderr : sink).writeln(line); } } @@ -272,7 +273,7 @@ CommandLaunchPlan instrumentedCommandLaunchPlan( /// disables exit pausing because terminal apps own their shutdown behavior and /// because the profiler uses [CommandLaunchPlan.expectedVmServiceUri] instead /// of scraping output for service auth codes. The bare `dart` command is -/// replaced with [Platform.resolvedExecutable] only when +/// replaced with the resolved Dart VM executable only when /// [normalizedExecutableName] confirms it is exactly the SDK token; explicit /// paths and suffixed executables are preserved. CommandLaunchPlan _dartLaunchPlan( @@ -289,7 +290,7 @@ CommandLaunchPlan _dartLaunchPlan( executable: normalizedExecutableName(command.first) == 'dart' && command.first == 'dart' - ? Platform.resolvedExecutable + ? resolveDartExecutable() : command.first, arguments: [ usesInheritedStdio ? '--observe=$vmServicePort' : '--observe=0', 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 cfab33a..df2fda9 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:devtools_profiler_protocol/devtools_profiler_protocol.dart'; import 'package:dtd/dtd.dart'; import 'package:json_rpc_2/json_rpc_2.dart'; @@ -19,6 +21,8 @@ final class ProfileSessionRegionRpcHandler { final ProfileSessionContext context; final ProfileSessionSnapshotCapture snapshotCapture; final ProfileSessionVmHookup vmHookup; + final _pendingRegionStops = >{}; + Future? _processExitOperation; /// Handles the DTD session-info request. Future> handleGetSessionInfo(Parameters params) async { @@ -48,6 +52,11 @@ final class ProfileSessionRegionRpcHandler { /// Handles the DTD region-start request. Future> handleStartRegion(Parameters params) async { + final rawExtra = params['extra'].valueOr(const {}); + if (rawExtra is! Map || rawExtra.keys.any((key) => key is! String)) { + throw RpcException.invalidParams('Region extra must be a JSON object.'); + } + final extra = Map.from(rawExtra); await vmHookup.waitForVmService(); vmHookup.validateSession(params['sessionId'].asString); if (context.processExited) { @@ -94,6 +103,11 @@ final class ProfileSessionRegionRpcHandler { } } + if (context.processExited) { + throw RpcException.invalidParams( + 'The profiling session ended while the region was starting.', + ); + } final region = ActiveProfileRegion( attributes: stringMap(params['attributes'].valueOr(const {})), isolateId: isolateId, @@ -103,6 +117,7 @@ final class ProfileSessionRegionRpcHandler { parentRegionId: parentRegionId, regionId: regionId, startTimestampMicros: startTimestampMicros, + extra: extra, ); context.activeRegions[region.regionId] = region; @@ -124,6 +139,9 @@ final class ProfileSessionRegionRpcHandler { Future> handleStopRegion(Parameters params) async { await vmHookup.waitForVmService(); vmHookup.validateSession(params['sessionId'].asString); + if (context.processExited) { + throw RpcException.invalidParams('The profiling session has ended.'); + } final regionId = params['regionId'].asString; final region = context.activeRegions[regionId]; if (region == null) { @@ -149,6 +167,8 @@ final class ProfileSessionRegionRpcHandler { } context.activeRegions.remove(region.regionId); + final pendingStop = Completer(); + _pendingRegionStops.add(pendingStop); try { final snapshot = await snapshotCapture.captureRegionSnapshot( @@ -175,6 +195,7 @@ final class ProfileSessionRegionRpcHandler { cpuSamples: snapshot.cpuSamples, memory: snapshot.memory, rawMemoryPayload: snapshot.rawMemoryPayload, + extra: region.extra, ); context.regions.add(result); return { @@ -199,17 +220,24 @@ final class ProfileSessionRegionRpcHandler { startTimestampMicros: region.startTimestampMicros, endTimestampMicros: stopTimestampMicros, error: error.toString(), + extra: region.extra, ); context.regions.add(failure); await postRegionErrorEvent(region: region, error: error.toString()); throw RpcException.invalidParams( 'Failed to capture requested profile data: $error', ); + } finally { + _pendingRegionStops.remove(pendingStop); + pendingStop.complete(); } } /// Finalizes this session when the profiled process exits. - Future handleProcessExit() async { + Future handleProcessExit() => + _processExitOperation ??= _handleProcessExit(); + + Future _handleProcessExit() async { context.processExited = true; await finishProfilingWindow( warningForRegion: (region) => @@ -222,6 +250,7 @@ final class ProfileSessionRegionRpcHandler { /// Finalizes this session when attach-mode profiling ends. Future finishAttachedWindow() async { + context.processExited = true; await finishProfilingWindow( warningForRegion: (region) => 'Region "${region.name}" was still active when the attach profiling ' @@ -236,6 +265,10 @@ final class ProfileSessionRegionRpcHandler { required String Function(ActiveProfileRegion region) warningForRegion, required String Function(ActiveProfileRegion region) errorForRegion, }) async { + context.overallProfilePoller?.cancel(); + // A stopped region is removed from activeRegions before its VM requests + // finish. Do not dispose the connection or serialize session.json early. + await Future.wait([for (final stop in _pendingRegionStops) stop.future]); final activeRegions = context.activeRegions.values.toList() ..sort( (left, right) => @@ -257,6 +290,7 @@ final class ProfileSessionRegionRpcHandler { startTimestampMicros: region.startTimestampMicros, endTimestampMicros: region.startTimestampMicros, error: errorForRegion(region), + extra: region.extra, ); context.regions.add(failure); } diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_snapshot_capture.dart b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_snapshot_capture.dart index d4a6785..774832f 100644 --- a/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_snapshot_capture.dart +++ b/packages/devtools_profiler_core/lib/src/capture/runner/profile_session_snapshot_capture.dart @@ -8,16 +8,27 @@ import '../../cpu/cpu_samples_merge.dart'; import '../../memory/memory_models.dart'; import '../../memory/memory_profile_summary.dart'; import 'capture_state.dart'; +import 'cpu_snapshot_cache.dart'; import 'profile_runner_shared.dart'; import 'profile_session_context.dart'; -const _vmServiceRequestTimeout = Duration(seconds: 2); +// Flutter startup and large profile response decoding can delay even getVM. +// Keep metadata requests bounded without treating a busy healthy VM as gone. +const _vmServiceRequestTimeout = Duration(seconds: 10); +// CPU and allocation responses contain the complete profile payload. They can +// be substantially larger than ordinary VM-service requests and need a longer +// bound during shutdown so a healthy target is not reported as failed merely +// because a large response took time to transfer. +const _vmServiceProfileRequestTimeout = Duration(seconds: 30); /// Captures CPU and memory snapshots for one profiling session. final class ProfileSessionSnapshotCapture { ProfileSessionSnapshotCapture(this.context); final ProfileSessionContext context; + final _cpuCache = CpuSnapshotCache(); + bool _reportedCachedCpuFallback = false; + bool _reportedCpuCacheEviction = false; /// Ensures the whole-session profile artifact has been captured. Future captureOverallProfile() async { @@ -50,13 +61,27 @@ final class ProfileSessionSnapshotCapture { try { try { - cpuSnapshot = - context.latestOverallSnapshot ?? - await captureCpuSnapshotForAllAppIsolates( - startTimestampMicros: 0, - timeExtentMicros: maxSafeJsInt, - warningContext: 'Whole-session profiling', - ); + // A cached poll may predate a completed region. Attempt one final live + // snapshot before falling back to retained data. + try { + cpuSnapshot = await captureCpuSnapshotForAllAppIsolates( + startTimestampMicros: 0, + timeExtentMicros: maxSafeJsInt, + warningContext: 'Whole-session profiling', + ); + } catch (_) { + cpuSnapshot = + await captureCpuSnapshotForIsolates( + isolateIds: const [], + startTimestampMicros: 0, + timeExtentMicros: maxSafeJsInt, + includePreviouslySeenIsolates: true, + ).catchError((Object error) { + final cached = context.latestOverallSnapshot; + if (cached != null) return cached; + throw error; + }); + } isolateIds.addAll(cpuSnapshot.isolateIds); } catch (error) { failures.add('cpu: $error'); @@ -184,16 +209,22 @@ final class ProfileSessionSnapshotCapture { context.overallSnapshotInProgress = true; try { - if (context.overallMemoryStartSnapshot == null) { - try { - context.overallMemoryStartSnapshot = - await captureMemorySnapshotForAllAppIsolates( - timestampMicros: DateTime.now().toUtc().microsecondsSinceEpoch, - warningContext: 'Whole-session memory start', - ); - } catch (_) { - // Best-effort. A later poll or region start can still seed memory. + // Capture CPU first: allocation profiles can be large enough for workers + // to exit while we are waiting for memory responses. + try { + final snapshot = await captureCpuSnapshotForAllAppIsolates( + startTimestampMicros: 0, + timeExtentMicros: maxSafeJsInt, + ); + final sampleCount = + snapshot.cpuSamples.sampleCount ?? + snapshot.cpuSamples.samples?.length ?? + 0; + if (sampleCount > 0) { + context.latestOverallSnapshot = snapshot; } + } catch (_) { + // Polling is best-effort while isolates start or shut down. } try { final memorySnapshot = await captureMemorySnapshotForAllAppIsolates( @@ -204,20 +235,6 @@ final class ProfileSessionSnapshotCapture { } catch (_) { // Best-effort. Memory capture should not block CPU snapshot polling. } - final snapshot = await captureCpuSnapshotForAllAppIsolates( - startTimestampMicros: 0, - timeExtentMicros: maxSafeJsInt, - ); - final sampleCount = - snapshot.cpuSamples.sampleCount ?? - snapshot.cpuSamples.samples?.length ?? - 0; - if (sampleCount > 0) { - context.latestOverallSnapshot = snapshot; - } - } catch (_) { - // Polling is best-effort. The isolate can be briefly unrunnable while - // the target is starting or shutting down. } finally { context.overallSnapshotInProgress = false; } @@ -253,16 +270,18 @@ final class ProfileSessionSnapshotCapture { originIsolateId: region.isolateId, ); - final cpuSamples = + final cpuSnapshot = region.options.captureKinds.contains(ProfileCaptureKind.cpu) - ? (await captureCpuSnapshotForIsolates( + ? await captureCpuSnapshotForIsolates( isolateIds: isolateIds, startTimestampMicros: region.startTimestampMicros, timeExtentMicros: nonZeroDuration( stopTimestampMicros - region.startTimestampMicros, ), warningContext: 'Region "${region.name}"', - )).cpuSamples + includePreviouslySeenIsolates: + region.options.isolateScope == ProfileIsolateScope.all, + ) : null; ProfileMemoryResult? memory; @@ -310,8 +329,11 @@ final class ProfileSessionSnapshotCapture { } return RegionCaptureSnapshot( - cpuSamples: cpuSamples, - isolateIds: List.unmodifiable(isolateIds), + cpuSamples: cpuSnapshot?.cpuSamples, + isolateIds: List.unmodifiable({ + ...isolateIds, + ...?cpuSnapshot?.isolateIds, + }), memory: memory, rawMemoryPayload: rawMemoryPayload, ); @@ -420,7 +442,7 @@ final class ProfileSessionSnapshotCapture { try { final allocationProfile = await context.vmService! .getAllocationProfile(isolateId) - .timeout(_vmServiceRequestTimeout); + .timeout(_vmServiceProfileRequestTimeout); capturedSnapshots.add( AllocationProfileSnapshot( isolateId: isolateId, @@ -473,6 +495,7 @@ final class ProfileSessionSnapshotCapture { startTimestampMicros: startTimestampMicros, timeExtentMicros: timeExtentMicros, warningContext: warningContext, + includePreviouslySeenIsolates: true, ); } @@ -513,33 +536,70 @@ final class ProfileSessionSnapshotCapture { required int startTimestampMicros, required int timeExtentMicros, String? warningContext, + bool includePreviouslySeenIsolates = false, }) async { - if (isolateIds.isEmpty) { + if (isolateIds.isEmpty && !includePreviouslySeenIsolates) { throw StateError('No application isolates were available for capture.'); } - final capturedIsolateIds = []; - final capturedSamples = []; + final capturedById = {}; final failures = []; await Future.wait([ for (final isolateId in isolateIds) () async { try { - final cpuSamples = await context.vmService!.getCpuSamples( - isolateId, - startTimestampMicros, - timeExtentMicros, - ); - capturedIsolateIds.add(isolateId); - capturedSamples.add(cpuSamples); + final rawSamples = await context.vmService! + .getCpuSamples( + isolateId, + includePreviouslySeenIsolates ? 0 : startTimestampMicros, + includePreviouslySeenIsolates + ? maxSafeJsInt + : timeExtentMicros, + ) + .timeout(_vmServiceProfileRequestTimeout); + final cpuSamples = compactCpuSamples(rawSamples); + capturedById[isolateId] = includePreviouslySeenIsolates + ? clipCpuSnapshot( + cpuSamples, + startTimestampMicros: startTimestampMicros, + timeExtentMicros: timeExtentMicros, + ) + : cpuSamples; + if (includePreviouslySeenIsolates) { + final evicted = _cpuCache.record(isolateId, cpuSamples); + if (evicted.isNotEmpty && !_reportedCpuCacheEviction) { + _reportedCpuCacheEviction = true; + context.warnings.add( + 'CPU snapshot retention reached its bound of ' + '${_cpuCache.capacity} isolates or ' + '${_cpuCache.maxStackEntries} stack entries. Earlier samples ' + 'from evicted isolates may be unavailable.', + ); + } + } } catch (error) { failures.add('$isolateId: $error'); } }(), ]); - if (capturedSamples.isEmpty) { + final sources = includePreviouslySeenIsolates + ? _cpuCache.withMissingIsolates( + capturedById, + startTimestampMicros: startTimestampMicros, + timeExtentMicros: timeExtentMicros, + ) + : capturedById; + if (sources.length > capturedById.length && !_reportedCachedCpuFallback) { + _reportedCachedCpuFallback = true; + context.warnings.add( + 'Retained earlier CPU snapshots for exited or unavailable isolates. ' + 'Samples after their last successful poll may be missing. OS thread ' + 'ids are not isolate ids and can be shared or change over time.', + ); + } + if (sources.isEmpty) { throw StateError( 'CPU samples could not be captured for any isolate.' '${failures.isEmpty ? '' : ' Failures: ${failures.join('; ')}'}', @@ -552,8 +612,11 @@ final class ProfileSessionSnapshotCapture { ); } + final capturedIsolateIds = sources.keys.toList()..sort(); return CpuCaptureSnapshot( - cpuSamples: mergeCpuSamples(capturedSamples), + cpuSamples: mergeCpuSamples([ + for (final id in capturedIsolateIds) sources[id]!, + ], isolateIds: capturedIsolateIds), isolateIds: List.unmodifiable(capturedIsolateIds), ); } diff --git a/packages/devtools_profiler_core/lib/src/cpu/call_tree.dart b/packages/devtools_profiler_core/lib/src/cpu/call_tree.dart index b0837e7..6e5bc04 100644 --- a/packages/devtools_profiler_core/lib/src/cpu/call_tree.dart +++ b/packages/devtools_profiler_core/lib/src/cpu/call_tree.dart @@ -248,14 +248,26 @@ ProfileCallTree buildCallTree({ ProfileCallTree buildBottomUpTree({ required CpuSamples cpuSamples, ProfileFramePredicate? includeFrame, -}) { - final buildResult = _buildTopDownTree( - cpuSamples: cpuSamples, - includeFrame: includeFrame, - ); +}) => buildBottomUpTreeFromCallTree( + buildCallTree(cpuSamples: cpuSamples, includeFrame: includeFrame), +); + +/// Builds a bottom-up view from an untruncated top-down [callTree]. +/// +/// Reuses resolved, filtered paths when multiple views of one profile are +/// needed. Apply presentation limits only after deriving all views. +/// Throws [ArgumentError] if [callTree] is not a top-down view. +ProfileCallTree buildBottomUpTreeFromCallTree(ProfileCallTree callTree) { + if (callTree.view != ProfileCallTreeView.topDown) { + throw ArgumentError.value( + callTree.view, + 'callTree.view', + 'Expected topDown', + ); + } final bottomUpRoots = <_MutableBottomUpNode>[]; - for (final rootChild in buildResult.root.children) { + for (final rootChild in callTree.root.children) { _generateBottomUpRoots( node: rootChild, parent: null, @@ -265,16 +277,16 @@ ProfileCallTree buildBottomUpTree({ final mergedRoots = _mergeBottomUpNodes(bottomUpRoots); final syntheticRoot = _MutableBottomUpNode.root( - sampleCount: buildResult.sampleCount, + sampleCount: callTree.sampleCount, )..children.addAll(mergedRoots); return ProfileCallTree( - sampleCount: buildResult.sampleCount, - samplePeriodMicros: buildResult.samplePeriodMicros, + sampleCount: callTree.sampleCount, + samplePeriodMicros: callTree.samplePeriodMicros, view: ProfileCallTreeView.bottomUp, root: syntheticRoot.freeze( - totalSampleCount: buildResult.sampleCount, - samplePeriodMicros: buildResult.samplePeriodMicros, + totalSampleCount: callTree.sampleCount, + samplePeriodMicros: callTree.samplePeriodMicros, ), ); } @@ -285,13 +297,13 @@ _TopDownBuildResult _buildTopDownTree({ }) { final samplePeriodMicros = cpuSamples.samplePeriod ?? 0; final functions = cpuSamples.functions ?? const []; + final resolver = ProfileFrameResolver(functions); final root = _MutableCallTreeNode.root(); var sampleCount = 0; for (final sample in cpuSamples.samples ?? const []) { - final frames = filterStackFrames( + final frames = resolver.filterStack( sample.stack ?? const [], - functions, includeFrame: includeFrame, ); if (frames.isEmpty) continue; @@ -316,7 +328,7 @@ _TopDownBuildResult _buildTopDownTree({ } void _generateBottomUpRoots({ - required _MutableCallTreeNode node, + required ProfileCallTreeNode node, required _MutableBottomUpNode? parent, required List<_MutableBottomUpNode> bottomUpRoots, }) { @@ -463,7 +475,7 @@ final class _MutableBottomUpNode { ); } - factory _MutableBottomUpNode.fromTopDownNode(_MutableCallTreeNode node) { + factory _MutableBottomUpNode.fromTopDownNode(ProfileCallTreeNode node) { return _MutableBottomUpNode( name: node.name, kind: node.kind, diff --git a/packages/devtools_profiler_core/lib/src/cpu/cpu_profile_summary.dart b/packages/devtools_profiler_core/lib/src/cpu/cpu_profile_summary.dart index 1580f8c..8c4ffe0 100644 --- a/packages/devtools_profiler_core/lib/src/cpu/cpu_profile_summary.dart +++ b/packages/devtools_profiler_core/lib/src/cpu/cpu_profile_summary.dart @@ -24,16 +24,17 @@ ProfileRegionResult summarizeCpuSamples({ String? rawProfilePath, int topFrameCount = 10, ProfileFramePredicate? includeFrame, + Map extra = const {}, }) { final functions = cpuSamples.functions ?? const []; + final resolver = ProfileFrameResolver(functions); final samples = cpuSamples.samples ?? const []; final statsByFrameKey = {}; var sampleCount = 0; for (final sample in samples) { - final frames = filterStackFrames( + final frames = resolver.filterStack( sample.stack ?? const [], - functions, includeFrame: includeFrame, ); if (frames.isEmpty) continue; @@ -89,6 +90,7 @@ ProfileRegionResult summarizeCpuSamples({ topTotalFrames: topTotalFrames, rawProfilePath: rawProfilePath, summaryPath: summaryPath, + extra: extra, ); } diff --git a/packages/devtools_profiler_core/lib/src/cpu/cpu_samples_merge.dart b/packages/devtools_profiler_core/lib/src/cpu/cpu_samples_merge.dart index 551477a..b751946 100644 --- a/packages/devtools_profiler_core/lib/src/cpu/cpu_samples_merge.dart +++ b/packages/devtools_profiler_core/lib/src/cpu/cpu_samples_merge.dart @@ -1,11 +1,123 @@ import 'package:vm_service/vm_service.dart'; +/// A VM CPU sample with profiler-recorded isolate provenance. +/// +/// [tid] identifies an OS thread, not a Dart isolate. An isolate can migrate +/// between threads, and a thread can execute multiple isolates over time. +final class ProfileCpuSample extends CpuSample { + /// Copies a VM sample without losing tags, truncation or allocation metadata. + ProfileCpuSample(CpuSample sample, {required this.isolateId}) + : super( + tid: sample.tid, + timestamp: sample.timestamp, + stack: sample.stack, + vmTag: sample.vmTag, + userTag: sample.userTag, + truncated: sample.truncated, + identityHashCode: sample.identityHashCode, + classId: sample.classId, + ); + + /// The VM isolate id recorded at capture time, or null for legacy artifacts. + final String? isolateId; + + @override + Map toJson() => { + ...super.toJson(), + if (isolateId != null) 'profilerIsolateId': isolateId, + }; +} + +/// Parses a stored VM CPU profile, restoring untyped function names and origins. +/// +/// VM [NativeFunction.toJson] emits a name without a type discriminator. +/// The generic VM parser does not restore these objects on an artifact +/// round-trip. Only explicit names from the artifact are restored here. +CpuSamples? parseProfileCpuSamples(Map json) { + final result = CpuSamples.parse(json); + if (result == null) return null; + final rawFunctions = json['functions'] as List? ?? const []; + final functions = result.functions ?? const []; + for (var i = 0; i < functions.length; i++) { + final raw = (rawFunctions[i] as Map)['function']; + if (functions[i].function == null && raw is Map && raw['name'] is String) { + functions[i].function = NativeFunction(name: raw['name'] as String); + } + } + final rawSamples = json['samples'] as List? ?? const []; + final samples = result.samples ?? const []; + result.samples = [ + for (var i = 0; i < samples.length; i++) + ProfileCpuSample( + samples[i], + isolateId: (rawSamples[i] as Map)['profilerIsolateId'] as String?, + ), + ]; + return result; +} + +/// Removes unreferenced functions from a CPU snapshot without changing stacks. +/// +/// The VM may return its entire compiled-function table for each isolate. +/// Compact snapshots before retaining them so short-lived workers do not each +/// keep thousands of unrelated Flutter framework functions alive. +CpuSamples compactCpuSamples(CpuSamples source) { + final functions = source.functions ?? const []; + final samples = source.samples ?? const []; + final referenced = { + for (final sample in samples) + for (final index in sample.stack ?? const []) + if (index >= 0 && index < functions.length) index, + }.toList()..sort(); + final indices = { + for (var i = 0; i < referenced.length; i++) referenced[i]: i, + }; + return CpuSamples( + sampleCount: source.sampleCount, + samplePeriod: source.samplePeriod, + maxStackDepth: source.maxStackDepth, + pid: source.pid, + timeOriginMicros: source.timeOriginMicros, + timeExtentMicros: source.timeExtentMicros, + functions: [for (final index in referenced) functions[index]], + samples: [ + for (final sample in samples) + ProfileCpuSample( + sample, + isolateId: sample is ProfileCpuSample ? sample.isolateId : null, + ) + ..stack = sample.stack == null + ? null + : [for (final index in sample.stack!) indices[index] ?? -1], + ], + ); +} + /// Merges multiple isolate-local CPU sample payloads into one synthetic profile. /// /// The merged profile preserves every source function by appending function /// tables and rewriting sample stack indices to the new offsets. -CpuSamples mergeCpuSamples(Iterable cpuSamplesByIsolate) { +CpuSamples mergeCpuSamples( + Iterable cpuSamplesByIsolate, { + List? isolateIds, +}) { final cpuSamplesList = cpuSamplesByIsolate.toList(growable: false); + if (isolateIds != null && isolateIds.length != cpuSamplesList.length) { + throw ArgumentError('Expected one isolate id per CPU sample payload.'); + } + final periods = { + for (final source in cpuSamplesList) + if (source.samplePeriod case final period? when period > 0) period, + }; + final pids = { + for (final source in cpuSamplesList) + if (source.pid case final pid? when pid >= 0) pid, + }; + if (periods.length > 1 || pids.length > 1) { + throw ArgumentError( + 'CPU payloads must come from one VM with a common sampling period.', + ); + } if (cpuSamplesList.isEmpty) { return CpuSamples( sampleCount: 0, @@ -16,7 +128,7 @@ CpuSamples mergeCpuSamples(Iterable cpuSamplesByIsolate) { samples: const [], ); } - if (cpuSamplesList.length == 1) { + if (cpuSamplesList.length == 1 && isolateIds == null) { return cpuSamplesList.single; } @@ -24,38 +136,46 @@ CpuSamples mergeCpuSamples(Iterable cpuSamplesByIsolate) { final mergedSamples = []; var sampleCount = 0; - int? samplePeriodMicros; int? timeOriginMicros; int? endTimestampMicros; - for (final cpuSamples in cpuSamplesList) { + for ( + var sourceIndex = 0; + sourceIndex < cpuSamplesList.length; + sourceIndex++ + ) { + final cpuSamples = cpuSamplesList[sourceIndex]; final functions = cpuSamples.functions ?? const []; final samples = cpuSamples.samples ?? const []; final functionIndexOffset = mergedFunctions.length; mergedFunctions.addAll(functions); mergedSamples.addAll([ for (final sample in samples) - CpuSample( - timestamp: sample.timestamp, - stack: switch (sample.stack) { + ProfileCpuSample( + sample, + isolateId: + isolateIds?[sourceIndex] ?? + (sample is ProfileCpuSample ? sample.isolateId : null), + ) + ..stack = switch (sample.stack) { final List stack => [ - for (final frameIndex in stack) frameIndex + functionIndexOffset, + for (final frameIndex in stack) + if (frameIndex >= 0 && frameIndex < functions.length) + frameIndex + functionIndexOffset + else + -1, ], _ => null, }, - ), ]); - sampleCount += cpuSamples.sampleCount ?? samples.length; - - final candidateSamplePeriod = cpuSamples.samplePeriod; - if (samplePeriodMicros == null || - samplePeriodMicros == 0 && candidateSamplePeriod != null) { - samplePeriodMicros = candidateSamplePeriod; - } + sampleCount += switch (cpuSamples.sampleCount) { + final count? when count >= 0 => count, + _ => samples.length, + }; final candidateOrigin = cpuSamples.timeOriginMicros; - if (candidateOrigin != null) { + if (candidateOrigin != null && candidateOrigin >= 0) { timeOriginMicros = switch (timeOriginMicros) { final int current when current <= candidateOrigin => current, _ => candidateOrigin, @@ -66,7 +186,8 @@ CpuSamples mergeCpuSamples(Iterable cpuSamplesByIsolate) { cpuSamples.timeOriginMicros, cpuSamples.timeExtentMicros, )) { - (final int origin, final int extent) => origin + extent, + (final int origin, final int extent) when origin >= 0 && extent >= 0 => + origin + extent, _ => null, }; if (candidateEnd != null) { @@ -84,8 +205,12 @@ CpuSamples mergeCpuSamples(Iterable cpuSamplesByIsolate) { final normalizedOrigin = timeOriginMicros ?? 0; final normalizedEnd = endTimestampMicros ?? normalizedOrigin; return CpuSamples( + pid: pids.singleOrNull, + maxStackDepth: cpuSamplesList + .map((samples) => samples.maxStackDepth ?? 0) + .fold(0, (max, value) => value > max ? value : max), sampleCount: sampleCount == 0 ? mergedSamples.length : sampleCount, - samplePeriod: samplePeriodMicros ?? 0, + samplePeriod: periods.singleOrNull ?? 0, timeOriginMicros: normalizedOrigin, timeExtentMicros: normalizedEnd - normalizedOrigin, functions: mergedFunctions, diff --git a/packages/devtools_profiler_core/lib/src/cpu/method_table.dart b/packages/devtools_profiler_core/lib/src/cpu/method_table.dart index ba9c95b..8eb96da 100644 --- a/packages/devtools_profiler_core/lib/src/cpu/method_table.dart +++ b/packages/devtools_profiler_core/lib/src/cpu/method_table.dart @@ -1,5 +1,6 @@ import 'package:vm_service/vm_service.dart'; +import 'call_tree.dart'; import 'profile_frames.dart'; /// A caller or callee relationship for a method table entry. @@ -233,39 +234,33 @@ class ProfileMethodTable { ProfileMethodTable buildMethodTable({ required CpuSamples cpuSamples, ProfileFramePredicate? includeFrame, -}) { - final samplePeriodMicros = cpuSamples.samplePeriod ?? 0; - final functions = cpuSamples.functions ?? const []; - final root = _MethodTableOccurrence.root(); - var sampleCount = 0; - var nextOccurrenceId = 1; - - for (final sample in cpuSamples.samples ?? const []) { - final frames = filterStackFrames( - sample.stack ?? const [], - functions, - includeFrame: includeFrame, +}) => buildMethodTableFromCallTree( + buildCallTree(cpuSamples: cpuSamples, includeFrame: includeFrame), +); + +/// Builds a method table from an untruncated top-down [callTree]. +/// +/// Reuses resolved, filtered paths when multiple views of one profile are +/// needed. Apply presentation limits only after deriving all views. +/// Throws [ArgumentError] if [callTree] is not a top-down view. +ProfileMethodTable buildMethodTableFromCallTree(ProfileCallTree callTree) { + if (callTree.view != ProfileCallTreeView.topDown) { + throw ArgumentError.value( + callTree.view, + 'callTree.view', + 'Expected topDown', ); - if (frames.isEmpty) continue; - - sampleCount++; - var current = root; - for (final frame in frames.reversed) { - current = current.childFor( - frame, - occurrenceIdFactory: () => nextOccurrenceId++, - ); - current.totalSamples++; - } - current.selfSamples++; } + final samplePeriodMicros = callTree.samplePeriodMicros; + final sampleCount = callTree.sampleCount; final methodsById = {}; - for (final child in root.children.values) { + final ancestorMethodIds = {}; + for (final child in callTree.root.children) { _walkMethodTableOccurrences( node: child, methodsById: methodsById, - ancestorOccurrenceIds: const {}, + ancestorMethodIds: ancestorMethodIds, parentEntry: null, ); } @@ -289,19 +284,25 @@ ProfileMethodTable buildMethodTable({ } void _walkMethodTableOccurrences({ - required _MethodTableOccurrence node, + required ProfileCallTreeNode node, required Map methodsById, - required Set ancestorOccurrenceIds, + required Set ancestorMethodIds, required _MutableMethodEntry? parentEntry, }) { + final methodId = '${node.name}|${node.kind}|${node.location ?? ''}'; final entry = methodsById.putIfAbsent( - node.methodId, - () => _MutableMethodEntry.fromNode(node), + methodId, + () => _MutableMethodEntry( + methodId: methodId, + name: node.name, + kind: node.kind, + location: node.location, + ), ); - final shouldMergeTotal = !entry.contributingOccurrenceIds.any( - ancestorOccurrenceIds.contains, - ); + // Only the outermost occurrence contributes inclusive samples. Reuse the + // active path rather than copying ancestors or scanning earlier branches. + final shouldMergeTotal = ancestorMethodIds.add(methodId); entry.merge(node, mergeTotal: shouldMergeTotal); if (parentEntry != null) { @@ -311,45 +312,16 @@ void _walkMethodTableOccurrences({ (entry.callerEdgeCounts[parentEntry.methodId] ?? 0) + node.totalSamples; } - final childAncestorIds = {...ancestorOccurrenceIds, node.occurrenceId}; - for (final child in node.children.values) { + for (final child in node.children) { _walkMethodTableOccurrences( node: child, methodsById: methodsById, - ancestorOccurrenceIds: childAncestorIds, + ancestorMethodIds: ancestorMethodIds, parentEntry: entry, ); } -} - -final class _MethodTableOccurrence { - _MethodTableOccurrence({required this.occurrenceId, required this.frame}); - - factory _MethodTableOccurrence.root() => _MethodTableOccurrence( - occurrenceId: 0, - frame: const ProfileFrame(name: 'all', kind: 'root', location: null), - ); - - final int occurrenceId; - final ProfileFrame frame; - final Map children = {}; - - int selfSamples = 0; - int totalSamples = 0; - - String get methodId => frame.key; - - _MethodTableOccurrence childFor( - ProfileFrame frame, { - required int Function() occurrenceIdFactory, - }) { - return children.putIfAbsent( - frame.key, - () => _MethodTableOccurrence( - occurrenceId: occurrenceIdFactory(), - frame: frame, - ), - ); + if (shouldMergeTotal) { + ancestorMethodIds.remove(methodId); } } @@ -361,28 +333,17 @@ final class _MutableMethodEntry { required this.location, }); - factory _MutableMethodEntry.fromNode(_MethodTableOccurrence node) { - return _MutableMethodEntry( - methodId: node.methodId, - name: node.frame.name, - kind: node.frame.kind, - location: node.frame.location, - ); - } - final String methodId; final String name; final String kind; final String? location; - final Set contributingOccurrenceIds = {}; final Map callerEdgeCounts = {}; final Map calleeEdgeCounts = {}; int selfSamples = 0; int totalSamples = 0; - void merge(_MethodTableOccurrence node, {required bool mergeTotal}) { - contributingOccurrenceIds.add(node.occurrenceId); + void merge(ProfileCallTreeNode node, {required bool mergeTotal}) { selfSamples += node.selfSamples; if (mergeTotal) { totalSamples += node.totalSamples; 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 59bd59c..efd248b 100644 --- a/packages/devtools_profiler_core/lib/src/cpu/profile_frames.dart +++ b/packages/devtools_profiler_core/lib/src/cpu/profile_frames.dart @@ -73,6 +73,16 @@ class ProfileFrame { return _packageNameFromFilePath(parsedUri.toFilePath()); } + /// Whether the frame belongs to `dart:async`. + bool get isAsyncOverhead { + final source = location; + if (source == null || source.isEmpty) return false; + if (source.startsWith('dart:async')) return true; + // org-dartlang-sdk:///sdk/lib/async/... + if (source.startsWith('org-dartlang-sdk:///sdk/lib/async/')) return true; + return false; + } + /// Whether the frame represents native code. bool get isNative { final source = location; @@ -108,9 +118,8 @@ String? _packageNameFromFilePath(String filePath) { } String _packageNameFromPubCacheFolder(String folder) { - final versionMatch = RegExp( - r'^(.+)-(\d+\.\d+\.\d+(?:[-+].*)?)$', - ).firstMatch(folder); + final versionMatch = RegExp(r'^(.+)-(\d+\.\d+\.\d+(?:[-+].*)?)$') + .firstMatch(folder); return versionMatch?.group(1) ?? folder; } @@ -134,23 +143,53 @@ List filterStackFrames( List stack, List functions, { ProfileFramePredicate? includeFrame, -}) { - if (stack.isEmpty) { - return const []; - } +}) => + ProfileFrameResolver(functions) + .filterStack(stack, includeFrame: includeFrame); - final frames = []; - final resolvedFrames = {}; - for (final functionIndex in stack) { - final frame = resolvedFrames.putIfAbsent( - functionIndex, - () => profileFrameFromFunction(functions, functionIndex), +/// Resolves function metadata once per index within one CPU profile. +/// +/// Create a new resolver for each profile or changed function table. The +/// function table must not be mutated while this resolver is in use. +/// Stack lists and predicate results are not cached, so memory is bounded by +/// the number of referenced functions, not by the number of samples. +final class ProfileFrameResolver { + /// Creates a resolver for [functions]. + ProfileFrameResolver(List functions) + : _functions = functions; + + final List _functions; + final Map _frames = {}; + + /// Returns the cached metadata for [functionIndex]. + ProfileFrame resolve(int functionIndex) { + // Invalid indices share one unknown entry rather than growing the cache. + final index = functionIndex < 0 || functionIndex >= _functions.length + ? -1 + : functionIndex; + return _frames.putIfAbsent( + index, + () => profileFrameFromFunction(_functions, index), ); - if (includeFrame == null || includeFrame(frame)) { - frames.add(frame); + } + + /// Returns included frames in stack order, preserving recursive occurrences. + /// + /// [includeFrame] is evaluated for each occurrence, including cached frames. + List filterStack( + List stack, { + ProfileFramePredicate? includeFrame, + }) { + if (stack.isEmpty) return const []; + final frames = []; + for (final index in stack) { + final frame = resolve(index); + if (includeFrame == null || includeFrame(frame)) { + frames.add(frame); + } } + return frames; } - return frames; } /// Returns a human-readable name for a VM profile function. @@ -222,3 +261,13 @@ String _simplifyStackFrameName(String? name) { } return normalized.split('&').last; } + +/// Returns the approximate source line number for [function], or `null` if +/// the line number is unavailable. +int? lineForFunction(ProfileFunction function) { + final object = function.function; + if (object case FuncRef(location: final SourceLocation location?)) { + return location.line; + } + return null; +} diff --git a/packages/devtools_profiler_core/lib/src/flutter/frame_analysis.dart b/packages/devtools_profiler_core/lib/src/flutter/frame_analysis.dart index f095595..ea92c4c 100644 --- a/packages/devtools_profiler_core/lib/src/flutter/frame_analysis.dart +++ b/packages/devtools_profiler_core/lib/src/flutter/frame_analysis.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:vm_service/vm_service.dart'; const _kFrameBudgetUs = 16666; diff --git a/packages/devtools_profiler_core/lib/src/flutter/screenshot.dart b/packages/devtools_profiler_core/lib/src/flutter/screenshot.dart index 832b56a..f82ecf1 100644 --- a/packages/devtools_profiler_core/lib/src/flutter/screenshot.dart +++ b/packages/devtools_profiler_core/lib/src/flutter/screenshot.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; + import 'package:vm_service/vm_service.dart'; /// Captures screenshots of running Flutter applications via VM service diff --git a/packages/devtools_profiler_core/lib/src/flutter/widget_tree.dart b/packages/devtools_profiler_core/lib/src/flutter/widget_tree.dart index 9e9eb46..a4e55fd 100644 --- a/packages/devtools_profiler_core/lib/src/flutter/widget_tree.dart +++ b/packages/devtools_profiler_core/lib/src/flutter/widget_tree.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:vm_service/vm_service.dart'; /// A node in the captured Flutter widget tree. 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 9db0b73..7c3b0fd 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 @@ -4,11 +4,13 @@ import 'dart:io'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:vm_service/vm_service.dart'; +import '../cpu/profile_frames.dart'; import 'memory_models.dart'; /// Predicate used to retain or hide memory class summaries. -typedef ProfileMemoryClassPredicate = - bool Function(ProfileMemoryClassSummary summary); +typedef ProfileMemoryClassPredicate = bool Function( + ProfileMemoryClassSummary summary, +); /// Builds a [ProfileMemoryResult] from start and end allocation snapshots. ProfileMemoryResult summarizeMemoryProfile({ @@ -139,9 +141,9 @@ Future readMemoryClassesFromArtifact( ProfileMemoryClassPredicate? includeClass, int topClassCount = 50, }) async { - final json = - jsonDecode(await File(rawProfilePath).readAsString()) - as Map; + final json = jsonDecode( + await File(rawProfilePath).readAsString(), + ) as Map; return rebuildMemoryProfileFromArtifact( json.cast(), rawProfilePath: rawProfilePath, @@ -265,3 +267,110 @@ final class _MutableMemoryClassStats { int liveBytes = 0; int liveInstances = 0; } + +/// A growing memory class paired with profile-wide CPU activity. +/// +/// This is correlation, not allocation-site evidence. Every class shares the +/// same CPU function distribution; samples do not identify which class a +/// function allocated. +class AllocationAttribution { + /// Creates an attribution entry. + const AllocationAttribution({ + required this.className, + required this.libraryUri, + required this.allocatedBytes, + required this.allocatedInstances, + required this.callSiteFractions, + }); + + /// The name of the class being allocated. + final String className; + + /// The library URI where the class is defined. + final String? libraryUri; + + /// Number of bytes allocated for this class in the capture window. + final int allocatedBytes; + + /// Number of instances allocated for this class in the capture window. + final int allocatedInstances; + + /// Profile-wide self functions and fractions of eligible named, non-native + /// self samples, sorted descending. + /// + /// Fractions are not percentages of allocations or of all CPU samples. + /// The legacy field name is retained for serialization compatibility. + final List<(String name, double fraction)> callSiteFractions; + + /// Serializes this attribution entry to JSON. + Map toJson() => { + 'attributionKind': 'profileWideCpuCorrelation', + 'className': className, + 'libraryUri': libraryUri, + 'allocatedBytes': allocatedBytes, + 'allocatedInstances': allocatedInstances, + 'callSiteFractions': [ + for (final (name, fraction) in callSiteFractions) + {'function': name, 'fraction': fraction}, + ], + }; +} + +/// Pairs growing memory classes with the profile-wide self CPU distribution. +/// +/// This does not identify allocation sites. Each growing class receives the +/// same distribution of eligible named, non-native self samples. +List attributeAllocationsToCallers( + ProfileMemoryResult memory, + CpuSamples cpuSamples, +) { + final classes = memory.topClasses + .where((c) => c.allocationBytesDelta > 0) + .toList(); + if (classes.isEmpty) return const []; + + final functions = cpuSamples.functions ?? const []; + final samples = cpuSamples.samples ?? const []; + if (functions.isEmpty || samples.isEmpty) return const []; + + // Count how many times each function appears as self-frame (top of stack). + final functionHits = {}; + for (final sample in samples) { + final stack = sample.stack ?? const []; + if (stack.isEmpty) continue; + final idx = stack.first; + if (idx < 0 || idx >= functions.length) continue; + final func = functions[idx]; + final kind = func.kind; + if (kind == null || kind.toLowerCase() == 'native') continue; + final name = displayNameForFunction(func); + if (name.isEmpty || name == 'unknown') continue; + functionHits[name] = (functionHits[name] ?? 0) + 1; + } + + if (functionHits.isEmpty) return const []; + + final totalHits = functionHits.values.fold(0, (s, v) => s + v); + if (totalHits <= 0) return const []; + + // Sort functions by hit count descending. + const maxCorrelatedFunctions = 5; + const maxGrowingClasses = 8; + final sortedFunctions = functionHits.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final topFunctions = sortedFunctions.take(maxCorrelatedFunctions).toList(); + + return [ + for (final cls in classes.take(maxGrowingClasses)) + AllocationAttribution( + className: cls.className, + libraryUri: cls.libraryUri, + allocatedBytes: cls.allocationBytesDelta, + allocatedInstances: cls.allocationInstancesDelta, + callSiteFractions: [ + for (final entry in topFunctions) + (entry.key, entry.value / totalHits), + ], + ), + ]; +} diff --git a/packages/devtools_profiler_core/pubspec.yaml b/packages/devtools_profiler_core/pubspec.yaml index da5cce4..d2b2576 100644 --- a/packages/devtools_profiler_core/pubspec.yaml +++ b/packages/devtools_profiler_core/pubspec.yaml @@ -2,10 +2,10 @@ name: devtools_profiler_core description: Pure-Dart profiling backend for DevTools-inspired Dart and Flutter CLI and MCP workflows. -version: 0.4.0 +version: 0.6.0 environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' resolution: workspace @@ -13,14 +13,14 @@ repository: https://github.com/kingwill101/devtools-profiler/tree/main/packages/ dependencies: dtd: ^4.0.0 - devtools_profiler_protocol: ^0.1.0 - devtools_shared: ^12.1.0 + devtools_profiler_protocol: ^0.3.0 + devtools_shared: ^14.0.0 json_rpc_2: ^4.1.0 - path: ^1.9.0 - vm_service: ^15.0.2 + path: ^1.9.1 + vm_service: ^15.3.0 dev_dependencies: - artisanal: ^0.3.0 - artisanal_widgets: ^0.2.0 - devtools_region_profiler: ^0.1.0 - test: ^1.25.8 + artisanal: '>=0.6.0 <1.0.0' + artisanal_widgets: '>=0.4.0 <1.0.0' + devtools_region_profiler: ^0.3.0 + test: ^1.32.0 diff --git a/packages/devtools_profiler_core/test/cpu_samples_merge_test.dart b/packages/devtools_profiler_core/test/cpu_samples_merge_test.dart index bda4d8d..a79a994 100644 --- a/packages/devtools_profiler_core/test/cpu_samples_merge_test.dart +++ b/packages/devtools_profiler_core/test/cpu_samples_merge_test.dart @@ -1,8 +1,145 @@ +import 'dart:convert'; + import 'package:devtools_profiler_core/devtools_profiler_core.dart'; import 'package:test/test.dart'; import 'package:vm_service/vm_service.dart'; void main() { + test( + 'compaction drops only unreferenced functions and preserves frame names', + () { + final source = CpuSamples( + sampleCount: 1, + samplePeriod: 1000, + pid: 42, + maxStackDepth: 128, + functions: [ + for (final name in ['unused', 'root', 'leaf']) + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: name, name: name), + ), + ], + samples: [ + CpuSample( + tid: 7, + timestamp: 10, + truncated: true, + stack: [2, 1, 2, 9], + ), + ], + ); + final compact = compactCpuSamples(source); + expect(compact.functions!.map(displayNameForFunction), ['root', 'leaf']); + expect(compact.samples!.single.stack, [1, 0, 1, -1]); + expect(compact.samples!.single.tid, 7); + expect(compact.samples!.single.truncated, isTrue); + expect(compact.pid, 42); + expect(compact.maxStackDepth, 128); + expect( + buildCallTree(cpuSamples: compact).toJson(), + buildCallTree(cpuSamples: source).toJson(), + ); + expect(source.functions, hasLength(3)); + expect(source.samples!.single.stack, [2, 1, 2, 9]); + }, + ); + + test( + 'artifact round trips preserve native, stub, tag and collected names', + () { + final source = CpuSamples( + functions: [ + for (final (kind, name) in [ + ('Native', 'malloc'), + ('Stub', '[Stub] Allocate Array'), + ('Tag', 'VM'), + ('Collected', ''), + ]) + ProfileFunction( + kind: kind, + function: NativeFunction(name: name), + ), + ProfileFunction(kind: 'Dart'), + ], + samples: [ + CpuSample(timestamp: 1, stack: [0, 1, 2, 3, 4]), + ], + ); + final restored = parseProfileCpuSamples( + jsonDecode(jsonEncode(source.toJson())) as Map, + )!; + expect(restored.functions!.map(displayNameForFunction), [ + 'malloc', + '[Stub] Allocate Array', + 'VM', + '', + 'unknown', + ]); + }, + ); + + test('merge preserves metadata and never aliases invalid local indices', () { + CpuSamples source(int tid, String name, List stack) => CpuSamples( + pid: 42, + maxStackDepth: 128, + samplePeriod: 1000, + functions: [ + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: 'f', name: name), + ), + ], + samples: [ + CpuSample( + tid: tid, + timestamp: tid, + stack: stack, + vmTag: 'Dart', + userTag: 'work', + truncated: true, + identityHashCode: 123, + classId: 7, + ), + ], + ); + final left = source(10, 'left', [0, 1, -1]); + final right = source(20, 'right', [0, -1, 99]); + final result = mergeCpuSamples( + [left, right], + isolateIds: ['main', 'worker'], + ); + expect(result.pid, 42); + expect(result.maxStackDepth, 128); + expect(result.samples![0].stack, [0, -1, -1]); + expect(result.samples![1].stack, [1, -1, -1]); + expect(left.samples!.single.stack, [0, 1, -1]); + final restored = parseProfileCpuSamples( + jsonDecode(jsonEncode(result.toJson())) as Map, + )!; + for (var i = 0; i < 2; i++) { + final sample = restored.samples![i] as ProfileCpuSample; + expect(sample.isolateId, ['main', 'worker'][i]); + expect(sample.tid, [10, 20][i]); + expect(sample.vmTag, 'Dart'); + expect(sample.userTag, 'work'); + expect(sample.truncated, isTrue); + expect(sample.identityHashCode, 123); + expect(sample.classId, 7); + } + final single = mergeCpuSamples([left], isolateIds: ['main']); + expect((single.samples!.single as ProfileCpuSample).isolateId, 'main'); + expect(() => mergeCpuSamples([left], isolateIds: []), throwsArgumentError); + expect( + () => mergeCpuSamples([left, CpuSamples(pid: 99)]), + throwsArgumentError, + ); + expect( + () => mergeCpuSamples([left, CpuSamples(samplePeriod: 50)]), + throwsArgumentError, + ); + }); + test('mergeCpuSamples preserves stacks from multiple isolates', () { final workerClass = ClassRef(id: 'classes/worker', name: 'Worker'); final left = CpuSamples( diff --git a/packages/devtools_profiler_core/test/cpu_snapshot_cache_test.dart b/packages/devtools_profiler_core/test/cpu_snapshot_cache_test.dart new file mode 100644 index 0000000..5652630 --- /dev/null +++ b/packages/devtools_profiler_core/test/cpu_snapshot_cache_test.dart @@ -0,0 +1,86 @@ +import 'package:devtools_profiler_core/src/capture/runner/cpu_snapshot_cache.dart'; +import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + CpuSamples source(List times) => CpuSamples( + samplePeriod: 1000, + functions: [], + samples: [for (final time in times) CpuSample(timestamp: time, stack: [])], + ); + + test( + 'cache replaces polls and clips exited workers to the region window', + () { + final cache = CpuSnapshotCache(); + cache.record('worker', source([5, 10])); + cache.record('worker', source([5, 10, 20, 30])); + final result = cache.withMissingIsolates( + { + 'main': source([15]), + }, + startTimestampMicros: 10, + timeExtentMicros: 10, + ); + expect(result.keys, containsAll(['main', 'worker'])); + expect(result['worker']!.samples!.map((sample) => sample.timestamp), [ + 10, + 20, + ]); + expect(result['worker']!.sampleCount, 2); + final live = source([19]); + expect( + cache.withMissingIsolates( + {'worker': live}, + startTimestampMicros: 10, + timeExtentMicros: 10, + )['worker'], + same(live), + ); + expect( + cache.withMissingIsolates( + {}, + startTimestampMicros: 100, + timeExtentMicros: 10, + ), + isEmpty, + ); + }, + ); + + test('cache evicts least recently observed isolates at its capacity', () { + final cache = CpuSnapshotCache(capacity: 2); + cache.record('old', source([10])); + cache.record('live', source([10])); + cache.record('live', source([20])); + expect(cache.record('new', source([20])), ['old']); + expect( + cache + .withMissingIsolates( + {}, + startTimestampMicros: 0, + timeExtentMicros: 50, + ) + .keys, + ['live', 'new'], + ); + }); + + test('cache also bounds retained stack entries', () { + final cache = CpuSnapshotCache(maxStackEntries: 2); + final large = CpuSamples( + samples: [ + CpuSample(timestamp: 10, stack: [0, 1, 2]), + ], + ); + expect(cache.record('large', large), ['large']); + expect( + cache.withMissingIsolates( + {}, + startTimestampMicros: 0, + timeExtentMicros: 20, + ), + isEmpty, + ); + }); +} diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/artisanal_widget_app.dart b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/artisanal_widget_app.dart index dde1103..feda226 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/artisanal_widget_app.dart +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/bin/artisanal_widget_app.dart @@ -1,10 +1,11 @@ import 'package:artisanal/tui.dart' as tui; +import 'package:artisanal_widgets/app.dart' as app; import 'package:artisanal_widgets/widgets.dart' as w; Future main() async { - final app = tui.WidgetApp(ProfilerWidgetApp()); + final widgetApp = app.WidgetApp(ProfilerWidgetApp()); await tui.runProgram( - app, + widgetApp, options: const tui.ProgramOptions( altScreen: true, mouseMode: tui.MouseMode.allMotion, diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.lock b/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.lock index 116a29f..b04eb5f 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.lock +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: acanthis - sha256: "0e003d8a563e74b376c58e7d347e142e429d18603be5b70c80362b77730f66b5" + sha256: ead4c2e6b0cc76fd73f6440a45efaa3ca4fd14a835237bff70c84b498c3171a4 url: "https://pub.dev" source: hosted - version: "1.5.4" + version: "1.6.0" archive: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.2.0" args: dependency: transitive description: @@ -29,18 +29,18 @@ packages: dependency: "direct main" description: name: artisanal - sha256: ff67fd9dea58a603dea7c4073427c30f7ddeebba5da6a05a40cb7a8777ec1b8c + sha256: "27c321a233061e9bdd6dc5940063f3c12df6e08bc68359630f1c2642e39e9946" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.6.0" artisanal_widgets: dependency: "direct main" description: name: artisanal_widgets - sha256: d32b5e41689c9853241437ff9644a5272fbf341e938154fe502601c69009fa0a + sha256: dab2cdfd0454e7ed386882823d684169308d832ee13934ea3007176be50a547e url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.4.0" async: dependency: transitive description: @@ -49,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" - chalkdart: - dependency: transitive - description: - name: chalkdart - sha256: "7dcf37e0b3d8dcec8c4ae0420d85aad9b3167d6a759601fd5b0f2b1958746581" - url: "https://pub.dev" - source: hosted - version: "3.1.0" characters: dependency: transitive description: @@ -69,10 +61,10 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: @@ -81,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + completion: + dependency: transitive + description: + name: completion + sha256: "82fa09800c0b71a2e8396bf3ca6b36b43cd35fe7ba2aeab53b6349ac671d0f5b" + url: "https://pub.dev" + source: hosted + version: "1.0.2" convert: dependency: transitive description: @@ -105,51 +105,67 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - dart_service_protocol_shared: + d4_array: dependency: transitive description: - name: dart_service_protocol_shared - sha256: "1737875c176d7e3d87bb3a359182828b542fe20a0b34198b8d31a81af5c7a76d" + name: d4_array + sha256: dac8e1edc9e26dab0433c0e005d8dc42e5bcc43d22e9add528a95eaf770176e9 url: "https://pub.dev" source: hosted - version: "0.0.3" - decimal: + version: "1.0.0" + d4_time: dependency: transitive description: - name: decimal - sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0 + name: d4_time + sha256: "30fb4deda3c5418cf4258a6881d22ed447a5baaef4f8bd6f5238d0c7d0e33a03" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "1.0.0" + d4_time_format: + dependency: transitive + description: + name: d4_time_format + sha256: "680ab4e327d68d177f052f18bb0787317ab5dffd761a15ae270481537377631a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + dart_service_protocol_shared: + dependency: transitive + description: + name: dart_service_protocol_shared + sha256: "1737875c176d7e3d87bb3a359182828b542fe20a0b34198b8d31a81af5c7a76d" + url: "https://pub.dev" + source: hosted + version: "0.0.3" devtools_profiler_core: dependency: "direct main" description: path: "../../.." relative: true source: path - version: "0.4.0" + version: "0.6.0" devtools_profiler_protocol: dependency: "direct overridden" description: path: "../../../../devtools_profiler_protocol" relative: true source: path - version: "0.1.0" + version: "0.3.0" devtools_region_profiler: dependency: "direct main" description: path: "../../../../devtools_region_profiler" relative: true source: path - version: "0.1.0" + version: "0.3.0" devtools_shared: dependency: transitive description: name: devtools_shared - sha256: "2daf7a9fba6a470668b26ecbd04200f7bf992aad81a2c31d12457c7791419dea" + sha256: "42f9b1dfcb58fb719ab52a8b8284662c8d82b0c79867dd150529234da90e2444" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "14.0.0" dtd: dependency: transitive description: @@ -190,14 +206,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - forge2d: - dependency: transitive - description: - name: forge2d - sha256: "0f63d177f2e137a5007b879fda4076d0b81de065fcd72056c6fc896c82758bb7" - url: "https://pub.dev" - source: hosted - version: "0.14.2+1" gato: dependency: transitive description: @@ -214,14 +222,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf" + url: "https://pub.dev" + source: hosted + version: "4.4.0" html: dependency: transitive description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.7" html_unescape: dependency: transitive description: @@ -250,18 +266,18 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.9.2" intl: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" json_rpc_2: dependency: transitive description: @@ -270,22 +286,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - json_schema_builder: + liquify: dependency: transitive description: - name: json_schema_builder - sha256: "65035d48d028401ad0ffc8c2f173209c7b1441e465a942a0f909070fae33170c" + name: liquify + sha256: "89228cfb5a269158d67253451b113ce4d110ce4e094955e95684bd89a99af0a7" url: "https://pub.dev" source: hosted - version: "0.1.3" - liquify: + version: "1.6.1" + listen: dependency: transitive description: - name: liquify - sha256: "23aaa728dd2adc15dcc863772dec64425e8bc35bfa3f81323e0726fb83e1e28f" + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.0.1" logging: dependency: transitive description: @@ -306,18 +322,26 @@ packages: dependency: transitive description: name: meta - sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "1.18.2" + version: "2.1.0" nanoid2: dependency: transitive description: name: nanoid2 - sha256: "35b5048f836652a1d711db0d716bdee59fcaaa4c37792db8b3568da4f7feb2f9" + sha256: "665594e1969ee90fc3b521c7ddda2194ae3ed8e2a7c946ac62727ec8469c1449" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" openapi_types: dependency: transitive description: @@ -334,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" petitparser: dependency: transitive description: @@ -346,34 +378,42 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" pool: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" - rational: + version: "6.5.2" + pure_svg: dependency: transitive description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + name: pure_svg + sha256: "0b440fbdd8487db70b247429328a2c8e35ef6c667d9390b61323c20bc3c67d16" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "0.2.0" + pure_ui: + dependency: transitive + description: + name: pure_ui + sha256: "7d1680b75de161e98f246124061034f7e77174e54adc1991a7caef8be76788c9" + url: "https://pub.dev" + source: hosted + version: "0.1.7" shelf: dependency: transitive description: @@ -402,10 +442,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -414,6 +454,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" string_scanner: dependency: transitive description: @@ -434,10 +482,10 @@ packages: dependency: transitive description: name: timezone - sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.11.1" typed_data: dependency: transitive description: @@ -450,34 +498,42 @@ packages: dependency: transitive description: name: ultraviolet - sha256: eddf41c6a43976d8d0de9059a789fc7b4013915fc658a2e596cb7cf56ffc0cd5 + sha256: c82ed967f3e976d0c95e3a141cd06c9318b099883e38350d4748171d3cbf0471 url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.1" unified_analytics: dependency: transitive description: name: unified_analytics - sha256: "406724e9231f8e30119673133c1087f9b24e2a75ba7111ea071253d57bb8f3b9" + sha256: "28bb11ef24567e720dc1397cba60df03cc5aded5bf6bef4d17b49934402c719b" url: "https://pub.dev" source: hosted - version: "8.0.14" + version: "8.0.18" vector_math: dependency: transitive description: name: vector_math - sha256: "47a1b32ee755c3fcffa33db52a7258c137f97bdb2209a1075be847809fac4ccf" + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "1.2.1" web: dependency: transitive description: @@ -522,10 +578,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_edit: dependency: transitive description: @@ -543,4 +599,4 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.yaml b/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.yaml index ff1cf50..b34a0a2 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.yaml +++ b/packages/devtools_profiler_core/test/fixtures/profiled_app/pubspec.yaml @@ -3,11 +3,11 @@ name: devtools_profiler_fixture_app publish_to: none environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' dependencies: - artisanal: ^0.3.0 - artisanal_widgets: ^0.2.0 + artisanal: '>=0.6.0 <1.0.0' + artisanal_widgets: '>=0.4.0 <1.0.0' devtools_profiler_core: path: ../../../../devtools_profiler_core devtools_region_profiler: diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/README.md b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/README.md new file mode 100644 index 0000000..4249a8b --- /dev/null +++ b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/README.md @@ -0,0 +1,109 @@ +# Flutter stress validation + +This is a real Linux desktop Flutter workload, not a mocked VM service or web +app. `lib/stress_main.dart` renders 240 animated grid cells, allocates and +processes JSON on the main isolate, runs two named persistent CPU workers, and +periodically creates short-lived worker isolates. + +Use debug mode for this recipe. Release/AOT and browser targets do not expose +the Dart VM CPU profiler. The profiler packages themselves remain pure Dart. + +## Repeated attach and worker exit + +From this fixture directory: + +```bash +flutter run -d linux --debug -t lib/stress_main.dart +``` + +On a headless Linux host with Xvfb installed, prefix that command with +`xvfb-run -a`. Copy the VM service URI from its output. From the repository root: + +```bash +dart run packages/devtools_profiler_core/tool/validate_flutter_stress.dart \ + http://127.0.0.1:PORT/TOKEN=/ \ + .dart_tool/flutter-stress-attach +``` + +The driver uses `ext.profilerFixture.start`, `status`, `retireWorker`, and +`stop` RPCs to: + +1. Start the UI/CPU workload. +2. Attach for an eight-second profiling window. +3. Retire a persistent worker four seconds after requesting attachment. +4. Check that samples retain main/worker isolate identities and OS thread ids. +5. Confirm the app is still running with one worker, then stop the workload. +6. Repeat with fresh workers and a fresh profiling session. + +Each window writes normal profiler artifacts plus `validation.json`, including +unknown/unsymbolized leaf counts, truncated samples, and the observed +isolate-to-thread sample counts. The timing-based retirement check can fail on +a severely overloaded host if attachment has not captured the worker before it +exits. That is a real observability limit, not a reason to fabricate samples. + +## CLI launch and all-isolate region + +From the repository root: + +```bash +dart run packages/devtools_profiler_cli/bin/devtools_profiler.dart run \ + --json \ + --duration 25s \ + --vm-service-timeout 3m \ + --artifact-dir "$PWD/.dart_tool/flutter-stress-launch" \ + --cwd packages/devtools_profiler_core/test/fixtures/profiled_flutter_app \ + -- flutter run -d linux --debug -t lib/stress_main.dart \ + --dart-define=STRESS_AUTOSTART=true \ + --dart-define=STRESS_DURATION_SECONDS=8 +``` + +This exercises DTD region wiring as well as CPU and memory capture. The workload +requests an all-isolate `flutter-stress` region and stops it before terminating +its workers. The duration limit triggers finalization before process termination; +outstanding region captures can make shutdown take longer than the limit. + +Do not reuse an auto-start binary built with another session's baked DTD +configuration for the attach recipe. Rebuild without the defines. + +## Observed results + +Validated locally with Flutter 3.47.1 / Dart 3.13.1, Linux x64, debug mode and +Impeller under Xvfb: + +| Scenario | Result | +| --- | --- | +| Two normal attach windows | 9,429 and 12,178 summarized samples; app stayed alive | +| Unknown leaf samples in those windows | 1 and 0; native addresses without symbols counted separately | +| Two worker-retirement windows | 9,703 and 9,260 summarized samples; exited worker samples retained | +| Final CLI launch | Exit 0; whole session 30,872 samples; marked region 24,509 samples | +| Launch provenance | Both profiles contained 3 sampled isolates and 4 OS thread ids | + +In the original first artifact, the old reader reported 5,112 unknown leaf +samples out of 9,921. Only five leaf samples were genuinely unnamed/unknown in +the stored raw data. The rest were mostly names lost while parsing untyped +native/stub/tag function objects. + +Removing unreferenced function entries reduced observed CPU artifacts from +about 64 MiB to 8–11 MiB in comparable runs. Workloads and sample counts differ, +so this is not an exact compression benchmark. + +## Interpretation limits + +- A Dart isolate is **not** an OS thread. The workers were observed migrating + between threads, and threads were reused by different isolates. +- `profilerIsolateId` is a profiler extension on each stored CPU sample; + `tid`, VM/user tags, and `truncated` retain their VM meanings. Use the core + artifact reader or `parseProfileCpuSamples` to preserve the extension. +- Legacy artifacts whose merge already erased `tid` cannot recover it. + Named native/stub/tag frames can be recovered when their raw names remain. +- Retention replaces cumulative snapshots rather than concatenating polls. + It is bounded by 64 isolates and two million stack entries. Eviction and + fallback to an earlier snapshot produce explicit warnings. +- Workers that live entirely between successful polls may still be missed; + the 180 ms transient workers are intentionally difficult cases. Retained + snapshots cannot recover the interval after an isolate's final successful poll. +- Truncated stacks and collected Dart functions remain incomplete. Native + module-plus-offset frames still need symbols for function-level attribution. +- These CPU samples are not a complete OS-thread trace or a raster/GPU profile. + This validation does not establish cross-isolate memory-diff accuracy when + workers exit; those cases retain the existing missing-isolate warnings. diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/screens/list_scroll_screen.dart b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/screens/list_scroll_screen.dart index 7ec7e93..3434f64 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/screens/list_scroll_screen.dart +++ b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/screens/list_scroll_screen.dart @@ -28,9 +28,8 @@ class ListScrollScreen extends StatelessWidget { ), trailing: const Icon(Icons.chevron_right), onTap: () { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Tapped $item'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Tapped $item'))); }, ); }, diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/stress_main.dart b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/stress_main.dart new file mode 100644 index 0000000..4cc8879 --- /dev/null +++ b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/lib/stress_main.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:devtools_region_profiler/devtools_region_profiler.dart'; +import 'package:flutter/material.dart'; + +/// Desktop workload controlled by buttons or ext.profilerFixture RPCs. +void main() { + runApp(const MaterialApp(home: StressScreen())); +} + +class StressScreen extends StatefulWidget { + const StressScreen({super.key}); + + @override + State createState() => _StressScreenState(); +} + +class _StressScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController animation = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + ); + final workers = []; + final replies = ReceivePort(); + final retained = >[]; + Timer? timer; + Timer? stopTimer; + ProfileRegionHandle? region; + var running = false; + var transitioning = false; + var ticks = 0; + var workerResults = 0; + var churnInFlight = false; + + @override + void initState() { + super.initState(); + replies.listen((_) => workerResults++); + for (final action in ['start', 'stop', 'status', 'retireWorker']) { + registerExtension('ext.profilerFixture.$action', (_, _) async { + if (action == 'start') await start(); + if (action == 'stop') await stop(); + if (action == 'retireWorker' && workers.isNotEmpty) { + workers.removeAt(0).kill(priority: Isolate.immediate); + } + return ServiceExtensionResponse.result( + jsonEncode({ + 'running': running, + 'ticks': ticks, + 'workers': workers.length, + 'workerResults': workerResults, + 'regionAvailable': region != null, + }), + ); + }); + } + if (const bool.fromEnvironment('STRESS_AUTOSTART')) { + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(start())); + } + } + + Future start() async { + if (running || transitioning) return; + transitioning = true; + try { + for (var i = 0; i < 2; i++) { + workers.add( + await Isolate.spawn( + persistentWorker, + replies.sendPort, + debugName: 'stress-worker-$i', + ), + ); + } + try { + region = await startProfileRegion( + 'flutter-stress', + options: const ProfileRegionOptions( + isolateScope: ProfileIsolateScope.all, + ), + ); + } on ProfileRegionConfigurationException { + // Direct launches exercise attach mode, without region wiring. + } + running = true; + animation.repeat(); + const seconds = int.fromEnvironment('STRESS_DURATION_SECONDS'); + if (seconds > 0) { + stopTimer = Timer(Duration(seconds: seconds), () => unawaited(stop())); + } + timer = Timer.periodic(const Duration(milliseconds: 100), (_) { + burnMainIsolate(); + retained.add( + List.from( + jsonDecode( + jsonEncode([ + for (var i = 0; i < 500; i++) + {'index': i, 'value': 'row-$ticks-$i'}, + ]), + ) as List, + ), + ); + if (retained.length > 12) retained.removeAt(0); + if (++ticks % 10 == 0 && !churnInFlight) { + churnInFlight = true; + unawaited( + Isolate.run(transientWorker, debugName: 'stress-transient').then(( + _, + ) { + workerResults++; + churnInFlight = false; + }), + ); + } + if (mounted) setState(() {}); + }); + if (mounted) setState(() {}); + } catch (_) { + for (final worker in workers) { + worker.kill(priority: Isolate.immediate); + } + workers.clear(); + rethrow; + } finally { + transitioning = false; + } + } + + Future stop() async { + if (!running || transitioning) return; + transitioning = true; + try { + timer?.cancel(); + stopTimer?.cancel(); + animation.stop(); + running = false; + await region?.stop(); + } finally { + region = null; + for (final worker in workers) { + worker.kill(priority: Isolate.immediate); + } + workers.clear(); + retained.clear(); + if (mounted) setState(() {}); + transitioning = false; + } + } + + @override + void dispose() { + timer?.cancel(); + stopTimer?.cancel(); + for (final worker in workers) { + worker.kill(priority: Isolate.immediate); + } + replies.close(); + animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Profiler stress validation')), + body: Column( + children: [ + Row( + children: [ + FilledButton(onPressed: start, child: const Text('Start workload')), + TextButton(onPressed: stop, child: const Text('Stop workload')), + Text('Ticks $ticks · Worker replies $workerResults'), + ], + ), + Expanded( + child: AnimatedBuilder( + animation: animation, + builder: (_, _) => GridView.builder( + itemCount: 240, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 12, + ), + itemBuilder: (_, index) => Transform.rotate( + angle: animation.value * math.pi * 2, + child: Container( + margin: const EdgeInsets.all(4), + color: Colors.primaries[index % Colors.primaries.length], + child: Center(child: Text('$index / $ticks')), + ), + ), + ), + ), + ), + ], + ), + ); +} + +@pragma('vm:never-inline') +void burnMainIsolate() => burnCpu(const Duration(milliseconds: 12)); + +@pragma('vm:never-inline') +void transientWorker() => burnCpu(const Duration(milliseconds: 180)); + +void persistentWorker(SendPort replies) { + Timer.periodic(const Duration(milliseconds: 80), (_) { + burnCpu(const Duration(milliseconds: 35)); + replies.send('done'); + }); +} + +@pragma('vm:never-inline') +void burnCpu(Duration duration) { + final watch = Stopwatch()..start(); + var value = 7; + while (watch.elapsed < duration) { + for (var i = 0; i < 20_000; i++) { + value = (value * 1_664_525 + i) & 0x7fffffff; + } + } + if (value == -1) throw StateError('unreachable'); +} diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.lock b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.lock index 7319a88..7bc5ac2 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.lock +++ b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: @@ -63,14 +63,14 @@ packages: path: "../../../../devtools_profiler_protocol" relative: true source: path - version: "0.1.0" + version: "0.3.0" devtools_region_profiler: dependency: "direct main" description: path: "../../../../devtools_region_profiler" relative: true source: path - version: "0.1.0" + version: "0.3.0" dtd: dependency: transitive description: @@ -128,10 +128,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" path: dependency: transitive description: @@ -144,10 +144,10 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" sky_engine: dependency: transitive description: flutter @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -205,18 +205,18 @@ packages: dependency: transitive description: name: unified_analytics - sha256: "0988c50d794f3cd96f6df92b4b35b7c333bbf869df304fc6062a45ebc7eb0776" + sha256: "28bb11ef24567e720dc1397cba60df03cc5aded5bf6bef4d17b49934402c719b" url: "https://pub.dev" source: hosted - version: "8.0.15" + version: "8.0.18" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" web: dependency: transitive description: @@ -242,4 +242,4 @@ packages: source: hosted version: "3.0.3" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.yaml b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.yaml index e4a41a8..d6c5a29 100644 --- a/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.yaml +++ b/packages/devtools_profiler_core/test/fixtures/profiled_flutter_app/pubspec.yaml @@ -2,7 +2,7 @@ name: devtools_profiler_flutter_fixture publish_to: none environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' dependencies: flutter: diff --git a/packages/devtools_profiler_core/test/fixtures/sync_region_diagnostics.dart b/packages/devtools_profiler_core/test/fixtures/sync_region_diagnostics.dart new file mode 100644 index 0000000..abee5c5 --- /dev/null +++ b/packages/devtools_profiler_core/test/fixtures/sync_region_diagnostics.dart @@ -0,0 +1,19 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:devtools_region_profiler/devtools_region_profiler.dart'; + +Future main() async { + final input = StreamIterator(stdin); + try { + // The parent subscribes to VM Logging before allowing the region to run. + await input.moveNext(); + profileRegionSync('diagnostic-test', () => 42); + // Keep the VM alive until the parent has received the expected diagnostic. + await input.moveNext(); + // Allow any duplicate fire-and-forget diagnostics to reach the VM stream. + await Future.delayed(const Duration(milliseconds: 100)); + } finally { + await input.cancel(); + } +} diff --git a/packages/devtools_profiler_core/test/flutter_frame_analysis_test.dart b/packages/devtools_profiler_core/test/flutter_frame_analysis_test.dart index dee2924..62a70a8 100644 --- a/packages/devtools_profiler_core/test/flutter_frame_analysis_test.dart +++ b/packages/devtools_profiler_core/test/flutter_frame_analysis_test.dart @@ -83,9 +83,9 @@ void main() { 'data', 'timeline_response.json', ); - final json = - jsonDecode(File(fixturePath).readAsStringSync()) - as Map; + final json = jsonDecode( + File(fixturePath).readAsStringSync(), + ) as Map; final traceEvents = (json['traceEvents'] as List) .cast>(); diff --git a/packages/devtools_profiler_core/test/flutter_memory_snapshot_test.dart b/packages/devtools_profiler_core/test/flutter_memory_snapshot_test.dart index 4aecb75..ad1fc3e 100644 --- a/packages/devtools_profiler_core/test/flutter_memory_snapshot_test.dart +++ b/packages/devtools_profiler_core/test/flutter_memory_snapshot_test.dart @@ -44,9 +44,9 @@ void main() { 'data', 'allocation_profile_response.json', ); - final json = - jsonDecode(File(fixturePath).readAsStringSync()) - as Map; + final json = jsonDecode( + File(fixturePath).readAsStringSync(), + ) as Map; final members = json['members'] as List; final firstMember = members.first as Map; diff --git a/packages/devtools_profiler_core/test/flutter_widget_tree_test.dart b/packages/devtools_profiler_core/test/flutter_widget_tree_test.dart index b6a941f..30ccfb7 100644 --- a/packages/devtools_profiler_core/test/flutter_widget_tree_test.dart +++ b/packages/devtools_profiler_core/test/flutter_widget_tree_test.dart @@ -14,9 +14,9 @@ void main() { 'data', 'widget_tree_response.json', ); - final json = - jsonDecode(File(fixturePath).readAsStringSync()) - as Map; + final json = jsonDecode( + File(fixturePath).readAsStringSync(), + ) as Map; expect(json['name'], 'MyApp'); @@ -67,9 +67,8 @@ void main() { ], }); - final capture = await WidgetTreeCaptureService( - vmService: vmService, - ).captureWidgetTree(isolateId: 'isolate', projectOnly: true); + final capture = await WidgetTreeCaptureService(vmService: vmService) + .captureWidgetTree(isolateId: 'isolate', projectOnly: true); expect(capture.root.children, hasLength(1)); final projectWidget = capture.root.children.single; diff --git a/packages/devtools_profiler_core/test/interrupt_finalization_test.dart b/packages/devtools_profiler_core/test/interrupt_finalization_test.dart new file mode 100644 index 0000000..7ae131d --- /dev/null +++ b/packages/devtools_profiler_core/test/interrupt_finalization_test.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:devtools_profiler_core/src/capture/runner/interrupt_finalization.dart'; +import 'package:test/test.dart'; + +void main() { + test('waits for slow finalization after the warning threshold', () async { + final finalization = Completer(); + var timedOut = false; + var completed = false; + + final waiting = awaitInterruptedFinalization( + finalization: finalization.future, + warningTimeout: const Duration(milliseconds: 1), + onTimeout: () => timedOut = true, + ).then((_) => completed = true); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(timedOut, isTrue); + expect(completed, isFalse); + + finalization.complete(); + await waiting; + expect(completed, isTrue); + }); + + test('propagates finalization errors', () async { + final error = StateError('capture failed'); + + await expectLater( + awaitInterruptedFinalization( + finalization: Future.error(error), + warningTimeout: const Duration(seconds: 1), + onTimeout: () {}, + ), + throwsA(same(error)), + ); + }); +} diff --git a/packages/devtools_profiler_core/test/method_table_test.dart b/packages/devtools_profiler_core/test/method_table_test.dart index 479d78d..689fea8 100644 --- a/packages/devtools_profiler_core/test/method_table_test.dart +++ b/packages/devtools_profiler_core/test/method_table_test.dart @@ -3,6 +3,51 @@ import 'package:test/test.dart'; import 'package:vm_service/vm_service.dart'; void main() { + test('recursive totals stay independent across sibling and root paths', () { + final samples = CpuSamples( + samplePeriod: 50, + functions: [ + for (final name in ['a', 'b', 'c']) + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: 'functions/$name', name: name), + ), + ], + samples: [ + CpuSample(timestamp: 100, stack: [0, 1, 0, 0]), + CpuSample(timestamp: 150, stack: [0, 2, 0]), + CpuSample(timestamp: 200, stack: [0, 1]), + CpuSample(timestamp: 250, stack: [1, 0, 1]), + ], + ); + + for (final includeFrame in [ + null, + (frame) => frame.name != 'c', + ]) { + final table = buildMethodTable( + cpuSamples: samples, + includeFrame: includeFrame, + ); + final a = table.methods.singleWhere((method) => method.name == 'a'); + final b = table.methods.singleWhere((method) => method.name == 'b'); + expect(table.sampleCount, 4); + expect(a.totalSamples, 4); + expect(a.selfSamples, 3); + expect(a.totalMicros, 200); + expect(b.totalSamples, 3); + expect(b.selfSamples, 1); + expect( + a.callees.singleWhere((relation) => relation.name == 'b').sampleCount, + 2, + ); + expect( + b.callees.singleWhere((relation) => relation.name == 'a').sampleCount, + 3, + ); + } + }); + test('buildMethodTable computes self totals and caller/callee edges', () { final workerClass = ClassRef(id: 'classes/worker', name: 'Worker'); final functions = [ diff --git a/packages/devtools_profiler_core/test/profile_frame_alignment_test.dart b/packages/devtools_profiler_core/test/profile_frame_alignment_test.dart new file mode 100644 index 0000000..3bbf69d --- /dev/null +++ b/packages/devtools_profiler_core/test/profile_frame_alignment_test.dart @@ -0,0 +1,80 @@ +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('limits aligned rows and treats zero as unlimited', () { + final columns = [ + ProfileFrameColumn( + label: 'a', + frames: [_frame('package:a/a.dart'), _frame('package:b/b.dart')], + ), + ProfileFrameColumn(label: 'b', frames: [_frame('package:b/b.dart')]), + ]; + final all = alignProfileFrames(columns); + expect(alignProfileFrames(columns, limit: 0), hasLength(2)); + final limited = alignProfileFrames(columns, limit: 1); + expect(limited, hasLength(1)); + expect(limited.single.toJson(), all.first.toJson()); + }); + + test('separates same-name functions by kind and exact location', () { + final rows = alignProfileFrames([ + ProfileFrameColumn( + label: 'a', + frames: [ + _frame('package:a/a.dart'), + _frame('package:b/b.dart'), + _frame('package:a/a.dart', kind: 'Native'), + ], + ), + ProfileFrameColumn(label: 'b', frames: [_frame('package:b/b.dart')]), + ]); + expect(rows, hasLength(3)); + expect( + rows.where((row) => row.frames[1] != null).single.location, + 'package:b/b.dart', + ); + expect(rows.where((row) => row.frames[1] == null), hasLength(2)); + expect(rows.first.toJson()['frames'], contains(null)); + }); + + test('does not guess checkout equivalence or absence as zero', () { + final rows = alignProfileFrames([ + ProfileFrameColumn(label: 'a', frames: [_frame('file:///a/lib/a.dart')]), + ProfileFrameColumn(label: 'b', frames: [_frame('file:///b/lib/a.dart')]), + ]); + expect(rows, hasLength(2)); + expect(rows[0].frames[1], isNull); + expect(rows[1].frames[0], isNull); + }); + + test('empty and duplicate inputs have explicit semantics', () { + expect(alignProfileFrames([]), isEmpty); + expect( + alignProfileFrames([ + const ProfileFrameColumn(label: 'empty', frames: []), + ]), + isEmpty, + ); + expect( + () => alignProfileFrames([ + ProfileFrameColumn( + label: 'duplicate', + frames: [_frame(null), _frame(null)], + ), + ]), + throwsArgumentError, + ); + }); +} + +ProfileFrameSummary _frame(String? location, {String kind = 'Dart'}) => + ProfileFrameSummary( + name: 'work', + kind: kind, + location: location, + selfSamples: 10, + totalSamples: 10, + selfPercent: 0.1, + totalPercent: 0.1, + ); diff --git a/packages/devtools_profiler_core/test/profile_frames_test.dart b/packages/devtools_profiler_core/test/profile_frames_test.dart index 7703ad4..61619d9 100644 --- a/packages/devtools_profiler_core/test/profile_frames_test.dart +++ b/packages/devtools_profiler_core/test/profile_frames_test.dart @@ -1,8 +1,43 @@ 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() { + test('resolver reuses metadata across stacks without caching predicates', () { + final resolver = ProfileFrameResolver([ + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: 'functions/work', name: 'work'), + ), + ]); + var predicateCalls = 0; + bool includeFrame(ProfileFrame frame) => ++predicateCalls != 2; + + final first = resolver.filterStack([0, 0], includeFrame: includeFrame); + final second = resolver.filterStack([0], includeFrame: includeFrame); + + expect(predicateCalls, 3); + expect(first, hasLength(1)); + expect(identical(first.single, second.single), isTrue); + expect(resolver.filterStack([0, 0]), hasLength(2)); + expect(resolver.filterStack([]), isEmpty); + expect(identical(resolver.resolve(-10), resolver.resolve(100)), isTrue); + expect(resolver.resolve(100).name, 'unknown'); + }); + + test('resolvers isolate metadata from different profile function tables', () { + ProfileFrameResolver resolverFor(String name) => ProfileFrameResolver([ + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: 'functions/0', name: name), + ), + ]); + + expect(resolverFor('first').resolve(0).name, 'first'); + expect(resolverFor('second').resolve(0).name, 'second'); + }); + test('packageName resolves package URIs', () { const frame = ProfileFrame( name: 'Value.toString', diff --git a/packages/devtools_profiler_core/test/profile_runner_test.dart b/packages/devtools_profiler_core/test/profile_runner_test.dart index b225b25..1cc1b2d 100644 --- a/packages/devtools_profiler_core/test/profile_runner_test.dart +++ b/packages/devtools_profiler_core/test/profile_runner_test.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:devtools_profiler_core/src/capture/runner/dart_executable.dart' + as dart_executable; import 'package:devtools_profiler_core/src/capture/runner/process_launch.dart' as launch; import 'package:path/path.dart' as path; @@ -174,6 +176,38 @@ void main() { }, ); + test('uses dart from PATH when the profiler is an AOT executable', () { + expect( + dart_executable.resolveDartExecutable( + resolvedExecutable: '/tmp/devtools-profiler', + environment: const {}, + ), + 'dart', + ); + }); + + test('keeps an actual Dart VM executable', () { + expect( + dart_executable.resolveDartExecutable( + resolvedExecutable: '/opt/dart-sdk/bin/dart', + environment: const {}, + ), + '/opt/dart-sdk/bin/dart', + ); + }); + + test('allows overriding the Dart executable for helper processes', () { + expect( + dart_executable.resolveDartExecutable( + resolvedExecutable: '/tmp/devtools-profiler', + environment: const { + 'DEVTOOLS_PROFILER_DART_EXECUTABLE': '/opt/dart/bin/dart', + }, + ), + '/opt/dart/bin/dart', + ); + }); + test( 'builds inherited-stdio Flutter run with a deterministic service URI', () { @@ -360,7 +394,8 @@ sleep 5 expect(result.overallProfile, isNotNull); expect(result.overallProfile!.succeeded, isTrue); expect(result.overallProfile!.sampleCount, greaterThan(0)); - }); + // Cold compilation of the terminal widget stack can exceed 30 seconds. + }, timeout: const Timeout(Duration(minutes: 2))); test('returns available diagnostics when interrupted', () async { if (Platform.isWindows) { @@ -399,6 +434,10 @@ sleep 5 isA().having((count) => count, 'count', greaterThan(0)), ); expect(File(payload['sessionJson']! as String).existsSync(), isTrue); + final session = await ProfileArtifacts.readSession( + payload['artifactDirectory']! as String, + ); + expect(session.overallProfile, isNotNull); }); test('waits for worker isolates before finalizing a Dart run', () async { @@ -803,7 +842,7 @@ echo "The Dart VM service is listening on http://127.0.0.1:1/" failed = true; } expect(failed, isTrue); - return argumentsFile.readAsLines(); + return await argumentsFile.readAsLines(); } finally { await tempDirectory.delete(recursive: true); } diff --git a/packages/devtools_profiler_core/test/region_finalization_test.dart b/packages/devtools_profiler_core/test/region_finalization_test.dart new file mode 100644 index 0000000..a8671be --- /dev/null +++ b/packages/devtools_profiler_core/test/region_finalization_test.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:devtools_profiler_core/src/capture/runner/capture_state.dart'; +import 'package:devtools_profiler_core/src/capture/runner/profile_session_controller.dart'; +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + test( + 'rejects non-object region metadata before waiting for VM readiness', + () async { + final directory = await Directory.systemTemp.createTemp( + 'region_metadata.', + ); + addTearDown(() => directory.delete(recursive: true)); + final controller = ProfileSessionController( + artifactStore: ProfileArtifactStore(directory), + childProcessId: null, + dtd: null, + sessionId: 'session', + ); + await expectLater( + controller.handleStartRegion( + Parameters('startRegion', { + 'extra': ['invalid'], + }), + ), + throwsA(isA()), + ); + expect(controller.context.activeRegions, isEmpty); + }, + ); + + for (final fail in [false, true]) { + test( + 'finalization waits for an in-flight region stop (failure=$fail)', + () async { + final directory = await Directory.systemTemp.createTemp( + 'region_finalize.', + ); + addTearDown(() => directory.delete(recursive: true)); + final service = _DelayedCpuService(); + addTearDown(service.dispose); + final controller = ProfileSessionController( + artifactStore: ProfileArtifactStore(directory), + childProcessId: null, + dtd: null, + sessionId: 'session', + ); + final context = controller.context; + context.vmService = service; + context.vmServiceReady.complete(); + context.overallProfileReady.complete(); + context.activeRegions['region'] = const ActiveProfileRegion( + attributes: {}, + isolateId: 'main', + memoryStartSnapshot: null, + name: 'work', + options: ProfileRegionOptions(captureKinds: [ProfileCaptureKind.cpu]), + parentRegionId: null, + regionId: 'region', + startTimestampMicros: 10, + ); + final stop = controller.handleStopRegion( + Parameters('stopRegion', { + 'sessionId': 'session', + 'regionId': 'region', + 'isolateId': 'main', + 'timestampMicros': 20, + }), + ); + // Install the error expectation before completing the delayed RPC. + final stopChecked = fail + ? expectLater(stop, throwsA(isA())) + : stop; + await service.requested.future; + expect(context.activeRegions, isEmpty); + var finalized = false; + final finish = controller.handleProcessExit().then( + (_) => finalized = true, + ); + await Future.delayed(Duration.zero); + expect(finalized, isFalse); + if (fail) { + service.samples.completeError(StateError('fixture failure')); + } else { + service.samples.complete( + CpuSamples( + samplePeriod: 1000, + functions: [ + ProfileFunction( + kind: 'Native', + function: NativeFunction(name: 'work'), + ), + ], + samples: [ + CpuSample(tid: 7, timestamp: 15, stack: [0]), + ], + ), + ); + } + await stopChecked; + await finish; + await controller.handleProcessExit(); + expect(context.regions, hasLength(1)); + expect(context.regions.single.succeeded, !fail); + expect(context.regions.single.sampleCount, fail ? 0 : 1); + if (!fail) { + context.latestOverallSnapshot = CpuCaptureSnapshot( + cpuSamples: CpuSamples(sampleCount: 0, functions: [], samples: []), + isolateIds: const ['main'], + ); + await controller.snapshotCapture.captureOverallProfile(); + expect( + context.overallProfile!.sampleCount, + 1, + reason: 'Final capture must not use a poll older than the region', + ); + } + }, + ); + } +} + +class _DelayedCpuService extends VmService { + _DelayedCpuService() : super(const Stream.empty(), (_) {}); + + final requested = Completer(); + final samples = Completer(); + + @override + Future getVM() async => VM( + isolates: [IsolateRef(id: 'main', name: 'main')], + ); + + @override + Future getCpuSamples(String isolateId, int origin, int extent) { + if (!requested.isCompleted) requested.complete(); + return samples.future; + } +} diff --git a/packages/devtools_profiler_core/test/shared_cpu_views_test.dart b/packages/devtools_profiler_core/test/shared_cpu_views_test.dart new file mode 100644 index 0000000..8796592 --- /dev/null +++ b/packages/devtools_profiler_core/test/shared_cpu_views_test.dart @@ -0,0 +1,122 @@ +import 'dart:math'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + test('shared views preserve per-sample totals, self counts and edges', () { + final random = Random(42); + final functions = [ + for (var i = 0; i < 6; i++) + ProfileFunction( + kind: 'Dart', + function: FuncRef(id: 'functions/$i', name: 'method$i'), + ), + ]; + final samples = CpuSamples( + samplePeriod: 50, + // Deliberately unreliable VM count: filtered nonempty stacks determine it. + sampleCount: 1000, + functions: functions, + samples: [ + for (var i = 0; i < 100; i++) + CpuSample( + timestamp: i * 50, + stack: [ + for (var j = 0, depth = random.nextInt(8); j < depth; j++) + random.nextInt(8) - 1, + ], + ), + ], + ); + + for (final includeFrame in [ + null, + (frame) => frame.name != 'method2', + (_) => false, + ]) { + final tree = buildCallTree( + cpuSamples: samples, + includeFrame: includeFrame, + ); + final before = tree.toJson(); + final table = buildMethodTableFromCallTree(tree); + final bottomUp = buildBottomUpTreeFromCallTree(tree); + final totals = {}; + final self = {}; + final edges = <(String, String), int>{}; + var count = 0; + for (final sample in samples.samples!) { + final frames = filterStackFrames( + sample.stack!, + functions, + includeFrame: includeFrame, + ); + if (frames.isEmpty) continue; + count++; + self.update(frames.first.key, (value) => value + 1, ifAbsent: () => 1); + for (final key in frames.map((frame) => frame.key).toSet()) { + totals.update(key, (value) => value + 1, ifAbsent: () => 1); + } + for (var i = 1; i < frames.length; i++) { + edges.update( + (frames[i].key, frames[i - 1].key), + (value) => value + 1, + ifAbsent: () => 1, + ); + } + } + expect(table.sampleCount, count); + expect(bottomUp.sampleCount, count); + expect(table.methods, hasLength(totals.length)); + for (final method in table.methods) { + expect(method.totalSamples, totals[method.methodId]); + expect(method.selfSamples, self[method.methodId] ?? 0); + expect(method.totalMicros, method.totalSamples * 50); + expect( + {for (final edge in method.callees) edge.methodId: edge.sampleCount}, + { + for (final edge in edges.entries) + if (edge.key.$1 == method.methodId) edge.key.$2: edge.value, + }, + ); + expect( + {for (final edge in method.callers) edge.methodId: edge.sampleCount}, + { + for (final edge in edges.entries) + if (edge.key.$2 == method.methodId) edge.key.$1: edge.value, + }, + ); + } + expect( + buildMethodTable( + cpuSamples: samples, + includeFrame: includeFrame, + ).toJson(), + table.toJson(), + ); + expect( + buildBottomUpTree( + cpuSamples: samples, + includeFrame: includeFrame, + ).toJson(), + bottomUp.toJson(), + ); + expect( + tree.toJson(), + before, + reason: 'Derived views must not mutate input', + ); + } + }); + + test('derived views handle missing data and reject bottom-up input', () { + final tree = buildCallTree(cpuSamples: CpuSamples()); + final bottomUp = buildBottomUpTreeFromCallTree(tree); + expect(bottomUp.root.children, isEmpty); + expect(buildMethodTableFromCallTree(tree).methods, isEmpty); + expect(() => buildBottomUpTreeFromCallTree(bottomUp), throwsArgumentError); + expect(() => buildMethodTableFromCallTree(bottomUp), throwsArgumentError); + }); +} diff --git a/packages/devtools_profiler_core/test/sync_region_diagnostics_test.dart b/packages/devtools_profiler_core/test/sync_region_diagnostics_test.dart new file mode 100644 index 0000000..8480877 --- /dev/null +++ b/packages/devtools_profiler_core/test/sync_region_diagnostics_test.dart @@ -0,0 +1,111 @@ +@Timeout(Duration(minutes: 2)) +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:vm_service/vm_service_io.dart'; + +void main() { + for (final failedOperation in ['start', 'stop']) { + test( + 'logs only the failing synchronous $failedOperation operation', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final sockets = []; + addTearDown(() async { + for (final socket in sockets) { + await socket.close(); + } + }); + final methods = []; + server.listen((request) async { + final socket = await WebSocketTransformer.upgrade(request); + sockets.add(socket); + socket.listen((message) { + final rpc = jsonDecode(message as String) as Map; + final method = rpc['method'] as String; + methods.add(method); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + if (method == 'DevToolsProfiler.${failedOperation}Region') + 'error': { + 'code': -32602, + 'message': 'Rejected $failedOperation', + } + else + 'result': {'type': 'Success', 'sessionId': 'session'}, + }), + ); + }); + }); + final root = Directory.current.path.endsWith('devtools_profiler_core') + ? Directory.current + : Directory('packages/devtools_profiler_core'); + final child = await Process.start( + Platform.resolvedExecutable, + [ + '--enable-vm-service=0', + '--disable-service-auth-codes', + '${root.absolute.path}/test/fixtures/sync_region_diagnostics.dart', + ], + environment: { + 'DEVTOOLS_PROFILER_DTD_URI': 'ws://127.0.0.1:${server.port}', + 'DEVTOOLS_PROFILER_SESSION_ID': 'session', + }, + ); + addTearDown(child.kill); + final uriReady = Completer(); + final stdoutDone = child.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((line) { + final uri = RegExp(r'http://127\.0\.0\.1:\d+/').firstMatch(line); + if (uri != null && !uriReady.isCompleted) { + uriReady.complete(uri.group(0)); + } + }); + final stderrText = child.stderr.transform(utf8.decoder).join(); + final uri = await uriReady.future.timeout(const Duration(seconds: 45)); + final service = await vmServiceConnectUri( + '${uri.replaceFirst('http:', 'ws:')}ws', + ); + addTearDown(service.dispose); + final logs = []; + final firstLog = Completer(); + final subscription = service.onLoggingEvent.listen((event) { + if (event.logRecord?.loggerName?.valueAsString != + 'devtools_region_profiler') { + return; + } + logs.add(event.logRecord!.message!.valueAsString!); + if (!firstLog.isCompleted) firstLog.complete(); + }); + addTearDown(subscription.cancel); + await service.streamListen(EventStreams.kLogging); + child.stdin.writeln('run'); + await firstLog.future.timeout(const Duration(seconds: 45)); + child.stdin.writeln('finish'); + expect(await child.exitCode.timeout(const Duration(seconds: 10)), 0); + await stdoutDone; + expect(await stderrText, isEmpty); + expect(logs, hasLength(1), reason: logs.join('\n')); + expect( + logs.single, + startsWith('Failed to $failedOperation profiling region'), + ); + expect(methods, [ + 'DevToolsProfiler.getSessionInfo', + 'DevToolsProfiler.startRegion', + if (failedOperation == 'stop') 'DevToolsProfiler.stopRegion', + ]); + }, + ); + } +} diff --git a/packages/devtools_profiler_core/tool/validate_flutter_stress.dart b/packages/devtools_profiler_core/tool/validate_flutter_stress.dart new file mode 100644 index 0000000..2281e35 --- /dev/null +++ b/packages/devtools_profiler_core/tool/validate_flutter_stress.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:devtools_profiler_core/devtools_profiler_core.dart'; +import 'package:vm_service/utils.dart'; +import 'package:vm_service/vm_service_io.dart'; + +/// Drives the stress fixture through VM RPCs and validates repeatable attaches. +Future main(List arguments) async { + if (arguments.length != 2) { + stderr.writeln( + 'Usage: validate_flutter_stress.dart ', + ); + exitCode = 64; + return; + } + final uri = Uri.parse(arguments[0]); + final service = await vmServiceConnectUri( + convertToWebSocketUrl(serviceProtocolUrl: uri).toString(), + ); + String? mainId; + Timer? retirementTimer; + try { + for (final isolate in (await service.getVM()).isolates!) { + final details = await service.getIsolate(isolate.id!); + if (details.extensionRPCs?.contains('ext.profilerFixture.start') ?? + false) { + mainId = isolate.id; + break; + } + } + if (mainId == null) throw StateError('Stress fixture RPCs not available.'); + for (var window = 0; window < 2; window++) { + await service.callServiceExtension( + 'ext.profilerFixture.start', + isolateId: mainId, + ); + // An observed worker exits during capture; its samples must survive. + final retired = Completer(); + Object? retirementError; + retirementTimer = Timer(const Duration(seconds: 4), () async { + try { + await service.callServiceExtension( + 'ext.profilerFixture.retireWorker', + isolateId: mainId, + ); + } catch (error) { + retirementError = error; + } finally { + retired.complete(); + } + }); + final result = await ProfileRunner().attach( + ProfileAttachRequest( + vmServiceUri: uri, + duration: const Duration(seconds: 8), + artifactDirectory: '${arguments[1]}/window-$window', + ), + ); + await retired.future; + if (retirementError != null) { + throw StateError('Worker retirement failed: $retirementError'); + } + final profile = result.overallProfile; + if (profile == null || !profile.succeeded || profile.sampleCount == 0) { + throw StateError('Attach failed: ${result.warnings}'); + } + final samples = await ProfileRunner().readCpuSamples( + profile.rawProfilePath!, + ); + final functions = samples.functions!; + final resolver = ProfileFrameResolver(functions); + final unknownFunctions = >[]; + var unknownSelf = 0; + var unresolvedNativeSelf = 0; + var truncated = 0; + final byIsolate = >{}; + for (var index = 0; index < functions.length; index++) { + final frame = profileFrameFromFunction(functions, index); + if (isUnknown(frame.name)) { + unknownFunctions.add({ + 'index': index, + 'runtimeType': functions[index].function.runtimeType.toString(), + 'function': functions[index].toJson(), + }); + } + } + for (final sample in samples.samples!) { + if (sample.truncated ?? false) truncated++; + if (sample is ProfileCpuSample && sample.isolateId != null) { + final threads = byIsolate.putIfAbsent(sample.isolateId!, () => {}); + threads.update( + sample.tid ?? -1, + (count) => count + 1, + ifAbsent: () => 1, + ); + } + final stack = sample.stack; + if (stack == null || stack.isEmpty) continue; + final frame = resolver.resolve(stack.first); + if (isUnknown(frame.name)) { + unknownSelf++; + } + if (frame.name.startsWith('[Native] ') && frame.name.contains('+0x')) { + unresolvedNativeSelf++; + } + } + final status = await service.callServiceExtension( + 'ext.profilerFixture.status', + isolateId: mainId, + ); + final report = { + 'window': window, + 'sampleCount': profile.sampleCount, + 'isolateIds': profile.isolateIds, + 'threadIds': samples.samples! + .map((sample) => sample.tid) + .toSet() + .toList(), + 'unknownSelfSamples': unknownSelf, + 'unresolvedNativeSelfSamples': unresolvedNativeSelf, + 'truncatedSamples': truncated, + 'isolateThreads': { + for (final entry in byIsolate.entries) + entry.key: { + for (final thread in entry.value.entries) + thread.key.toString(): thread.value, + }, + }, + 'unknownFunctions': unknownFunctions, + 'topSelf': profile.topSelfFrames + .map((frame) => frame.toJson()) + .toList(), + 'warnings': result.warnings, + 'status': status.json, + }; + await File('${arguments[1]}/window-$window/validation.json') + .writeAsString(const JsonEncoder.withIndent(' ').convert(report)); + stdout.writeln( + jsonEncode({ + ...report, + 'unknownFunctions': unknownFunctions.length, + 'topSelf': profile.topSelfFrames + .take(3) + .map( + (frame) => { + 'name': frame.name, + 'selfSamples': frame.selfSamples, + }, + ) + .toList(), + }), + ); + if (profile.isolateIds.length < 3) { + throw StateError('Expected main and both persistent workers.'); + } + if (byIsolate.length < 3 || + byIsolate.values.any((threads) => threads.containsKey(-1))) { + throw StateError( + 'Missing isolate/thread provenance in captured samples.', + ); + } + if (status.json?['workers'] != 1) { + throw StateError('Worker retirement did not execute during capture.'); + } + await service.callServiceExtension( + 'ext.profilerFixture.stop', + isolateId: mainId, + ); + } + } finally { + retirementTimer?.cancel(); + try { + if (mainId != null) { + await service.callServiceExtension( + 'ext.profilerFixture.stop', + isolateId: mainId, + ); + } + } catch (error) { + stderr.writeln('Stress fixture cleanup failed: $error'); + } finally { + try { + await service.dispose(); + } catch (error) { + stderr.writeln('VM service cleanup failed: $error'); + } + } + } +} + +bool isUnknown(String name) => + name.isEmpty || name == 'unknown' || name.startsWith(' json) { final captureKinds = switch (json['captureKinds']) { @@ -103,6 +105,10 @@ class ProfileRegionOptions { captureKinds: normalizeProfileCaptureKinds(captureKinds), isolateScope: isolateScope, parentRegionId: json['parentRegionId'] as String?, + extra: switch (json['extra']) { + final Map m => m, + _ => {}, + }, ); } @@ -118,6 +124,17 @@ class ProfileRegionOptions { /// an inherited parent automatically. final String? parentRegionId; + /// Extra tool-specific metadata for this region. + /// + /// Tools like `lualike` can attach arbitrary key-value data here (e.g. + /// `{'luaFile': 'calls.lua', 'luaFunction': 'runBenchmark'}`). This data + /// is preserved in the session artifact and displayed in region summaries, + /// making it searchable and reproducible across profiling sessions. + /// + /// Defaults to an empty map. Values should be JSON-serializable types + /// (String, int, double, bool, null, List, Map). + final Map extra; + /// Whether CPU capture is requested. bool get capturesCpu => captureKinds.contains(ProfileCaptureKind.cpu); @@ -133,6 +150,7 @@ class ProfileRegionOptions { ProfileIsolateScope? isolateScope, String? parentRegionId, bool clearParentRegionId = false, + Map? extra, }) { return ProfileRegionOptions( captureKinds: captureKinds ?? this.captureKinds, @@ -140,6 +158,7 @@ class ProfileRegionOptions { parentRegionId: clearParentRegionId ? null : parentRegionId ?? this.parentRegionId, + extra: extra ?? this.extra, ); } @@ -148,6 +167,7 @@ class ProfileRegionOptions { 'captureKinds': [for (final kind in captureKinds) kind.name], 'isolateScope': isolateScope.name, 'parentRegionId': parentRegionId, + if (extra.isNotEmpty) 'extra': Map.from(extra), }; } diff --git a/packages/devtools_profiler_protocol/pubspec.yaml b/packages/devtools_profiler_protocol/pubspec.yaml index b7e0da5..8c66d94 100644 --- a/packages/devtools_profiler_protocol/pubspec.yaml +++ b/packages/devtools_profiler_protocol/pubspec.yaml @@ -2,14 +2,14 @@ name: devtools_profiler_protocol description: Shared protocol models for the pure-Dart DevTools profiler packages. -version: 0.1.0 +version: 0.3.0 environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' resolution: workspace repository: https://github.com/kingwill101/devtools-profiler/tree/main/packages/devtools_profiler_protocol dev_dependencies: - test: ^1.25.8 + test: ^1.32.0 diff --git a/packages/devtools_region_profiler/CHANGELOG.md b/packages/devtools_region_profiler/CHANGELOG.md index 7a7449c..4491573 100644 --- a/packages/devtools_region_profiler/CHANGELOG.md +++ b/packages/devtools_region_profiler/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.3.0 + +- Reported synchronous start, stop, and cleanup failures at their own operation, + without relabeling failed starts as failed stops or masking a stop failure + with a cleanup failure. +- Required Dart 3.13 or later and refreshed test dependencies. Flutter targets + using this helper need a Flutter SDK that includes Dart 3.13 or later. + +## 0.2.0 + +- Added `profileRegionSync()` — synchronous overload of `profileRegion` that + accepts `T Function()` instead of `Future Function()`. DTD start/stop + events are sent asynchronously (fire-and-forget) so synchronous code is not + blocked by profiler transport. Timestamps are captured at the call site. +- Added `startProfileRegionSync()` — synchronous variant of `startProfileRegion` + that returns a `ProfileRegionHandle` immediately. + ## 0.1.0 - Initial release of the app-side region profiling helper. diff --git a/packages/devtools_region_profiler/README.md b/packages/devtools_region_profiler/README.md index 8aaecf8..b994691 100644 --- a/packages/devtools_region_profiler/README.md +++ b/packages/devtools_region_profiler/README.md @@ -16,6 +16,11 @@ The package exports: - `profileRegion()`: wraps one async closure and stops the region automatically - `startProfileRegion()`: starts a region manually and returns a stop handle +- `profileRegionSync()`: synchronous variant — DTD events are sent + fire-and-forget so synchronous code is not blocked by profiler transport. + Use this for tight loops or dispatch functions that don't use `async`/`await`. +- `startProfileRegionSync()`: synchronous manual start, returns a handle + immediately - `ProfileRegionHandle`: the active region handle returned by manual starts - `ProfileRegionConfigurationException`: thrown when the process is not running inside a compatible profiler session @@ -24,6 +29,9 @@ The package exports: Use `profileRegion()` by default. Reach for `startProfileRegion()` only when the measured work spans multiple branches, callbacks, or lifecycle hooks. +Use `profileRegionSync()` when the profiled code is synchronous — for example +a hot instruction dispatch — to avoid wrapping it in unnecessary +`() async => body()` closures. ## Add It To A Target App @@ -137,6 +145,72 @@ await profileRegion('request', () async { Nested regions inherit the active parent automatically unless you pass an explicit `parentRegionId`. +## Synchronous Region Profiling + +For code that runs synchronously, use `profileRegionSync()` instead of wrapping +in `() async => syncBody()`. DTD start/stop events are sent asynchronously +(fire-and-forget), so region timing stays accurate — timestamps are captured at +the call site, not when the DTD message arrives. + +```dart +import 'package:devtools_region_profiler/devtools_region_profiler.dart'; + +Object? executeInstruction(Opcode op, Object? arg) { + return profileRegionSync( + 'dispatch-${op.name}', + attributes: {'opcode': op.name}, + () { + // synchronous dispatch — no Future wrapper + switch (op) { + case Opcode.call: return callFunction(arg); + case Opcode.return_: return popFrame(); + case Opcode.add: return addValues(arg); + } + }, + ); +} +``` + +For manual handle-based synchronous profiling: + +```dart +void hotLoop() { + final region = startProfileRegionSync('hot-loop'); + try { + while (hasWork()) { + doWork(); + } + } finally { + region.stop(); // fire-and-forget + } +} +``` + +## Tool-Specific Metadata + +Attach extra metadata to regions using the `extra` field on +`ProfileRegionOptions`. This data is preserved in the session artifact and +displayed in region summaries, making it searchable across profiling sessions. + +```dart +await profileRegion( + 'test-runner', + options: const ProfileRegionOptions( + extra: { + 'luaFile': 'calls.lua', + 'luaFunction': 'runBenchmark', + 'bytecodeVersion': 3, + }, + ), + () async { + await runTest(); + }, +); +``` + +Tool-specific metadata supports string, numeric, and boolean values, and is +serialized as JSON in the session artifact. + ## Capture Options Default options: diff --git a/packages/devtools_region_profiler/lib/src/profile_region.dart b/packages/devtools_region_profiler/lib/src/profile_region.dart index 12361cb..ba074d1 100644 --- a/packages/devtools_region_profiler/lib/src/profile_region.dart +++ b/packages/devtools_region_profiler/lib/src/profile_region.dart @@ -77,6 +77,58 @@ Future profileRegion( }, zoneValues: {_activeRegionStackZoneKey: regionStack}); } +/// Runs a synchronous [body] while reporting a named profiling region. +/// +/// Unlike [profileRegion], this does not wrap the callback in a Future. +/// DTD start/stop events are sent asynchronously (fire-and-forget) so the +/// caller's synchronous execution is not blocked by profiler transport. +/// Timestamps are captured at the call site, so region timing remains +/// accurate even if the DTD messages arrive slightly late. +/// +/// Nested calls inherit the current region parent automatically. +/// +/// Throws a [ProfileRegionConfigurationException] when this process was not +/// started by the profiler CLI in a session that can receive region events. +T profileRegionSync( + String name, + T Function() body, { + Map attributes = const {}, + ProfileRegionOptions options = const ProfileRegionOptions(), +}) { + final inheritedOptions = options.parentRegionId == null + ? options.copyWith(parentRegionId: _currentRegionId()) + : options; + final handle = startProfileRegionSync( + name, + attributes: attributes, + options: inheritedOptions, + ); + final regionStack = [..._currentRegionStack(), handle.regionId]; + + return runZoned(() { + T? result; + Object? pendingError; + StackTrace? pendingStackTrace; + + try { + result = body(); + } catch (error, stackTrace) { + pendingError = error; + pendingStackTrace = stackTrace; + } + + // Each transport phase reports its own failures. Observe the future without + // relabeling a failed start as a failed stop. + unawaited(handle.stop().catchError((Object _) {})); + + if (pendingError != null) { + Error.throwWithStackTrace(pendingError, pendingStackTrace!); + } + + return result as T; + }, zoneValues: {_activeRegionStackZoneKey: regionStack}); +} + /// Starts a profiling region and returns a handle that can stop it later. /// /// Use this when the measured work spans multiple control-flow paths or cannot @@ -105,6 +157,127 @@ Future startProfileRegion( ); } +/// Starts a profiling region synchronously, firing DTD events in the +/// background. +/// +/// Use this when you need to profile synchronous code without adding a +/// `Future` wrapper. Awaiting the returned handle's [ProfileRegionHandle.stop] +/// waits for both the start and stop messages. A failed start is logged and +/// causes stop to fail without sending a stop for an unknown region. +/// +/// Throws a [ProfileRegionConfigurationException] when this process was not +/// started by the profiler CLI in a session that can receive region events. +ProfileRegionHandle startProfileRegionSync( + String name, { + Map attributes = const {}, + ProfileRegionOptions options = const ProfileRegionOptions(), +}) { + final isolateId = developer.Service.getIsolateId(Isolate.current); + if (isolateId == null) { + throw const ProfileRegionConfigurationException( + 'The current Dart runtime does not expose a service protocol isolate ID.', + ); + } + + final inheritedOptions = options.parentRegionId == null + ? options.copyWith(parentRegionId: _currentRegionId()) + : options; + + final controlClient = _ProfilerControlClient.fromEnvironment(); + final regionId = _generateRegionId(); + final startTimestampMicros = developer.Timeline.now; + + // Fire DTD start asynchronously — timestamps are already captured. + final started = _startRegionAsync( + controlClient, + dtdParams: { + 'attributes': attributes, + 'captureKinds': [ + for (final kind in inheritedOptions.captureKinds) kind.name, + ], + 'isolateId': isolateId, + 'isolateScope': inheritedOptions.isolateScope.name, + 'name': name, + if (inheritedOptions.parentRegionId != null) + 'parentRegionId': inheritedOptions.parentRegionId, + 'regionId': regionId, + 'sessionId': controlClient.sessionId, + 'timestampMicros': startTimestampMicros, + if (inheritedOptions.extra.isNotEmpty) 'extra': inheritedOptions.extra, + }, + ); + + return ProfileRegionHandle._( + attributes: attributes, + name: name, + regionId: regionId, + stopImpl: () async { + // Capture the endpoint before waiting for transport, not after it. + final timestampMicros = developer.Timeline.now; + // Failed starts already report their error and close their connection. + await started; + var stopFailed = false; + try { + await controlClient.stopRegionAsynchronously( + isolateId: isolateId, + regionId: regionId, + timestampMicros: timestampMicros, + ); + } catch (error, stack) { + stopFailed = true; + _reportRegionFailure(regionId, 'stop', error, stack); + rethrow; + } finally { + try { + await controlClient.close(); + } catch (error, stack) { + _reportRegionFailure(regionId, 'close', error, stack); + if (!stopFailed) rethrow; + } + } + }, + ); +} + +/// Fires a DTD startRegion call in the background without awaiting. +Future _startRegionAsync( + _ProfilerControlClient client, { + required Map dtdParams, +}) { + final started = client + .callService(_profilerControlService, _startRegionMethod, dtdParams) + .catchError((Object error, StackTrace stack) async { + await client.close(); + Error.throwWithStackTrace(error, stack); + }); + // Observe errors immediately, while retaining them for an awaited stop. + unawaited( + started.catchError((Object error, StackTrace stack) { + _reportRegionFailure( + dtdParams['regionId'].toString(), + 'start', + error, + stack, + ); + }), + ); + return started; +} + +void _reportRegionFailure( + String regionId, + String operation, + Object error, + StackTrace stack, +) { + developer.log( + 'Failed to $operation profiling region $regionId: $error', + name: 'devtools_region_profiler', + error: error, + stackTrace: stack, + ); +} + /// A handle for an in-flight profiling region. /// /// Instances are returned by [startProfileRegion] and represent one active @@ -166,6 +339,10 @@ class _ProfilerControlClient { final Uri _dtdUri; final String _sessionId; + Future? _connection; + + /// The configured profiler session identifier. + String get sessionId => _sessionId; /// Creates a control client from profiler-provided environment values. /// @@ -215,6 +392,7 @@ class _ProfilerControlClient { 'regionId': regionId, 'sessionId': _sessionId, 'timestampMicros': developer.Timeline.now, + if (options.extra.isNotEmpty) 'extra': options.extra, }, ); }); @@ -266,6 +444,59 @@ class _ProfilerControlClient { await dtd.close(); } } + + /// Opens one validated connection for a synchronous region's lifetime. + Future _connect() async { + final dtd = await DartToolingDaemon.connect(_dtdUri); + try { + await _validateSession(dtd); + return dtd; + } catch (_) { + await dtd.close(); + rethrow; + } + } + + /// Closes the region's cached connection after stop or failed start. + Future close() async { + final connection = _connection; + _connection = null; + if (connection == null) return; + DartToolingDaemon dtd; + try { + dtd = await connection; + } catch (_) { + // A failed connection/validation already performed its own cleanup. + return; + } + await dtd.close(); + } + + /// Calls a DTD service method over the region's validated connection. + /// + /// The stop closure waits for start before sending its request. + Future callService( + String service, + String method, + Map params, + ) async { + final dtd = await (_connection ??= _connect()); + await dtd.call(service, method, params: params); + } + + /// Stops a region by sending a fire-and-forget DTD message. + Future stopRegionAsynchronously({ + required String isolateId, + required String regionId, + required int timestampMicros, + }) async { + await callService(_profilerControlService, _stopRegionMethod, { + 'isolateId': isolateId, + 'regionId': regionId, + 'sessionId': _sessionId, + 'timestampMicros': timestampMicros, + }); + } } /// Returns a unique identifier for a new region. diff --git a/packages/devtools_region_profiler/pubspec.yaml b/packages/devtools_region_profiler/pubspec.yaml index 4716d5f..72bc1cb 100644 --- a/packages/devtools_region_profiler/pubspec.yaml +++ b/packages/devtools_region_profiler/pubspec.yaml @@ -2,10 +2,10 @@ name: devtools_region_profiler description: Helper APIs for marking CPU profiling regions in Dart and Flutter applications. -version: 0.1.0 +version: 0.3.0 environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' resolution: workspace @@ -13,7 +13,7 @@ repository: https://github.com/kingwill101/devtools-profiler/tree/main/packages/ dependencies: dtd: ^4.0.0 - devtools_profiler_protocol: ^0.1.0 + devtools_profiler_protocol: ^0.3.0 dev_dependencies: - test: ^1.25.8 + test: ^1.32.0 diff --git a/packages/devtools_region_profiler/test/fixtures/sync_region.dart b/packages/devtools_region_profiler/test/fixtures/sync_region.dart new file mode 100644 index 0000000..b04aec0 --- /dev/null +++ b/packages/devtools_region_profiler/test/fixtures/sync_region.dart @@ -0,0 +1,11 @@ +import 'package:devtools_region_profiler/devtools_region_profiler.dart'; + +Future main() async { + final handle = startProfileRegionSync('immediate-stop'); + try { + await handle.stop(); + print('stopped'); + } catch (_) { + print('rejected'); + } +} diff --git a/packages/devtools_region_profiler/test/profile_region_test.dart b/packages/devtools_region_profiler/test/profile_region_test.dart index 88e00c8..b2d1fb3 100644 --- a/packages/devtools_region_profiler/test/profile_region_test.dart +++ b/packages/devtools_region_profiler/test/profile_region_test.dart @@ -8,4 +8,55 @@ void main() { throwsA(isA()), ); }); + + test('startProfileRegionSync throws outside a profiler session', () { + expect( + () => startProfileRegionSync('outside-session'), + throwsA(isA()), + ); + }); + + test('profileRegionSync throws outside a profiler session', () { + expect( + () => profileRegionSync('outside-session', () => 42), + throwsA(isA()), + ); + }); + + group('ProfileRegionOptions.extra', () { + test('defaults to empty map', () { + const options = ProfileRegionOptions(); + expect(options.extra, isEmpty); + }); + + test('serializes and deserializes extra metadata', () { + const options = ProfileRegionOptions( + extra: {'luaFile': 'calls.lua', 'version': 3, 'opt': true}, + ); + final json = options.toJson(); + expect(json['extra'], isA>()); + final restored = ProfileRegionOptions.fromJson(json); + expect(restored.extra['luaFile'], 'calls.lua'); + expect(restored.extra['version'], 3); + expect(restored.extra['opt'], true); + }); + + test('is not included in json when empty', () { + const options = ProfileRegionOptions(); + final json = options.toJson(); + expect(json.containsKey('extra'), isFalse); + }); + + test('preserved by copyWith', () { + const options = ProfileRegionOptions(extra: {'luaFile': 'bench.lua'}); + final copied = options.copyWith(); + expect(copied.extra['luaFile'], 'bench.lua'); + }); + + test('can be replaced by copyWith', () { + const options = ProfileRegionOptions(extra: {'luaFile': 'bench.lua'}); + final copied = options.copyWith(extra: {'luaFile': 'math.lua'}); + expect(copied.extra['luaFile'], 'math.lua'); + }); + }); } diff --git a/packages/devtools_region_profiler/test/sync_region_transport_test.dart b/packages/devtools_region_profiler/test/sync_region_transport_test.dart new file mode 100644 index 0000000..170ccc7 --- /dev/null +++ b/packages/devtools_region_profiler/test/sync_region_transport_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + for (final mode in ['success', 'start-error', 'stale-session']) { + test('sync region orders and closes DTD requests: $mode', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final sockets = []; + addTearDown(() async { + for (final socket in sockets) { + await socket.close(); + } + }); + final methods = []; + var startAcknowledged = false; + var prematureStop = false; + final closed = Completer(); + server.listen((request) async { + final socket = await WebSocketTransformer.upgrade(request); + sockets.add(socket); + socket.listen( + (message) async { + final rpc = jsonDecode(message as String) as Map; + final method = rpc['method'] as String; + methods.add(method); + if (method.endsWith('.startRegion')) { + // Stop is requested immediately by the child. Keep reading while + // the start response is delayed to detect an unordered channel. + await Future.delayed(const Duration(milliseconds: 100)); + startAcknowledged = true; + } + if (method.endsWith('.stopRegion') && !startAcknowledged) { + prematureStop = true; + } + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + if (mode == 'start-error' && method.endsWith('.startRegion')) + 'error': {'code': -32602, 'message': 'Rejected start'} + else + 'result': { + 'type': 'Success', + 'sessionId': mode == 'stale-session' ? 'stale' : 'session', + }, + }), + ); + }, + onDone: () { + if (!closed.isCompleted) closed.complete(); + }, + ); + }); + final packageRoot = + Directory.current.path.endsWith('devtools_region_profiler') + ? Directory.current + : Directory('packages/devtools_region_profiler'); + final child = await Process.start( + Platform.resolvedExecutable, + ['${packageRoot.absolute.path}/test/fixtures/sync_region.dart'], + environment: { + 'DEVTOOLS_PROFILER_DTD_URI': 'ws://127.0.0.1:${server.port}', + 'DEVTOOLS_PROFILER_SESSION_ID': 'session', + }, + ); + addTearDown(child.kill); + final output = child.stdout.transform(utf8.decoder).join(); + final errors = child.stderr.transform(utf8.decoder).join(); + expect(await child.exitCode.timeout(const Duration(seconds: 20)), 0); + expect(await errors, isEmpty); + expect( + await output, + contains(mode == 'success' ? 'stopped' : 'rejected'), + ); + await closed.future.timeout(const Duration(seconds: 2)); + expect(sockets, hasLength(1)); + expect(prematureStop, isFalse); + expect(methods, [ + 'DevToolsProfiler.getSessionInfo', + if (mode != 'stale-session') 'DevToolsProfiler.startRegion', + if (mode == 'success') 'DevToolsProfiler.stopRegion', + ]); + }); + } +} diff --git a/pubspec.lock b/pubspec.lock index a8bff0c..1b2d58a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,34 +5,34 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117 url: "https://pub.dev" source: hosted - version: "99.0.0" + version: "107.0.0" acanthis: dependency: transitive description: name: acanthis - sha256: "0e003d8a563e74b376c58e7d347e142e429d18603be5b70c80362b77730f66b5" + sha256: ead4c2e6b0cc76fd73f6440a45efaa3ca4fd14a835237bff70c84b498c3171a4 url: "https://pub.dev" source: hosted - version: "1.5.4" + version: "1.6.0" analyzer: dependency: transitive description: name: analyzer - sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791 url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "14.3.0" archive: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.2.0" args: dependency: transitive description: @@ -45,18 +45,18 @@ packages: dependency: transitive description: name: artisanal - sha256: ff67fd9dea58a603dea7c4073427c30f7ddeebba5da6a05a40cb7a8777ec1b8c + sha256: "27c321a233061e9bdd6dc5940063f3c12df6e08bc68359630f1c2642e39e9946" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.6.0" artisanal_widgets: dependency: transitive description: name: artisanal_widgets - sha256: d32b5e41689c9853241437ff9644a5272fbf341e938154fe502601c69009fa0a + sha256: dab2cdfd0454e7ed386882823d684169308d832ee13934ea3007176be50a547e url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.4.0" async: dependency: transitive description: @@ -73,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - chalkdart: - dependency: transitive - description: - name: chalkdart - sha256: "7dcf37e0b3d8dcec8c4ae0420d85aad9b3167d6a759601fd5b0f2b1958746581" - url: "https://pub.dev" - source: hosted - version: "3.1.0" characters: dependency: transitive description: @@ -101,10 +93,10 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: @@ -113,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + completion: + dependency: transitive + description: + name: completion + sha256: "82fa09800c0b71a2e8396bf3ca6b36b43cd35fe7ba2aeab53b6349ac671d0f5b" + url: "https://pub.dev" + source: hosted + version: "1.0.2" convert: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" crypto: dependency: transitive description: @@ -145,14 +145,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + d4_array: + dependency: transitive + description: + name: d4_array + sha256: dac8e1edc9e26dab0433c0e005d8dc42e5bcc43d22e9add528a95eaf770176e9 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + d4_time: + dependency: transitive + description: + name: d4_time + sha256: "30fb4deda3c5418cf4258a6881d22ed447a5baaef4f8bd6f5238d0c7d0e33a03" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + d4_time_format: + dependency: transitive + description: + name: d4_time_format + sha256: "680ab4e327d68d177f052f18bb0787317ab5dffd761a15ae270481537377631a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dart_mcp: dependency: transitive description: name: dart_mcp - sha256: "92a2ee1cca577ed54fa4f3d5ae0e80ce2dd61e1892e793bb4f951b30eab9c4b1" + sha256: "852b51da915d679be8d051e2e3b6c68ed19fe685f1c676bb35272a2d73ceac7a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.5.2" dart_service_protocol_shared: dependency: transitive description: @@ -161,22 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" - decimal: - dependency: transitive - description: - name: decimal - sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0 - url: "https://pub.dev" - source: hosted - version: "3.2.4" devtools_shared: dependency: transitive description: name: devtools_shared - sha256: "2daf7a9fba6a470668b26ecbd04200f7bf992aad81a2c31d12457c7791419dea" + sha256: "42f9b1dfcb58fb719ab52a8b8284662c8d82b0c79867dd150529234da90e2444" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "14.0.0" dtd: dependency: transitive description: @@ -217,14 +233,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - forge2d: - dependency: transitive - description: - name: forge2d - sha256: "0f63d177f2e137a5007b879fda4076d0b81de065fcd72056c6fc896c82758bb7" - url: "https://pub.dev" - source: hosted - version: "0.14.2+1" frontend_server_client: dependency: transitive description: @@ -245,10 +253,10 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" highlight: dependency: transitive description: @@ -257,14 +265,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf" + url: "https://pub.dev" + source: hosted + version: "4.4.0" html: dependency: transitive description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.7" html_unescape: dependency: transitive description: @@ -301,26 +317,26 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.9.2" intl: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" json_rpc_2: dependency: transitive description: @@ -329,22 +345,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - json_schema_builder: + liquify: dependency: transitive description: - name: json_schema_builder - sha256: "65035d48d028401ad0ffc8c2f173209c7b1441e465a942a0f909070fae33170c" + name: liquify + sha256: "89228cfb5a269158d67253451b113ce4d110ce4e094955e95684bd89a99af0a7" url: "https://pub.dev" source: hosted - version: "0.1.3" - liquify: + version: "1.6.1" + listen: dependency: transitive description: - name: liquify - sha256: "23aaa728dd2adc15dcc863772dec64425e8bc35bfa3f81323e0726fb83e1e28f" + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.0.1" logging: dependency: transitive description: @@ -365,34 +381,34 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" meta: dependency: transitive description: name: meta - sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.2" + version: "1.19.0" mime: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" nanoid2: dependency: transitive description: name: nanoid2 - sha256: "35b5048f836652a1d711db0d716bdee59fcaaa4c37792db8b3568da4f7feb2f9" + sha256: "665594e1969ee90fc3b521c7ddda2194ae3ed8e2a7c946ac62727ec8469c1449" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" node_preamble: dependency: transitive description: @@ -413,10 +429,10 @@ packages: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" path: dependency: transitive description: @@ -425,6 +441,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" petitparser: dependency: transitive description: @@ -437,42 +461,50 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" pool: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" - rational: + version: "2.2.1" + pure_svg: dependency: transitive description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + name: pure_svg + sha256: "0b440fbdd8487db70b247429328a2c8e35ef6c667d9390b61323c20bc3c67d16" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "0.2.0" + pure_ui: + dependency: transitive + description: + name: pure_ui + sha256: "7d1680b75de161e98f246124061034f7e77174e54adc1991a7caef8be76788c9" + url: "https://pub.dev" + source: hosted + version: "0.1.7" shelf: dependency: transitive description: @@ -517,10 +549,10 @@ packages: dependency: transitive description: name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" url: "https://pub.dev" source: hosted - version: "0.10.13" + version: "0.10.14" source_span: dependency: transitive description: @@ -541,10 +573,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -557,10 +589,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -581,34 +613,34 @@ packages: dependency: transitive description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: de5d145b0afff7921e5e788a880f52d7e5f3ae24068a202f6fd3b58e4ba26323 url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.32.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.14" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "80f3fb49087454e07e7e07c67578cfdd156c8c3a5227d8b3f47c7b2d019c2e93" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.20" timezone: dependency: transitive description: name: timezone - sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.11.1" typed_data: dependency: transitive description: @@ -621,34 +653,34 @@ packages: dependency: transitive description: name: ultraviolet - sha256: eddf41c6a43976d8d0de9059a789fc7b4013915fc658a2e596cb7cf56ffc0cd5 + sha256: c82ed967f3e976d0c95e3a141cd06c9318b099883e38350d4748171d3cbf0471 url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.1" unified_analytics: dependency: transitive description: name: unified_analytics - sha256: "406724e9231f8e30119673133c1087f9b24e2a75ba7111ea071253d57bb8f3b9" + sha256: "28bb11ef24567e720dc1397cba60df03cc5aded5bf6bef4d17b49934402c719b" url: "https://pub.dev" source: hosted - version: "8.0.14" + version: "8.0.18" vector_math: dependency: transitive description: name: vector_math - sha256: "47a1b32ee755c3fcffa33db52a7258c137f97bdb2209a1075be847809fac4ccf" + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" vm_service: dependency: transitive description: name: vm_service - sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.1.0" + version: "15.3.0" watcher: dependency: transitive description: @@ -701,10 +733,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_edit: dependency: transitive description: @@ -722,4 +754,4 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 54d1219..452c51c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: devtools_profiler_workspace publish_to: none environment: - sdk: '>=3.10.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' workspace: - packages/devtools_profiler_cli diff --git a/skills/devtools-profiler-local/SKILL.md b/skills/devtools-profiler-local/SKILL.md index f7e06ee..091fdbe 100644 --- a/skills/devtools-profiler-local/SKILL.md +++ b/skills/devtools-profiler-local/SKILL.md @@ -209,6 +209,32 @@ table, and memory summaries. Use `run` mode for region capture because the profiler launches the target with the session and DTD configuration needed by the region library. +For synchronous code that cannot use `async`/`await`, use +`profileRegionSync()` (or `startProfileRegionSync()` for manual handles). +DTD start/stop events are sent fire-and-forget so the caller is not blocked: + +```dart +Object? dispatch(Opcode op, Object? arg) { + return profileRegionSync( + 'dispatch-${op.name}', + () => executeOp(op, arg), + ); +} +``` + +Attach tool-specific metadata with the `extra` field on +`ProfileRegionOptions`. This data is preserved in the session artifact: + +```dart +await profileRegion( + 'lua-test', + () async { ... }, + options: const ProfileRegionOptions( + extra: {'luaFile': 'calls.lua', 'version': 3}, + ), +); +``` + ## Live Flutter Analysis The CLI can connect to already-running Flutter or Dart applications for live @@ -324,6 +350,17 @@ devtools-profiler summarize \ path/to/session ``` +Positional arguments accept session ids in addition to file paths, so you can +pass a session id directly instead of its on-disk path: + +```bash +devtools-profiler summarize 0712060003-8c410 +devtools-profiler compare 0712060003-8c410 0711235455-ebfb3 +devtools-profiler trends session-a session-b session-c +devtools-profiler inspect --method Parser.parseFile 0712060003-8c410 +devtools-profiler inspect-classes --class String 0712060003-8c410 +``` + Inspect a method: ```bash @@ -365,6 +402,46 @@ Comparison commands (`compare`, `compare-method`, `trends`) default to the latest two sessions. Use `--session-id latest`, `--session-id previous`, or `--session-id ` to select a different stored session explicitly. +Check for regressions against a baseline (CI-ready, exits 1 on regression): + +```bash +devtools-profiler regress path/to/baseline + devtools-profiler regress 0712060003-8c410 0711235455-ebfb3 + devtools-profiler regress --warn-only path/to/baseline +``` + +Compare three or more sessions at once: + +```bash +devtools-profiler compare session-a session-b session-c +``` + +Use `--csv` for compact, machine-readable tables: + +```bash +devtools-profiler summarize --csv --hide-sdk + devtools-profiler compare --csv baseline current + devtools-profiler trends --last 5 --csv +``` + +Use `--collapse-async` to categorize `dart:async` frames by type (normal +completions, error completions, listener dispatch, microtask scheduling, +zone overhead) and attribute async cost to the calling function: + +```bash +devtools-profiler summarize --collapse-async +devtools-profiler compare --collapse-async baseline current +``` + +Entries appear as `async (await _runFrame)` or `async (normal completions)` +showing which calling function triggered the async cost. + +Use `--last N` on trends to analyze the N most recent sessions: + +```bash +devtools-profiler trends --last 5 +``` + Use `--profile-id overall` for the whole session. Use the printed region id to inspect a marked region.