From: Serhiy Storchaka Date: Tue, 4 Aug 2026 10:03:05 +0000 (+0300) Subject: gh-152548: Report a subprocess of runInSubprocess() dying after the test (GH-155162) X-Git-Url: http://git.ipfire.org/index.cgi?a=commitdiff_plain;h=ef0affbf7c1cb8bbc136b65cc0f02985f8c568b5;p=thirdparty%2FPython%2Fcpython.git gh-152548: Report a subprocess of runInSubprocess() dying after the test (GH-155162) 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) --- diff --git a/Lib/test/_isolated_sample.py b/Lib/test/_isolated_sample.py index 360a27a2b081..c89f7145e732 100644 --- a/Lib/test/_isolated_sample.py +++ b/Lib/test/_isolated_sample.py @@ -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() diff --git a/Lib/test/support/isolation.py b/Lib/test/support/isolation.py index f449bf44034d..bc2189329c03 100644 --- a/Lib/test/support/isolation.py +++ b/Lib/test/support/isolation.py @@ -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(). diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 4b9bc245d6f7..2317077b30ac 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -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.