From ca91030bcb75c799f6147c0772ad6fc5fb3fde29 Mon Sep 17 00:00:00 2001 From: Cheruku Sri Charan Reddy <185475071+sricharanreddycheruku@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:14:13 +0000 Subject: [PATCH] fix: preserve vector and stream scan callback exceptions --- docs/usage.md | 9 +++++++-- src/hyperscan/extension.c | 12 ++++++++++-- tests/test_hyperscan.py | 38 ++++++++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index a4ec38d..649f320 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -80,8 +80,13 @@ any Python object passed to a ``scan`` method. The return value determines whether or not Hyperscan should halt scanning. If the match handler returns anything other than ``None`` -that is *truthy*, scanning will be halted and any subsequent calls to -``Database.scan`` or ``Stream.scan`` will throw a ``hyperscan.error``. +that is *truthy*, the scan raises ``hyperscan.ScanTerminated``. A terminated +stream also raises this exception on subsequent ``Stream.scan`` calls; +block and vectored databases can be scanned again. + +Exceptions raised by a callback, including errors when evaluating its +return value's truthiness, propagate from ``Database.scan`` or ``Stream.scan`` +without being replaced by ``ScanTerminated``. ## Pattern Scanning diff --git a/src/hyperscan/extension.c b/src/hyperscan/extension.c index e072d10..e7926be 100644 --- a/src/hyperscan/extension.c +++ b/src/hyperscan/extension.c @@ -244,7 +244,9 @@ static int hs_match_handler( cctx->success = 0; } else { halt = rv == Py_None ? 0 : PyObject_IsTrue(rv); - cctx->success = 1; + cctx->success = halt >= 0; + if (halt < 0) + halt = 1; } Py_XDECREF(rv); PyGILState_Release(gstate); @@ -285,7 +287,9 @@ static int ch_match_handler( cctx->success = 0; } else { halt = rv == Py_None ? 0 : PyObject_IsTrue(rv); - cctx->success = 1; + cctx->success = halt >= 0; + if (halt < 0) + halt = 1; } Py_XDECREF(rv); Py_XDECREF(ocaptured); @@ -772,6 +776,8 @@ static PyObject *Database_scan(Database *self, PyObject *args, PyObject *kwds) PyMem_RawFree(data); PyMem_RawFree(lengths); Py_XDECREF(fast_seq); + if (PyErr_Occurred()) + HS_LOCK_RETURN_NULL(); HANDLE_HYPERSCAN_ERR(hs_err, NULL); } else { if (!PyObject_CheckBuffer(odata)) { @@ -1182,6 +1188,8 @@ static PyObject *Stream_scan(Stream *self, PyObject *args, PyObject *kwds) ocallback == Py_None ? NULL : (void *)&cctx); Py_END_ALLOW_THREADS; PyBuffer_Release(&view); + if (PyErr_Occurred()) + HS_LOCK_RETURN_NULL(); HANDLE_HYPERSCAN_ERR(hs_err, NULL); } diff --git a/tests/test_hyperscan.py b/tests/test_hyperscan.py index fcfac2d..1fddcc1 100644 --- a/tests/test_hyperscan.py +++ b/tests/test_hyperscan.py @@ -397,11 +397,41 @@ def test_database_deserialize_scan(db_fixture_name, request, mocker): db.scan(buf, match_event_handler=callback) -def test_database_exception_in_callback(database_block, mocker): - callback = mocker.Mock(side_effect=RuntimeError("oops")) +@pytest.mark.parametrize( + "db_fixture_name", + ["database_block", "database_vector", "database_stream", "database_chimera"], +) +@pytest.mark.parametrize("failure", ["raise", "bool", "halt"]) +def test_database_exception_in_callback(db_fixture_name, failure, request, mocker): + database = request.getfixturevalue(db_fixture_name) + error = RuntimeError("oops") - with pytest.raises(RuntimeError, match=r"^oops$"): - database_block.scan(b"foobar", match_event_handler=callback) + class InvalidTruth: + def __bool__(self): + raise error + + if failure == "raise": + callback = mocker.Mock(side_effect=error) + else: + callback = mocker.Mock( + return_value=True if failure == "halt" else InvalidTruth() + ) + expected = hyperscan.ScanTerminated if failure == "halt" else RuntimeError + + with pytest.raises(expected) as caught: + if db_fixture_name == "database_stream": + with database.stream(match_event_handler=callback) as stream: + stream.scan(b"foobar") + else: + data = ( + [b"foo", b"bar"] if db_fixture_name == "database_vector" else b"foobar" + ) + database.scan(data, match_event_handler=callback) + if failure != "halt": + assert caught.value is error + callback.assert_called_once() + if db_fixture_name != "database_stream": + database.scan(data, match_event_handler=lambda *args: None) def test_literal_expressions(mocker):