Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ target command has its own options. Dart launches are held at isolate exit long
enough for final CPU and memory snapshots, so short scripts can still produce a
whole-session profile.

Profile the bundled terminal UI fixture with direct stdio inheritance:

```bash
devtools-profiler run \
--terminal \
--cwd packages/devtools_profiler_core/test/fixtures/profiled_app \
-- dart run bin/artisanal_widget_app.dart
```

Use `--terminal` for TUI programs that need stdin, raw mode, mouse tracking, or
alternate-screen rendering. Terminal mode cannot be combined with `--json`
because the target owns stdout and stderr while it runs. When the CLI receives
Ctrl+C or SIGTERM, it finalizes the diagnostics captured so far and then prints
the normal session summary.

Profile a Flutter test run:

```bash
Expand Down Expand Up @@ -600,6 +615,8 @@ Common presentation flags:
process to expose a VM service URI before profiling starts. Examples: `3m`,
`300s`.
- `--forward-output` forwards child stdout/stderr. Defaults to `true`.
- `--terminal` gives the launched process direct terminal access for TUI and
alternate-screen apps. It cannot be combined with `--json`.

`attach` options:

Expand Down
10 changes: 10 additions & 0 deletions packages/devtools_profiler_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.3.0-wip

- Added `run --terminal` for profiling terminal UI and alternate-screen apps
with direct stdin, stdout, and stderr access.
- Added Ctrl+C/SIGTERM handling for launched runs so the CLI prints available
diagnostics before stopping an interrupted target.
- Preserved terminal mode in reproduction commands for captured sessions.
- Documented terminal-mode profiling in the root README, package READMEs, and
local profiler skill.

## 0.2.1

- Added `devtools-profiler run <file>.dart` shorthand for profiling Dart files
Expand Down
17 changes: 17 additions & 0 deletions packages/devtools_profiler_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ Bare Dart files are expanded to `dart run <file>`. Dart launches are held at
isolate exit long enough for final CPU and memory snapshots, so short scripts
can still produce a whole-session profile.

Profile the bundled terminal UI fixture:

```bash
devtools-profiler run \
--terminal \
--cwd packages/devtools_profiler_core/test/fixtures/profiled_app \
-- dart run bin/artisanal_widget_app.dart
```

Use `--terminal` when the target needs direct stdin, raw-mode input, mouse
tracking, or alternate-screen rendering. It cannot be combined with `--json`
because the target writes directly to the terminal. If you press Ctrl+C while
the CLI is running, the profiler writes the diagnostics captured so far before
printing the session summary.

Profile a Flutter test:

```bash
Expand Down Expand Up @@ -240,6 +255,8 @@ devtools-profiler trends \
duration. Examples: `15s`, `2m`, `500ms`.
- `--vm-service-timeout <duration>` controls startup wait time before the VM
service is available. Examples: `3m`, `300s`.
- `--terminal` gives the launched process direct terminal access for TUI and
alternate-screen apps. It cannot be combined with `--json`.
- `--min-live-bytes <n>` filters memory class rows for `compare` and
`inspect-classes`.
- `--memory-class-limit <n>` controls compared memory class rows for `compare`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ class RunCommand extends ProfilerCommand {
'forward-output',
defaultsTo: true,
help: 'Echo stdout and stderr from the launched process.',
)
..addFlag(
'terminal',
negatable: false,
help:
'Give the launched process direct terminal access for TUI and alternate-screen apps.',
);
}

Expand All @@ -59,6 +65,7 @@ class RunCommand extends ProfilerCommand {
const [
'devtools-profiler run bin/main.dart',
'devtools-profiler run -- dart run bin/main.dart',
'devtools-profiler run --terminal -- dart run bin/tui.dart',
'devtools-profiler run --cwd path/to/app -- dart run bin/main.dart',
'devtools-profiler run --duration 15s --cwd path/to/flutter_app -- flutter run -d linux -t lib/main.dart',
],
Expand All @@ -74,12 +81,23 @@ class RunCommand extends ProfilerCommand {
'the target command has options that could be parsed as profiler options.',
);
}
final terminalMode = argResults!['terminal'] as bool;
if (terminalMode && printJson) {
usageException(
'--terminal cannot be combined with --json because the target process '
'writes directly to stdout and stderr.',
);
}

