diff --git a/src/extra.i b/src/extra.i index 9808ac119..a72f8c13f 100644 --- a/src/extra.i +++ b/src/extra.i @@ -221,6 +221,13 @@ int asprintf(char** str, const char* fmt, ...) static void messagev(const char* format, va_list va) { + // Put aside any pending exception before the static variables below are + // initialised: with one set, PyImport_ImportModule() returns NULL. + PyObject* exc_type; + PyObject* exc_value; + PyObject* exc_traceback; + PyErr_Fetch(&exc_type, &exc_value, &exc_traceback); + static PyObject* pymupdf_module = PyImport_ImportModule("pymupdf"); static PyObject* message_fn = PyObject_GetAttrString(pymupdf_module, "message"); char* text; @@ -232,6 +239,13 @@ static void messagev(const char* format, va_list va) Py_XDECREF(args); Py_XDECREF(text_py); free(text); + + if (exc_type) + { + // The caller's exception takes precedence; PyErr_Restore() discards + // any error from outputting the message. + PyErr_Restore(exc_type, exc_value, exc_traceback); + } } static void messagef(const char* format, ...) diff --git a/tests/test_drawings.py b/tests/test_drawings.py index 0859805fd..8956443bc 100644 --- a/tests/test_drawings.py +++ b/tests/test_drawings.py @@ -6,6 +6,7 @@ import os import sys import pprint +import subprocess import pymupdf @@ -264,3 +265,36 @@ def test_4954_2(): d = document[0].get_drawings()[-1] print(f'{d["width"]=}') # Expected: 2.0, Actual: 1.0 assert abs(d['width'] - 2.449) < 0.01 + + +def test_cdrawings_callback_exception(): + """An exception raised by a get_cdrawings() callback must not crash Python. + + It used to: jm_append_merge() reports the failed callback via messagef() + while the exception is still set, so messagev()'s import of pymupdf + returned NULL, which was then dereferenced. + """ + if os.environ.get('PYODIDE_ROOT'): + print('test_cdrawings_callback_exception(): not running on Pyodide - cannot run child processes.') + return + # This bug is a segv, and only shows if there has been no earlier + # messagef() call in the process, so we run the test in a child process. + code = ( + 'import pymupdf\n' + 'document = pymupdf.open()\n' + 'page = document.new_page()\n' + 'page.draw_line((10, 10), (100, 100))\n' + 'def callback(path):\n' + ' raise ValueError("callback failed")\n' + 'page.get_cdrawings(callback=callback)\n' + 'print("returned")\n' + ) + env = os.environ.copy() + env.pop('PYMUPDF_MESSAGE', None) + cp = subprocess.run([sys.executable, '-c', code], capture_output=True, text=True, check=0, env=env) + print(f'{cp.returncode=}') + print(f'{cp.stdout=}') + print(f'{cp.stderr=}') + assert cp.returncode == 0 + assert 'calling cdrawings callback function/method failed!\n' in cp.stdout + assert 'returned' in cp.stdout