]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-152548: Report a subprocess of runInSubprocess() dying after the test (GH-155162)
authorSerhiy Storchaka <storchaka@gmail.com>
Tue, 4 Aug 2026 10:03:05 +0000 (13:03 +0300)
committerGitHub <noreply@github.com>
Tue, 4 Aug 2026 10:03:05 +0000 (10:03 +0000)
The subprocess writes the result of the test before exiting, so a crash
during interpreter finalization left the result intact and the test was
reported as passed.  Check the exit code too, after replaying the outcomes,
so that a failure of the test itself is still reported as a failure.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lib/test/_isolated_sample.py
Lib/test/support/isolation.py
Lib/test/test_support.py

index 360a27a2b081173d68b110e55c425c03d7137238..c89f7145e7328d3ed7dfc8564bddc7fc2b1ee619 100644 (file)
@@ -5,6 +5,8 @@ This module is imported, never run as a test file, so that
 a subprocess.  Several of these tests fail, error or are skipped on purpose.
 """
 
+import atexit
+import os
 import time
 import unittest
 from test.support import isolation
@@ -109,3 +111,33 @@ class BrokenSubclassSample(SubclassingSample):
     @classmethod
     def setUpClass(cls):
         pass
+
+
+# The exit code the samples below die with, after their tests have run.
+EXIT_CODE = 3
+
+
+def _die_at_exit():
+    atexit.register(os._exit, EXIT_CODE)
+
+
+class MethodExitSample(unittest.TestCase):
+
+    @isolation.runInSubprocess()
+    def test_passes_then_dies(self):
+        _die_at_exit()
+
+    @isolation.runInSubprocess()
+    def test_fails_and_dies(self):
+        _die_at_exit()
+        self.fail('the test itself failed')
+
+
+@isolation.runInSubprocess()
+class ClassExitSample(unittest.TestCase):
+
+    def test_pass(self):
+        pass
+
+    def test_dies(self):
+        _die_at_exit()
index f449bf44034da355f3acca3526960f7994efb4b3..bc2189329c03997102860c74c6807fb80f8f2205 100644 (file)
@@ -163,6 +163,16 @@ def _raise_fixture_outcome(outcome):
     raise exc from _remote(outcome['detail'])
 
 
+def _check_returncode(returncode, output, what):
+    # The subprocess writes its result before exiting, so a non-zero exit code
+    # means it died afterwards, during finalization, unnoticed by the result.
+    if returncode:
+        exc = _SubprocessTestError(
+            f'the subprocess exited with code {returncode} '
+            f'after running the {what}')
+        raise exc from _remote(output)
+
+
 def _isolate_method(func):
     @functools.wraps(func)
     def wrapper(self, /, *args, **kwargs):
@@ -180,7 +190,9 @@ def _isolate_method(func):
             raise exc from _remote(output)
         # The parent measures this method's own duration (the real cost of the
         # isolated run, subprocess startup included), so nothing to forward here.
+        # Replay the outcomes first: a failure of the test itself is more useful.
         _replay_outcomes(self, payload['outcomes'])
+        _check_returncode(returncode, output, 'test')
     return wrapper
 
 
@@ -219,13 +231,20 @@ def _isolate_class(cls):
             by_id.setdefault(outcome['id'], []).append(outcome)
         cls._isolated_outcomes = by_id
         cls._isolated_durations = dict(payload.get('durations', ()))
+        # Report the crash from tearDownClass(), after replaying the outcomes.
+        cls._isolated_exit = (returncode, output)
 
     def tearDownClass(cls):
         if runningInSubprocess:
             orig_tearDownClass(cls)
-        else:
-            cls._isolated_outcomes = None
-            cls._isolated_durations = None
+            return
+        cls._isolated_outcomes = None
+        cls._isolated_durations = None
+        # Missing if an overriding setUpClass() bypassed the subprocess.
+        exited = getattr(cls, '_isolated_exit', None)
+        cls._isolated_exit = None
+        if exited is not None:
+            _check_returncode(*exited, 'class')
 
     def _callSetUp(self):
         # In the parent the real test does not run, so neither should setUp().
index 4b9bc245d6f78a8953ffbb1692288e08308bdc26..2317077b30ac38899a7c1d6fc94a5e29df8abef8 100644 (file)
@@ -1180,6 +1180,31 @@ class TestIsolated(unittest.TestCase):
         self.assertEqual(len(result.errors), 1)
         self.assertIn('did not run in a subprocess', result.errors[0][1])
 
+    @support.requires_subprocess()
+    def test_subprocess_dying_after_the_test_is_reported(self):
+        from test._isolated_sample import EXIT_CODE
+        result = self._run('MethodExitSample.test_passes_then_dies')
+        self.assertEqual(result.testsRun, 1)
+        self.assertEqual(len(result.errors), 1)
+        self.assertIn(f'exited with code {EXIT_CODE}', result.errors[0][1])
+
+    @support.requires_subprocess()
+    def test_subprocess_dying_does_not_hide_the_failure(self):
+        result = self._run('MethodExitSample.test_fails_and_dies')
+        self.assertEqual(self._names(result.failures), ['test_fails_and_dies'])
+        self.assertEqual(result.errors, [])
+
+    @support.requires_subprocess()
+    def test_class_subprocess_dying_after_the_tests_is_reported(self):
+        # The tests that ran are still reported, and the crash once, for the class.
+        from test._isolated_sample import EXIT_CODE
+        result = self._run('ClassExitSample')
+        self.assertEqual(result.testsRun, 2)
+        self.assertEqual(result.failures, [])
+        self.assertEqual(len(result.errors), 1)
+        self.assertIn('tearDownClass', str(result.errors[0][0]))
+        self.assertIn(f'exited with code {EXIT_CODE}', result.errors[0][1])
+
     def test_skipped_without_subprocess_support(self):
         # On a platform without subprocess support the test is skipped in the
         # parent, before any subprocess is spawned.