final session = await profileRunner.run(
ProfileRunRequest(
artifactDirectory: argResults!['artifact-dir'] as String?,
command: commandArguments,
forwardOutput: argResults!['forward-output'] as bool,
handleInterruptSignals: true,
processIoMode: terminalMode
? ProfileProcessIoMode.inheritStdio
: ProfileProcessIoMode.pipe,
runDuration: parseDuration(
argResults!['duration'] as String?,
optionName: 'duration',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ String sessionCliCommand(ProfileRunResult session) {
return shellJoin([
'devtools-profiler',
'run',
if (session.processIoMode == ProfileProcessIoMode.inheritStdio)
'--terminal',
'--cwd',
session.workingDirectory,
'--artifact-dir',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Future<PreparedSessionPresentation> prepareSessionPresentation(
workingDirectory: session.workingDirectory,
exitCode: session.exitCode,
artifactDirectory: session.artifactDirectory,
terminatedByProfiler: session.terminatedByProfiler,
processIoMode: session.processIoMode,
supportedCaptureKinds: session.supportedCaptureKinds,
supportedIsolateScopes: session.supportedIsolateScopes,
overallProfile: overallProfile,
Expand Down
4 changes: 2 additions & 2 deletions packages/devtools_profiler_cli/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: devtools_profiler_cli
description: CLI and local stdio MCP server for automated Dart and Flutter CPU profiling.

version: 0.2.1
version: 0.3.0-wip

environment:
sdk: '>=3.10.0 <4.0.0'
Expand All @@ -17,7 +17,7 @@ executables:
dependencies:
artisanal: ^0.3.0
dart_mcp: ^0.5.0
devtools_profiler_core: ^0.2.1
devtools_profiler_core: ^0.3.0-wip
path: ^1.9.0
stream_channel: ^2.1.4
vm_service: ^15.0.2
Expand Down
65 changes: 65 additions & 0 deletions packages/devtools_profiler_cli/test/cli_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ void main() {
stdoutCapture.text,
contains('devtools-profiler run -- dart run bin/main.dart'),
);
expect(
stdoutCapture.text,
contains('devtools-profiler run --terminal -- dart run bin/tui.dart'),
);
expect(stdoutCapture.text, contains('--terminal'));
expect(
stdoutCapture.text,
contains(
Expand Down Expand Up @@ -157,12 +162,71 @@ void main() {
output: stdoutCapture.sink,
errorOutput: stderrCapture.sink,
);
await stdoutCapture.flush();

expect(exitCode, 0);
expect(runner.lastRunRequest?.runDuration, const Duration(seconds: 3));
expect(runner.lastRunRequest?.vmServiceTimeout, const Duration(minutes: 2));
});

test('run forwards terminal mode to the profiler backend', () async {
final runner = _FakeProfileRunner();
final stdoutCapture = _OutputCapture();
final stderrCapture = _OutputCapture();
addTearDown(() async {
await stdoutCapture.close();
await stderrCapture.close();
});

final exitCode = await runCli(
const ['run', '--terminal', '--', 'dart', 'run', 'bin/tui.dart'],
runner: runner,
output: stdoutCapture.sink,
errorOutput: stderrCapture.sink,
);
await stdoutCapture.flush();

expect(exitCode, 0);
expect(
runner.lastRunRequest?.processIoMode,
ProfileProcessIoMode.inheritStdio,
);
expect(runner.lastRunRequest?.handleInterruptSignals, isTrue);
expect(runner.lastRunRequest?.command, ['dart', 'run', 'bin/tui.dart']);
expect(
stdoutCapture.text,
contains('devtools-profiler run --terminal --cwd'),
);
});

test('run rejects terminal mode with json output', () async {
final stdoutCapture = _OutputCapture();
final stderrCapture = _OutputCapture();
addTearDown(() async {
await stdoutCapture.close();
await stderrCapture.close();
});

final exitCode = await runCli(
const [
'run',
'--json',
'--terminal',
'--',
'dart',
'run',
'bin/tui.dart',
],
runner: _FakeProfileRunner(),
output: stdoutCapture.sink,
errorOutput: stderrCapture.sink,
);
await stderrCapture.flush();

expect(exitCode, isNot(0));
expect(stderrCapture.text, contains('--terminal cannot be combined'));
});

test('attach profiles an existing VM service URI', () async {
final runner = _FakeProfileRunner();
final stdoutCapture = _OutputCapture();
Expand Down Expand Up @@ -1656,6 +1720,7 @@ class _FakeProfileRunner extends ProfileRunner {
artifactDirectory: _session.artifactDirectory,
vmServiceUri: _session.vmServiceUri,
overallProfile: _session.overallProfile,
processIoMode: request.processIoMode,
regions: _session.regions,
warnings: _session.warnings,
);
Expand Down
1 change: 1 addition & 0 deletions packages/devtools_profiler_cli/test/mcp_server_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,7 @@ class _FakeProfileRunner extends ProfileRunner {
artifactDirectory: '/tmp/artifacts/session-1',
vmServiceUri: 'http://127.0.0.1:8181/abcd/',
overallProfile: _overallProfile,
processIoMode: request.processIoMode,
regions: const [],
warnings: const [],
);
Expand Down
11 changes: 11 additions & 0 deletions packages/devtools_profiler_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.3.0-wip

- Added inherited-stdio process IO mode for terminal UI and alternate-screen
profiling targets.
- Added deterministic VM-service discovery for inherited-stdio Dart launches
and Flutter `run` launches.
- Added optional interrupt signal handling so hosts can finalize available
diagnostics before stopping an interrupted target.
- Reduced shutdown polling overhead by backing off exit-pause probes and adding
bounded VM-service request timeouts during final capture.

## 0.2.1

- Added bare Dart file launch support by expanding file paths to
Expand Down
19 changes: 19 additions & 0 deletions packages/devtools_profiler_core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ For Flutter commands, the backend injects profiler session values through
`runDuration` starts after the VM service is attached. `vmServiceTimeout`
controls the startup/build wait before profiling can begin.

For terminal UI targets, use inherited stdio so the child process receives the
real terminal:

```dart
await runner.run(
const ProfileRunRequest(
command: ['dart', 'run', 'bin/tui.dart'],
processIoMode: ProfileProcessIoMode.inheritStdio,
handleInterruptSignals: true,
workingDirectory: '/path/to/dart/app',
),
);
```

`handleInterruptSignals` is intended for CLI-like hosts that should trap Ctrl+C
or SIGTERM, finalize available diagnostics, stop the target, and return a
session result. Long-lived automation servers normally leave it disabled and
manage process signals at the host level.

## Attach To An Existing VM Service

Attach mode is for tools that already have a Dart VM service URI. This is useful
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/// How the profiled process should receive standard IO.
enum ProfileProcessIoMode {
/// Pipe child output through the profiler.
///
/// This lets the profiler read tool output and optionally forward it to the
/// caller. It is the best default for automation, MCP, and JSON workflows.
pipe,

/// Give the child process direct access to this process' standard IO.
///
/// Use this for terminal applications that need a real stdin/stdout/stderr
/// stream, including TUI applications that enter alternate-screen or raw
/// terminal modes.
inheritStdio,
}

/// A request to launch and profile a Dart or Flutter command.
///
/// Use this with [ProfileRunner.run] when the profiler should own the target
Expand All @@ -14,6 +30,8 @@ class ProfileRunRequest {
this.artifactDirectory,
this.forwardOutput = false,
this.environment = const {},
this.processIoMode = ProfileProcessIoMode.pipe,
this.handleInterruptSignals = false,
this.runDuration,
this.vmServiceTimeout,
});
Expand All @@ -37,6 +55,16 @@ class ProfileRunRequest {
/// Extra environment variables to inject into the launched process.
final Map<String, String> environment;

/// How the launched process should receive standard IO.
final ProfileProcessIoMode processIoMode;

/// Whether [ProfileRunner.run] should trap interrupt signals while running.
///
/// CLI callers usually enable this so Ctrl+C can finalize and print the
/// artifacts captured so far. Long-lived automation hosts such as MCP servers
/// should normally leave it disabled and manage process signals themselves.
final bool handleInterruptSignals;

/// Optional duration to profile before terminating the launched process.
///
/// This is useful for long-running apps such as `flutter run`, where the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:devtools_profiler_protocol/devtools_profiler_protocol.dart';

import 'model_json_utils.dart';
import 'profile_region_result.dart';
import 'profile_run_request.dart';

/// A full profiling session result.
///
Expand All @@ -28,6 +29,7 @@ class ProfileRunResult {
],
this.terminatedByProfiler = false,
this.overallProfile,
this.processIoMode = ProfileProcessIoMode.pipe,
this.vmServiceUri,
}) : supportedCaptureKinds = List.unmodifiable(
normalizeProfileCaptureKinds(supportedCaptureKinds),
Expand All @@ -48,6 +50,10 @@ class ProfileRunResult {
artifactDirectory: json['artifactDirectory'] as String? ?? '',
vmServiceUri: json['vmServiceUri'] as String?,
terminatedByProfiler: json['terminatedByProfiler'] as bool? ?? false,
processIoMode: switch (json['processIoMode']) {
'inheritStdio' => ProfileProcessIoMode.inheritStdio,
_ => ProfileProcessIoMode.pipe,
},
supportedCaptureKinds: switch (json['supportedCaptureKinds']) {
final List<Object?> values => [
for (final value in values)
Expand Down Expand Up @@ -96,6 +102,9 @@ class ProfileRunResult {
/// Whether the profiler stopped the process after a requested run duration.
final bool terminatedByProfiler;

/// How the launched process received standard IO.
final ProfileProcessIoMode processIoMode;

/// The directory containing the session artifacts.
final String artifactDirectory;

Expand Down Expand Up @@ -126,6 +135,7 @@ class ProfileRunResult {
'workingDirectory': workingDirectory,
'exitCode': exitCode,
'terminatedByProfiler': terminatedByProfiler,
'processIoMode': processIoMode.name,
'artifactDirectory': artifactDirectory,
'vmServiceUri': vmServiceUri,
'supportedCaptureKinds': [
Expand Down
Loading
Loading