From c9b864ddbca5910714ac3afb69632ebe09260f98 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 12 Sep 2026 10:48:16 -0500 Subject: [PATCH 1/2] fix(core): restore terminal state after inherited-stdio profiling --- packages/devtools_profiler_core/README.md | 18 ++ .../lib/src/capture/profile_runner.dart | 67 +++++-- .../capture/runner/terminal_lifecycle.dart | 167 ++++++++++++++++++ .../test/terminal_lifecycle_test.dart | 144 +++++++++++++++ .../tool/terminal_lifecycle_probe.dart | 54 ++++++ .../tool/terminal_lifecycle_pty_test.py | 102 +++++++++++ 6 files changed, 536 insertions(+), 16 deletions(-) create mode 100644 packages/devtools_profiler_core/lib/src/capture/runner/terminal_lifecycle.dart create mode 100644 packages/devtools_profiler_core/test/terminal_lifecycle_test.dart create mode 100644 packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart create mode 100644 packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py 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(); + } +} diff --git a/packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart b/packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart new file mode 100644 index 0000000..e8166b0 --- /dev/null +++ b/packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:devtools_profiler_core/src/capture/runner/terminal_lifecycle.dart'; + +Future main(List args) async { + if (args case ['redirected']) { + final lifecycle = await TerminalLifecycle.capture(); + await lifecycle.restore(); + stdout.write('redirected-ok\n'); + return; + } + if (args case ['target', final markerPath]) { + stdout.write( + '\x1b[?1049h\x1b[?1003h\x1b[?1006h' + '\x1b[?2004h\x1b[?25l', + ); + await stdout.flush(); + // Do this in the child, after the enabling sequences have reached the + // tty. The parent uses the marker to observe the live termios state + // instead of racing a fixed delay. + stdin.echoMode = false; + stdin.lineMode = false; + // Give the host a scheduling turn before the parent samples termios. + await Future.delayed(const Duration(milliseconds: 100)); + await File(markerPath).writeAsString('ready\n'); + await Future.delayed(const Duration(seconds: 10)); + return; + } + + if (args.length != 1) { + throw ArgumentError('Expected a marker path'); + } + final marker = File(args.single); + final lifecycle = await TerminalLifecycle.capture(); + final target = await Process.start(Platform.resolvedExecutable, [ + Platform.script.toFilePath(), + 'target', + marker.path, + ], mode: ProcessStartMode.inheritStdio); + try { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!await marker.exists()) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('target did not signal terminal setup'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } + target.kill(ProcessSignal.sigterm); + await target.exitCode.timeout(const Duration(seconds: 2)); + } finally { + await lifecycle.restore(); + } +} diff --git a/packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py b/packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py new file mode 100644 index 0000000..4075b9c --- /dev/null +++ b/packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Exercise terminal cleanup through a real pseudo-terminal. + +This is intentionally outside package:test: a test runner's stdio is normally +pipe-backed, which cannot detect leaked termios or alternate-screen state. +""" + +import os +import pty +import select +import subprocess +import tempfile +import termios +import time + + +def main() -> None: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__) + )))) + with tempfile.TemporaryDirectory(prefix="profiler-pty-") as workspace: + marker = os.path.join(workspace, "target-ready") + command = [ + "dart", + "run", + "packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart", + marker, + ] + pid, fd = pty.fork() + if pid == 0: + os.chdir(root) + os.environ.setdefault("TERM", "xterm-256color") + os.execvp(command[0], command) + + before = termios.tcgetattr(fd) + output = bytearray() + deadline = time.monotonic() + 15 + marker_seen = False + try: + while time.monotonic() < deadline: + if os.path.exists(marker): + marker_seen = True + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + output.extend(os.read(fd, 4096)) + except OSError: + break + waited, status = os.waitpid(pid, os.WNOHANG) + if waited: + # Keep draining the PTY: TerminalLifecycle.restore runs + # during process shutdown and its bytes may arrive after + # the child status becomes waitable. + while True: + ready, _, _ = select.select([fd], [], [], 0) + if not ready: + break + try: + output.extend(os.read(fd, 4096)) + except OSError: + break + break + else: + os.kill(pid, 9) + raise SystemExit("terminal lifecycle probe timed out") + finally: + try: + _, status = os.waitpid(pid, 0) + except ChildProcessError: + pass + after = termios.tcgetattr(fd) + os.close(fd) + + if status != 0: + raise SystemExit(f"probe failed with status {status}: {output!r}") + if not marker_seen: + raise SystemExit("target terminal-state marker was not observed") + if before != after: + raise SystemExit("termios changed after lifecycle cleanup") + redirected = subprocess.run( + [ + "dart", + "run", + "packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart", + "redirected", + ], + cwd=root, + capture_output=True, + timeout=5, + check=False, + ) + if redirected.returncode != 0 or redirected.stdout != b"redirected-ok\n": + raise SystemExit( + "redirected stdout lifecycle check failed: " + f"{redirected.returncode=}, stdout={redirected.stdout!r}, " + f"stderr={redirected.stderr!r}" + ) + print("terminal lifecycle PTY regression passed") + + +if __name__ == "__main__": + main() From 92972e4f0bea15601f4d4dadf8fb81ecc3036cbc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 12 Sep 2026 12:10:34 -0500 Subject: [PATCH 2/2] chore(core): remove terminal debugging tools --- .../tool/terminal_lifecycle_probe.dart | 54 ---------- .../tool/terminal_lifecycle_pty_test.py | 102 ------------------ 2 files changed, 156 deletions(-) delete mode 100644 packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart delete mode 100644 packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py diff --git a/packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart b/packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart deleted file mode 100644 index e8166b0..0000000 --- a/packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:devtools_profiler_core/src/capture/runner/terminal_lifecycle.dart'; - -Future main(List args) async { - if (args case ['redirected']) { - final lifecycle = await TerminalLifecycle.capture(); - await lifecycle.restore(); - stdout.write('redirected-ok\n'); - return; - } - if (args case ['target', final markerPath]) { - stdout.write( - '\x1b[?1049h\x1b[?1003h\x1b[?1006h' - '\x1b[?2004h\x1b[?25l', - ); - await stdout.flush(); - // Do this in the child, after the enabling sequences have reached the - // tty. The parent uses the marker to observe the live termios state - // instead of racing a fixed delay. - stdin.echoMode = false; - stdin.lineMode = false; - // Give the host a scheduling turn before the parent samples termios. - await Future.delayed(const Duration(milliseconds: 100)); - await File(markerPath).writeAsString('ready\n'); - await Future.delayed(const Duration(seconds: 10)); - return; - } - - if (args.length != 1) { - throw ArgumentError('Expected a marker path'); - } - final marker = File(args.single); - final lifecycle = await TerminalLifecycle.capture(); - final target = await Process.start(Platform.resolvedExecutable, [ - Platform.script.toFilePath(), - 'target', - marker.path, - ], mode: ProcessStartMode.inheritStdio); - try { - final deadline = DateTime.now().add(const Duration(seconds: 5)); - while (!await marker.exists()) { - if (DateTime.now().isAfter(deadline)) { - throw StateError('target did not signal terminal setup'); - } - await Future.delayed(const Duration(milliseconds: 10)); - } - target.kill(ProcessSignal.sigterm); - await target.exitCode.timeout(const Duration(seconds: 2)); - } finally { - await lifecycle.restore(); - } -} diff --git a/packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py b/packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py deleted file mode 100644 index 4075b9c..0000000 --- a/packages/devtools_profiler_core/tool/terminal_lifecycle_pty_test.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise terminal cleanup through a real pseudo-terminal. - -This is intentionally outside package:test: a test runner's stdio is normally -pipe-backed, which cannot detect leaked termios or alternate-screen state. -""" - -import os -import pty -import select -import subprocess -import tempfile -import termios -import time - - -def main() -> None: - root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__) - )))) - with tempfile.TemporaryDirectory(prefix="profiler-pty-") as workspace: - marker = os.path.join(workspace, "target-ready") - command = [ - "dart", - "run", - "packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart", - marker, - ] - pid, fd = pty.fork() - if pid == 0: - os.chdir(root) - os.environ.setdefault("TERM", "xterm-256color") - os.execvp(command[0], command) - - before = termios.tcgetattr(fd) - output = bytearray() - deadline = time.monotonic() + 15 - marker_seen = False - try: - while time.monotonic() < deadline: - if os.path.exists(marker): - marker_seen = True - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - output.extend(os.read(fd, 4096)) - except OSError: - break - waited, status = os.waitpid(pid, os.WNOHANG) - if waited: - # Keep draining the PTY: TerminalLifecycle.restore runs - # during process shutdown and its bytes may arrive after - # the child status becomes waitable. - while True: - ready, _, _ = select.select([fd], [], [], 0) - if not ready: - break - try: - output.extend(os.read(fd, 4096)) - except OSError: - break - break - else: - os.kill(pid, 9) - raise SystemExit("terminal lifecycle probe timed out") - finally: - try: - _, status = os.waitpid(pid, 0) - except ChildProcessError: - pass - after = termios.tcgetattr(fd) - os.close(fd) - - if status != 0: - raise SystemExit(f"probe failed with status {status}: {output!r}") - if not marker_seen: - raise SystemExit("target terminal-state marker was not observed") - if before != after: - raise SystemExit("termios changed after lifecycle cleanup") - redirected = subprocess.run( - [ - "dart", - "run", - "packages/devtools_profiler_core/tool/terminal_lifecycle_probe.dart", - "redirected", - ], - cwd=root, - capture_output=True, - timeout=5, - check=False, - ) - if redirected.returncode != 0 or redirected.stdout != b"redirected-ok\n": - raise SystemExit( - "redirected stdout lifecycle check failed: " - f"{redirected.returncode=}, stdout={redirected.stdout!r}, " - f"stderr={redirected.stderr!r}" - ) - print("terminal lifecycle PTY regression passed") - - -if __name__ == "__main__": - main()