From 2606a57402c501cd20bdcc2775b85ad27c4460a1 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Mon, 22 Jun 2026 00:45:06 +0800 Subject: [PATCH 1/2] gh-151892: Fix crash unpickling interpreters.call() result When call() result reconstruction in the calling interpreter raised (e.g. unpickling failed), the live exception was left set while _PyXI_Exit() applied the preserve-failure override, tripping an assertion. --- Lib/test/test_interpreters/test_api.py | 70 +++++++++++++++++++ ...-06-22-00-43-50.gh-issue-151892.E919BI.rst | 5 ++ Python/crossinterp.c | 15 +++- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-22-00-43-50.gh-issue-151892.E919BI.rst diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index 13d23af5aceb475..9250c02d515c7ee 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -1726,6 +1726,76 @@ def test_call_invalid(self): with self.assertRaises(interpreters.NotShareableError): interp.call(func, op, 'eggs!') + def test_call_result_unpickling_raises(self): + # gh-151892: caller-side result unpickling that raised left the + # exception set when _PyXI_Exit() applied the "preserve failed" + # override, tripping an assertion instead of propagating cleanly. + source = dedent(""" + from concurrent import interpreters + + def _raise_on_unpickle(): + raise ValueError('unpickling failed') + + def _raise_notshareable_on_unpickle(): + raise interpreters.NotShareableError('nope') + + class BadResult: + def __reduce__(self): + return (_raise_on_unpickle, ()) + + class NotShareableResult: + def __reduce__(self): + return (_raise_notshareable_on_unpickle, ()) + + class ReduceRaises: + def __reduce__(self): + raise RuntimeError('reduce failed') + + def make_bad_result(): + return BadResult() + + def make_notshareable_result(): + return NotShareableResult() + + def make_reduce_raises(): + return ReduceRaises() + """) + with import_helper.ready_to_import('unpicklable_result', source) as ( + name, path): + interp = interpreters.create() + interp.prepare_main(_moddir=os.path.dirname(path)) + interp.exec(dedent(f""" + import sys + sys.path.insert(0, _moddir) + import {name} as mod + """)) + + # Guards this fix: caller-side unpickle raises (ValueError). + with self.subTest('unpickle raises'): + with self.assertRaises( + interpreters.NotShareableError) as cm: + interp.call(eval, 'mod.make_bad_result()') + # __cause__ is a cross-interp _PyXI_excinfo wrapper (a generic + # Exception); the original type cannot cross the boundary, so + # don't tighten this to assertIsInstance(..., ValueError). + self.assertIn('ValueError', str(cm.exception.__cause__)) + self.assertIn('unpickling failed', + str(cm.exception.__cause__)) + + # Same branch as above, but the unpickle error is itself a + # NotShareableError; verify it is preserved as the __cause__. + with self.subTest('unpickle raises NotShareableError'): + with self.assertRaises(interpreters.NotShareableError) as cm: + interp.call(eval, 'mod.make_notshareable_result()') + self.assertIn('NotShareableError', + str(cm.exception.__cause__)) + + # Adjacent coverage: callee-side reduce raises (the pre-existing + # capture_session_error path); does not exercise this fix. + with self.subTest('reduce raises'): + with self.assertRaises(interpreters.NotShareableError): + interp.call(eval, 'mod.make_reduce_raises()') + def test_callable_requires_frame(self): # There are various functions that require a current frame. interp = interpreters.create() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-22-00-43-50.gh-issue-151892.E919BI.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-22-00-43-50.gh-issue-151892.E919BI.rst new file mode 100644 index 000000000000000..ac98938673151d3 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-22-00-43-50.gh-issue-151892.E919BI.rst @@ -0,0 +1,5 @@ +Fixed a crash in :meth:`concurrent.interpreters.Interpreter.call` when +reconstructing the result in the calling interpreter raised an exception +(for example when unpickling the returned object failed). The failure is +now reported as :exc:`concurrent.interpreters.NotShareableError` instead of +aborting the interpreter. diff --git a/Python/crossinterp.c b/Python/crossinterp.c index 6b489bf03f86ecd..5761a10a0d4782e 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -2841,10 +2841,23 @@ _PyXI_Exit(_PyXI_session *session, _PyXI_failure *override, PyErr_FormatUnraisable( "Exception ignored while capturing preserved objects"); } + else if (_PyErr_Occurred(tstate)) { + // Restoring the preserved namespace (e.g. unpickling a result) + // raised an exception, which we propagate as the failure. + _PyXI_failure _override = XI_FAILURE_INIT; + _override.code = _PyXI_ERR_PRESERVE_FAILURE; + _propagate_not_shareable_error(tstate, &_override); + PyObject *exc = xi_error_resolve_current_exc(tstate, &_override); + assert(exc != NULL); + failure = xi_error_set_exc(tstate, &err, exc); + Py_DECREF(exc); + if (failure == NULL && _override.code == _PyXI_ERR_NOT_SHAREABLE) { + xi_error_set_override(tstate, &err, &_override); + } + } else { xi_error_set_override_code( tstate, &err, _PyXI_ERR_PRESERVE_FAILURE); - _propagate_not_shareable_error(tstate, err.override); } } if (result != NULL) { From cf9ddc39a784a3699c54623813a1e3376f7bad04 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Mon, 22 Jun 2026 10:09:04 +0800 Subject: [PATCH 2/2] Trim verbose test comments --- Lib/test/test_interpreters/test_api.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index 9250c02d515c7ee..77257f80a09b518 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -1727,9 +1727,8 @@ def test_call_invalid(self): interp.call(func, op, 'eggs!') def test_call_result_unpickling_raises(self): - # gh-151892: caller-side result unpickling that raised left the - # exception set when _PyXI_Exit() applied the "preserve failed" - # override, tripping an assertion instead of propagating cleanly. + # gh-151892: a caller-side result-unpickle failure left the exception + # set in _PyXI_Exit(), tripping an assertion instead of propagating. source = dedent(""" from concurrent import interpreters @@ -1775,9 +1774,8 @@ def make_reduce_raises(): with self.assertRaises( interpreters.NotShareableError) as cm: interp.call(eval, 'mod.make_bad_result()') - # __cause__ is a cross-interp _PyXI_excinfo wrapper (a generic - # Exception); the original type cannot cross the boundary, so - # don't tighten this to assertIsInstance(..., ValueError). + # __cause__ is a generic Exception (the type cannot cross + # interpreters); don't tighten this to assertIsInstance. self.assertIn('ValueError', str(cm.exception.__cause__)) self.assertIn('unpickling failed', str(cm.exception.__cause__))