diff --git a/packages/devtools_profiler_core/README.md b/packages/devtools_profiler_core/README.md index d0ca144..de90dde 100644 --- a/packages/devtools_profiler_core/README.md +++ b/packages/devtools_profiler_core/README.md @@ -37,6 +37,24 @@ The core package can: It does not contain terminal rendering, MCP transport, Flutter UI code, or web UI code. +### Inherited terminal cleanup + +When a run inherits stdio, the runner captures Dart's portable stdin controls +(`echoMode`, `lineMode`, and `echoNewlineMode`) and restores each control that +the host exposes. Unsupported controls, and failures restoring one control, do +not prevent the other controls from being restored. Common interactive escape +state (mouse reporting, bracketed paste, cursor visibility, SGR, margins, and +the alternate screen) is reset only on stdout or stderr streams reported as +terminal-backed. Redirected streams receive no escape sequences, and shared +stdout/stderr sinks are flushed but never closed. + +This deliberately uses only `dart:io`; it does not invoke `stty`, open +`/dev/tty`, or add native dependencies. On Windows, the stdin mode accessors +are restored where supported. ANSI cleanup depends on the host console already +having virtual-terminal processing enabled; enabling that Windows native +console mode is not provided by the Dart standard API. The cleanup does not +erase the primary screen or its scrollback. + ## Launch And Profile A Command ```dart diff --git a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart index c67d4f3..ce1aa57 100644 --- a/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart +++ b/packages/devtools_profiler_core/lib/src/capture/profile_runner.dart @@ -13,6 +13,7 @@ import 'runner/interrupt_finalization.dart'; import 'runner/process_launch.dart'; import 'runner/profile_runner_shared.dart'; import 'runner/profile_session_controller.dart'; +import 'runner/terminal_lifecycle.dart'; /// Launches profiled Dart processes and reads stored artifacts. /// @@ -65,11 +66,15 @@ class ProfileRunner { LaunchedProcess? launchedProcess; _ProfileRunSignalWatcher? interruptWatcher; Process? process; + TerminalLifecycle? terminalLifecycle; Timer? runDurationTimer; var processExited = false; var terminatedByProfiler = false; try { + if (request.processIoMode == ProfileProcessIoMode.inheritStdio) { + terminalLifecycle = await TerminalLifecycle.capture(); + } final vmServiceTimeout = request.vmServiceTimeout ?? defaultVmServiceTimeoutForCommand(command); @@ -155,10 +160,14 @@ class ProfileRunner { '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.', - ); + if (process != null) { + final stopped = await _terminateProcessWithEscalation(process); + if (!stopped) { + sessionController.addWarning( + 'Failed to terminate the target process after the profile ' + 'run duration elapsed.', + ); + } } } }()); @@ -247,19 +256,22 @@ class ProfileRunner { await artifactStore.writeSession(result); return result; } finally { - await launchedProcess?.stdoutSubscription?.cancel(); - await launchedProcess?.stderrSubscription?.cancel(); - await interruptWatcher?.dispose(); - runDurationTimer?.cancel(); - if (process != null && !processExited) { - process.kill(); - try { - await process.exitCode.timeout(const Duration(seconds: 2)); - } on TimeoutException { - sessionController.addWarning( - 'Timed out waiting for target process cleanup after profiling.', - ); + // Keep terminal restoration in an outer finally. A failed subscription + // cleanup or process shutdown must not leave an inherited tty unusable. + try { + await launchedProcess?.stdoutSubscription?.cancel(); + await launchedProcess?.stderrSubscription?.cancel(); + await interruptWatcher?.dispose(); + runDurationTimer?.cancel(); + if (process != null && !processExited) { + if (!await _terminateProcessWithEscalation(process)) { + sessionController.addWarning( + 'Timed out waiting for target process cleanup after profiling.', + ); + } } + } finally { + await terminalLifecycle?.restore(); } await sessionController.dispose(); await dtdSession.dispose(); @@ -677,6 +689,29 @@ bool _killProcess(Process process, ProcessSignal signal) { } } +/// Terminates [process] with bounded graceful and forced-exit windows. +Future _terminateProcessWithEscalation(Process process) async { + if (!_killProcess(process, ProcessSignal.sigterm)) { + return false; + } + try { + await process.exitCode.timeout(const Duration(seconds: 2)); + return true; + } on TimeoutException { + // Escalate below. + } + + if (!_killProcess(process, ProcessSignal.sigkill)) { + return false; + } + try { + await process.exitCode.timeout(const Duration(seconds: 1)); + return true; + } on TimeoutException { + return false; + } +} + String _profileSignalName(ProcessSignal signal) { if (signal == ProcessSignal.sigint) { return 'SIGINT'; diff --git a/packages/devtools_profiler_core/lib/src/capture/runner/terminal_lifecycle.dart b/packages/devtools_profiler_core/lib/src/capture/runner/terminal_lifecycle.dart new file mode 100644 index 0000000..d49fdd6 --- /dev/null +++ b/packages/devtools_profiler_core/lib/src/capture/runner/terminal_lifecycle.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +/// The stdin terminal controls that can be captured without platform code. +abstract interface class TerminalInput { + bool get hasTerminal; + bool get echoMode; + set echoMode(bool value); + bool get lineMode; + set lineMode(bool value); + bool get echoNewlineMode; + set echoNewlineMode(bool value); +} + +/// A terminal-backed output sink used for terminal cleanup. +abstract interface class TerminalOutput { + bool get hasTerminal; + void write(String value); + Future flush(); +} + +final class _StdinTerminalInput implements TerminalInput { + const _StdinTerminalInput(); + + @override + bool get hasTerminal => stdin.hasTerminal; + @override + bool get echoMode => stdin.echoMode; + @override + set echoMode(bool value) => stdin.echoMode = value; + @override + bool get lineMode => stdin.lineMode; + @override + set lineMode(bool value) => stdin.lineMode = value; + @override + bool get echoNewlineMode => stdin.echoNewlineMode; + @override + set echoNewlineMode(bool value) => stdin.echoNewlineMode = value; +} + +final class _IoTerminalOutput implements TerminalOutput { + const _IoTerminalOutput(this._sink, this._hasTerminal); + + final IOSink _sink; + final bool Function() _hasTerminal; + + @override + bool get hasTerminal => _hasTerminal(); + @override + void write(String value) => _sink.write(value); + @override + Future flush() => _sink.flush(); +} + +/// Captures and restores terminal state for an inherited-stdio session. +final class TerminalLifecycle { + TerminalLifecycle._(this._modes, this._outputs); + + final List<_CapturedMode> _modes; + final List _outputs; + + /// Captures supported stdin modes and terminal-backed output streams. + /// + /// Unsupported mode accessors are skipped independently. ANSI cleanup is + /// never sent to redirected streams. Windows hosts without virtual-terminal + /// processing enabled cannot interpret ANSI sequences; enabling that native + /// console feature is outside the Dart standard API. + static Future capture({ + TerminalInput? input, + List? outputs, + }) async { + final actualInput = input ?? const _StdinTerminalInput(); + final actualOutputs = + outputs ?? + [ + _IoTerminalOutput(stdout, () => stdout.hasTerminal), + _IoTerminalOutput(stderr, () => stderr.hasTerminal), + ]; + final modes = <_CapturedMode>[]; + try { + if (actualInput.hasTerminal) { + for (final mode in _terminalModes(actualInput)) { + try { + modes.add(_CapturedMode(mode, mode.read())); + } catch (_) { + // Some terminals expose only a subset of the controls. + } + } + } + } catch (_) { + // A stream which cannot report terminal status is not controllable. + } + return TerminalLifecycle._(modes, [ + for (final output in actualOutputs) + if (_hasTerminal(output)) output, + ]); + } + + /// Restores captured modes and common interactive terminal UI state. + /// + /// Each mode and output is independent. Shared stdout/stderr sinks are + /// flushed but never closed. + Future restore() async { + for (final mode in _modes) { + try { + mode.write(mode.value); + } catch (_) { + // One unsupported or failing setter must not block the others. + } + } + + const cleanupSequences = [ + '\x1b[?1000l\x1b[?1002l\x1b[?1003l', + '\x1b[?1006l\x1b[?1015l\x1b[?1016l', + '\x1b[?1004l\x1b[?2004l\x1b[?2026l', + '\x1b[?25h\x1b[0m\x1b[r', + // Leave the primary screen and its scrollback intact. + '\x1b[?1049l\x1b[?1047l\x1b[?47l', + ]; + for (final output in _outputs) { + for (final sequence in cleanupSequences) { + try { + output.write(sequence); + await output.flush(); + } catch (_) { + // A broken output must not prevent another output being cleaned. + } + } + } + } +} + +bool _hasTerminal(TerminalOutput output) { + try { + return output.hasTerminal; + } catch (_) { + return false; + } +} + +Iterable<_TerminalMode> _terminalModes(TerminalInput input) sync* { + yield _TerminalMode(() => input.echoMode, (value) { + input.echoMode = value; + }); + yield _TerminalMode(() => input.lineMode, (value) { + input.lineMode = value; + }); + yield _TerminalMode(() => input.echoNewlineMode, (value) { + input.echoNewlineMode = value; + }); +} + +final class _TerminalMode { + const _TerminalMode(this.read, this.write); + + final bool Function() read; + final void Function(bool value) write; +} + +final class _CapturedMode { + const _CapturedMode(this.mode, this.value); + + final _TerminalMode mode; + final bool value; + + void write(bool value) => mode.write(value); + bool read() => mode.read(); +} diff --git a/packages/devtools_profiler_core/test/terminal_lifecycle_test.dart b/packages/devtools_profiler_core/test/terminal_lifecycle_test.dart new file mode 100644 index 0000000..881a324 --- /dev/null +++ b/packages/devtools_profiler_core/test/terminal_lifecycle_test.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:devtools_profiler_core/src/capture/runner/terminal_lifecycle.dart'; +import 'package:test/test.dart'; + +void main() { + test('restores supported stdin modes independently', () async { + final input = _FakeInput(echo: true, line: false, newline: true); + final output = _FakeOutput(terminal: true); + final lifecycle = await TerminalLifecycle.capture( + input: input, + outputs: [output], + ); + input.echoMode = false; + input.lineMode = true; + input.echoNewlineMode = false; + + await lifecycle.restore(); + + expect(input.echoMode, isTrue); + expect(input.lineMode, isFalse); + expect(input.echoNewlineMode, isTrue); + expect(output.writes, hasLength(5)); + expect(output.closed, isFalse); + }); + + test('skips unsupported modes while restoring the others', () async { + final input = _FakeInput(echo: true, line: false, newline: true) + ..unsupportedEchoNewline = true; + final lifecycle = await TerminalLifecycle.capture( + input: input, + outputs: [_FakeOutput(terminal: true)], + ); + input.echoMode = false; + input.lineMode = true; + await lifecycle.restore(); + + expect(input.echoMode, isTrue); + expect(input.lineMode, isFalse); + }); + + test('does not write cleanup to redirected output', () async { + final input = _FakeInput(echo: true, line: true, newline: true); + final output = _FakeOutput(terminal: false); + final lifecycle = await TerminalLifecycle.capture( + input: input, + outputs: [output], + ); + await lifecycle.restore(); + + expect(output.writes, isEmpty); + expect(input.echoMode, isTrue); + }); + + test('continues after mode and output failures', () async { + final input = _FakeInput(echo: true, line: true, newline: true) + ..failEchoRestore = true; + final goodOutput = _FakeOutput(terminal: true); + final badOutput = _FakeOutput(terminal: true)..failWrites = true; + final lifecycle = await TerminalLifecycle.capture( + input: input, + outputs: [badOutput, goodOutput], + ); + + await expectLater(lifecycle.restore(), completes); + expect(input.lineMode, isTrue); + expect(goodOutput.writes, isNotEmpty); + }); + + test('capture and restore are safe without an inherited tty', () async { + final lifecycle = await TerminalLifecycle.capture( + input: _FakeInput(terminal: false), + outputs: [_FakeOutput(terminal: false)], + ); + await expectLater(lifecycle.restore(), completes); + }); +} + +final class _FakeInput implements TerminalInput { + _FakeInput({ + this.terminal = true, + bool echo = false, + bool line = false, + bool newline = false, + }) : _echo = echo, + _line = line, + _newline = newline; + + final bool terminal; + bool _echo; + bool _line; + bool _newline; + bool unsupportedEchoNewline = false; + bool failEchoRestore = false; + + @override + bool get hasTerminal => terminal; + @override + bool get echoMode => _echo; + @override + set echoMode(bool value) { + if (failEchoRestore && value) throw StateError('echo failure'); + _echo = value; + } + + @override + bool get lineMode => _line; + @override + set lineMode(bool value) => _line = value; + @override + bool get echoNewlineMode { + if (unsupportedEchoNewline) throw UnsupportedError('not available'); + return _newline; + } + + @override + set echoNewlineMode(bool value) { + if (unsupportedEchoNewline) throw UnsupportedError('not available'); + _newline = value; + } +} + +final class _FakeOutput implements TerminalOutput { + _FakeOutput({required this.terminal}); + + final bool terminal; + final writes = []; + bool closed = false; + bool failWrites = false; + + @override + bool get hasTerminal => terminal; + @override + void write(String value) { + if (failWrites) throw StateError('write failure'); + writes.add(value); + } + + @override + Future flush() async { + if (failWrites) throw StateError('flush failure'); + await Future.value(); + } +}