https://github.com/python/cpython/commit/9d92ac1225ab93b25acd43b658d214e12c228afe
commit: 9d92ac1225ab93b25acd43b658d214e12c228afe
branch: main
author: Marta Gómez Macías <[email protected]>
committer: pablogsal <[email protected]>
date: 2025-12-27T00:36:15Z
summary:

gh-143040: Exit taychon live mode gracefully and display profiled script errors 
(#143101)

files:
M Lib/profiling/sampling/_sync_coordinator.py
M Lib/profiling/sampling/cli.py
M Lib/profiling/sampling/live_collector/collector.py
M Lib/profiling/sampling/sample.py
M Lib/test/test_profiling/test_sampling_profiler/test_cli.py
M Lib/test/test_profiling/test_sampling_profiler/test_live_collector_ui.py

diff --git a/Lib/profiling/sampling/_sync_coordinator.py 
b/Lib/profiling/sampling/_sync_coordinator.py
index 63d057043f0416..a1cce314b33b19 100644
--- a/Lib/profiling/sampling/_sync_coordinator.py
+++ b/Lib/profiling/sampling/_sync_coordinator.py
@@ -135,7 +135,7 @@ def _execute_module(module_name: str, module_args: 
List[str]) -> None:
         module_args: Arguments to pass to the module
 
     Raises:
-        TargetError: If module execution fails
+        TargetError: If module cannot be found
     """
     # Replace sys.argv to match how Python normally runs modules
     # When running 'python -m module args', sys.argv is ["__main__.py", "args"]
@@ -145,11 +145,8 @@ def _execute_module(module_name: str, module_args: 
List[str]) -> None:
         runpy.run_module(module_name, run_name="__main__", alter_sys=True)
     except ImportError as e:
         raise TargetError(f"Module '{module_name}' not found: {e}") from e
-    except SystemExit:
-        # SystemExit is normal for modules
-        pass
-    except Exception as e:
-        raise TargetError(f"Error executing module '{module_name}': {e}") from 
e
+    # Let other exceptions (including SystemExit) propagate naturally
+    # so Python prints the full traceback to stderr
 
 
 def _execute_script(script_path: str, script_args: List[str], cwd: str) -> 
None:
@@ -183,22 +180,20 @@ def _execute_script(script_path: str, script_args: 
List[str], cwd: str) -> None:
     except PermissionError as e:
         raise TargetError(f"Permission denied reading script: {script_path}") 
from e
 
-    try:
-        main_module = types.ModuleType("__main__")
-        main_module.__file__ = script_path
-        main_module.__builtins__ = __builtins__
-        # gh-140729: Create a __mp_main__ module to allow pickling
-        sys.modules['__main__'] = sys.modules['__mp_main__'] = main_module
+    main_module = types.ModuleType("__main__")
+    main_module.__file__ = script_path
+    main_module.__builtins__ = __builtins__
+    # gh-140729: Create a __mp_main__ module to allow pickling
+    sys.modules['__main__'] = sys.modules['__mp_main__'] = main_module
 
+    try:
         code = compile(source_code, script_path, 'exec', module='__main__')
-        exec(code, main_module.__dict__)
     except SyntaxError as e:
         raise TargetError(f"Syntax error in script {script_path}: {e}") from e
-    except SystemExit:
-        # SystemExit is normal for scripts
-        pass
-    except Exception as e:
-        raise TargetError(f"Error executing script '{script_path}': {e}") from 
e
+
+    # Execute the script - let exceptions propagate naturally so Python
+    # prints the full traceback to stderr
+    exec(code, main_module.__dict__)
 
 
 def main() -> NoReturn:
@@ -209,6 +204,8 @@ def main() -> NoReturn:
     with the sample profiler by signaling when the process is ready
     to be profiled.
     """
+    # Phase 1: Parse arguments and set up environment
+    # Errors here are coordinator errors, not script errors
     try:
         # Parse and validate arguments
         sync_port, cwd, target_args = _validate_arguments(sys.argv)
@@ -237,21 +234,19 @@ def main() -> NoReturn:
         # Signal readiness to profiler
         _signal_readiness(sync_port)
 
-        # Execute the target
-        if is_module:
-            _execute_module(module_name, module_args)
-        else:
-            _execute_script(script_path, script_args, cwd)
-
     except CoordinatorError as e:
         print(f"Profiler coordinator error: {e}", file=sys.stderr)
         sys.exit(1)
     except KeyboardInterrupt:
         print("Interrupted", file=sys.stderr)
         sys.exit(1)
-    except Exception as e:
-        print(f"Unexpected error in profiler coordinator: {e}", 
file=sys.stderr)
-        sys.exit(1)
+
+    # Phase 2: Execute the target script/module
+    # Let exceptions propagate naturally so Python prints full tracebacks
+    if is_module:
+        _execute_module(module_name, module_args)
+    else:
+        _execute_script(script_path, script_args, cwd)
 
     # Normal exit
     sys.exit(0)
diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py
index dd6431a0322bc7..e43925ea8595f0 100644
--- a/Lib/profiling/sampling/cli.py
+++ b/Lib/profiling/sampling/cli.py
@@ -272,11 +272,6 @@ def _run_with_sync(original_cmd, suppress_output=False):
 
         try:
             _wait_for_ready_signal(sync_sock, process, _SYNC_TIMEOUT_SEC)
-
-            # Close stderr pipe if we were capturing it
-            if process.stderr:
-                process.stderr.close()
-
         except socket.timeout:
             # If we timeout, kill the process and raise an error
             if process.poll() is None:
@@ -1103,14 +1098,27 @@ def _handle_live_run(args):
             blocking=args.blocking,
         )
     finally:
