From 442621325ca49323819e9a6bb79113a7d6b6ebcc Mon Sep 17 00:00:00 2001 From: SEUNGWOO LEE <69357689+lifrary@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:14:53 +0900 Subject: [PATCH] src/extra.i: fix segfault when a get_cdrawings() callback raises. jm_append_merge() reports a failed callback with messagef() before calling PyErr_Clear(), so messagev() runs with the callback's exception still set. Its function-local statics were initialised on the first call with PyImport_ImportModule("pymupdf"), which returns NULL while an exception is set, and the NULL was then passed to PyObject_GetAttrString(), so the first exception raised by a callback in a process killed the interpreter with SIGSEGV instead of being reported. Once the statics were set, later calls still called pymupdf.message() with the exception set, which prints only part of the message. messagev() now puts any pending exception aside with PyErr_Fetch() before its static variables are initialised, and restores it with PyErr_Restore() after the message is output, discarding any error from the output itself in that case; with no exception pending, an error from the output is left set as before. The behaviour for a failing callback is otherwise unchanged: the failure is reported and the exception is cleared. tests/test_drawings.py:test_cdrawings_callback_exception() runs the case in a child process, because the crash only shows when there has been no earlier messagef() call in the process, and checks the message is output whole. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/extra.i | 14 ++++++++++++++ tests/test_drawings.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) 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