-        # Clean up the subprocess
-        if process.poll() is None:
+        # Clean up the subprocess and get any error output
+        returncode = process.poll()
+        if returncode is None:
+            # Process still running - terminate it
             process.terminate()
             try:
                 process.wait(timeout=_PROCESS_KILL_TIMEOUT_SEC)
             except subprocess.TimeoutExpired:
                 process.kill()
-                process.wait()
+        # Ensure process is fully terminated
+        process.wait()
+        # Read any stderr output (tracebacks, errors, etc.)
+        if process.stderr:
+            with process.stderr:
+                try:
+                    stderr = process.stderr.read()
+                    if stderr:
+                        print(stderr.decode(), file=sys.stderr)
+                except (OSError, ValueError):
+                    # Ignore errors if pipe is already closed
+                    pass
 
 
 def _handle_replay(args):
diff --git a/Lib/profiling/sampling/live_collector/collector.py 
b/Lib/profiling/sampling/live_collector/collector.py
index cdf95a77eeccd8..b31ab060a6b934 100644
--- a/Lib/profiling/sampling/live_collector/collector.py
+++ b/Lib/profiling/sampling/live_collector/collector.py
@@ -216,6 +216,9 @@ def __init__(
     def elapsed_time(self):
         """Get the elapsed time, frozen when finished."""
         if self.finished and self.finish_timestamp is not None:
+            # Handle case where process exited before any samples were 
collected
+            if self.start_time is None:
+                return 0
             return self.finish_timestamp - self.start_time
         return time.perf_counter() - self.start_time if self.start_time else 0
 
diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py
index 5525bffdf5747d..e73306ebf290e7 100644
--- a/Lib/profiling/sampling/sample.py
+++ b/Lib/profiling/sampling/sample.py
@@ -42,7 +42,9 @@ def _pause_threads(unwinder, blocking):
     LiveStatsCollector = None
 
 _FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None
-
+# Minimum number of samples required before showing the TUI
+# If fewer samples are collected, we skip the TUI and just print a message
+MIN_SAMPLES_FOR_TUI = 200
 
 class SampleProfiler:
     def __init__(self, pid, sample_interval_usec, all_threads, *, 
mode=PROFILING_MODE_WALL, native=False, gc=True, opcodes=False, 
skip_non_matching_threads=True, collect_stats=False, blocking=False):
@@ -459,6 +461,11 @@ def sample_live(
     """
     import curses
 
+    # Check if process is alive before doing any heavy initialization
+    if not _is_process_running(pid):
+        print(f"No samples collected - process {pid} exited before profiling 
could begin.", file=sys.stderr)
+        return collector
+
     # Get sample interval from collector
     sample_interval_usec = collector.sample_interval_usec
 
@@ -486,6 +493,12 @@ def curses_wrapper_func(stdscr):
         collector.init_curses(stdscr)
         try:
             profiler.sample(collector, duration_sec, async_aware=async_aware)
+            # If too few samples were collected, exit cleanly without showing 
TUI
+            if collector.successful_samples < MIN_SAMPLES_FOR_TUI:
+                # Clear screen before exiting to avoid visual artifacts
+                stdscr.clear()
+                stdscr.refresh()
+                return
             # Mark as finished and keep the TUI running until user presses 'q'
             collector.mark_finished()
             # Keep processing input until user quits
@@ -500,4 +513,11 @@ def curses_wrapper_func(stdscr):
     except KeyboardInterrupt:
         pass
 
+    # If too few samples were collected, print a message
+    if collector.successful_samples < MIN_SAMPLES_FOR_TUI:
+        if collector.successful_samples == 0:
+            print(f"No samples collected - process {pid} exited before 
profiling could begin.", file=sys.stderr)
+        else:
+            print(f"Only {collector.successful_samples} sample(s) collected 
(minimum {MIN_SAMPLES_FOR_TUI} required for TUI) - process {pid} exited too 
quickly.", file=sys.stderr)
+
     return collector
diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py 
b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
index fb4816a0b6085a..f187f6c51d88e2 100644
--- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
+++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
@@ -18,7 +18,6 @@
 from profiling.sampling.cli import main
 from profiling.sampling.errors import SamplingScriptNotFoundError, 
SamplingModuleNotFoundError, SamplingUnknownProcessError
 
-
 class TestSampleProfilerCLI(unittest.TestCase):
     def _setup_sync_mocks(self, mock_socket, mock_popen):
         """Helper to set up socket and process mocks for coordinator tests."""
diff --git 
a/Lib/test/test_profiling/test_sampling_profiler/test_live_collector_ui.py 
b/Lib/test/test_profiling/test_sampling_profiler/test_live_collector_ui.py
index 2ed9d82a4a4aa2..c0d39f487c8cbd 100644
--- a/Lib/test/test_profiling/test_sampling_profiler/test_live_collector_ui.py
+++ b/Lib/test/test_profiling/test_sampling_profiler/test_live_collector_ui.py
@@ -4,11 +4,14 @@
 edge cases, update display, and display helpers.
 """
 
+import functools
+import io
 import sys
+import tempfile
 import time
 import unittest
 from unittest import mock
-from test.support import requires
+from test.support import requires, requires_remote_subprocess_debugging
 from test.support.import_helper import import_module
 
 # Only run these tests if curses is available
@@ -16,10 +19,12 @@
 curses = import_module("curses")
 
 from profiling.sampling.live_collector import LiveStatsCollector, MockDisplay
+from profiling.sampling.cli import main
 from ._live_collector_helpers import (
     MockThreadInfo,
     MockInterpreterInfo,
 )
+from .helpers import close_and_unlink
 
 
 class TestLiveStatsCollectorWithMockDisplay(unittest.TestCase):
@@ -816,5 +821,70 @@ def test_get_all_lines_full_display(self):
         self.assertTrue(any("PID" in line for line in lines))
 
 
+@requires_remote_subprocess_debugging()
+class TestLiveModeErrors(unittest.TestCase):
+    """Tests running error commands in the live mode fails gracefully."""
+
+    def mock_curses_wrapper(self, func):
+        func(mock.MagicMock())
+
+    def mock_init_curses_side_effect(self, n_times, mock_self, stdscr):
+        mock_self.display = MockDisplay()
+        # Allow the loop to run for a bit (approx 0.5s) before quitting
+        # This ensures we don't exit too early while the subprocess is
+        # still failing
+        for _ in range(n_times):
+            mock_self.display.simulate_input(-1)
+        if n_times >= 500:
+            mock_self.display.simulate_input(ord('q'))
+
+    def test_run_failed_module_live(self):
+        """Test that running a existing module that fails exits with clean 
error."""
+
+        args = [
+            "profiling.sampling.cli", "run", "--live", "-m", "test",
+            "test_asdasd"
+        ]
+
+        with (
+            mock.patch(
+                
'profiling.sampling.live_collector.collector.LiveStatsCollector.init_curses',
+                autospec=True,
+                
side_effect=functools.partial(self.mock_init_curses_side_effect, 1000)
+            ),
+            mock.patch('curses.wrapper', side_effect=self.mock_curses_wrapper),
+            mock.patch("sys.argv", args),
+            mock.patch('sys.stderr', new=io.StringIO()) as fake_stderr
+        ):
+            main()
+            self.assertIn(
+                'test test_asdasd crashed -- Traceback (most recent call 
last):',
+                fake_stderr.getvalue()
+            )
+
+    def test_run_failed_script_live(self):
+        """Test that running a failing script exits with clean error."""
+        script = tempfile.NamedTemporaryFile(suffix=".py")
+        self.addCleanup(close_and_unlink, script)
+        script.write(b'1/0\n')
+        script.seek(0)
+
+        args = ["profiling.sampling.cli", "run", "--live", script.name]
+
+        with (
+            mock.patch(
+                
'profiling.sampling.live_collector.collector.LiveStatsCollector.init_curses',
+                autospec=True,
+                
side_effect=functools.partial(self.mock_init_curses_side_effect, 200)
+            ),
+            mock.patch('curses.wrapper', side_effect=self.mock_curses_wrapper),
+            mock.patch("sys.argv", args),
+            mock.patch('sys.stderr', new=io.StringIO()) as fake_stderr
+        ):
+            main()
+            stderr = fake_stderr.getvalue()
+            self.assertIn('ZeroDivisionError', stderr)
+
+
 if __name__ == "__main__":
     unittest.main()

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to