This is an automated email from the ASF dual-hosted git repository.

tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git


The following commit(s) were added to refs/heads/main by this push:
     new 32ab66ee [FIX] Make ffi.Error picklable (#709)
32ab66ee is described below

commit 32ab66ee756997208e7666841eb77b6de5e8420d
Author: Yaxing Cai <[email protected]>
AuthorDate: Tue Aug 25 02:28:50 2026 +0800

    [FIX] Make ffi.Error picklable (#709)
    
    ## Motivation
    
    `Error.py_error()` attaches the FFI error object to the Python exception
    it raises:
    
    ```python
    py_error.__tvm_ffi_error__ = self
    ```
    
    `BaseException.__reduce__` includes the instance `__dict__`, so pickling
    an FFI-originated exception pickles that `Error` through
    `CObject.__getstate__` → `ToJSONGraph`. `ffi.Error` has no native
    creator and no `__ffi_new__` type attr, so this fails:
    
    ```
    TypeError: Type `ffi.Error` does not support ToJSONGraph
               (no native creator or __ffi_new__ type attr)
    ```
    
    Harnesses that ship exceptions across a process boundary lose the
    original error and report this instead. It was reported downstream
    against a CUDA IMA (error 700), where the replacement message made it
    look like tvm-ffi had suppressed a coredump — it had not; the coredump
    happens before the error is raised, and the only real defect is that the
    exception could not be pickled.
    
    `copy.deepcopy` hits the same path.
    
    ## Changes
    
    `ffi.Error` now pickles by value. An error is fully described by its
    `(kind, message, backtrace)` strings, so `Error.__reduce__` reconstructs
    it through `__init__` rather than the JSON graph. This mirrors `Device`
    and `DataType`, which already carry hand-written `__reduce__`
    implementations for the same reason.
    
    `extra_context` is intentionally dropped — it may hold arbitrary native
    payloads, and preserving it only when it happens to be serializable
    would make pickling an error depend on where the error came from.
    
    The attribute stays a live `Error` rather than degrading to `None`,
    because `set_last_ffi_error` calls `update_backtrace` on
    `__tvm_ffi_error__` when a restored exception propagates back through
    the FFI; a `None` there would fail on an unrelated `hasattr` check that
    is already in the code.
    
    ## Not in scope
    
    An earlier revision of this PR also rewrote the message raised when
    pickling types that genuinely cannot round-trip (`ffi.Function`,
    `ffi.Module`, `ffi.Tensor`, `ffi.OpaquePyObject`). Per review, that is
    dropped here and can be revisited separately — this PR is now only the
    `Error` fix. Those types are unchanged and still surface the
    serializer's own message.
    
    ## Testing
    
    New tests in `tests/python/test_error.py`: exception and bare-`Error`
    pickle round trips, `deepcopy`, NULL handle, the `extra_context` drop,
    and re-propagating a restored exception through an FFI call (which
    exercises `set_last_ffi_error`'s `update_backtrace` on the restored
    object).
    
    Full Python suite: 2421 passed, 52 skipped, 2 xfailed. `pre-commit`
    clean on all changed files.
---
 python/tvm_ffi/core.pyi         |  1 +
 python/tvm_ffi/cython/error.pxi | 19 +++++++++
 tests/python/test_error.py      | 91 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 111 insertions(+)

diff --git a/python/tvm_ffi/core.pyi b/python/tvm_ffi/core.pyi
index 668be688..1ebed00d 100644
--- a/python/tvm_ffi/core.pyi
+++ b/python/tvm_ffi/core.pyi
@@ -88,6 +88,7 @@ def _type_index_to_type_info(type_index: int) -> TypeInfo | 
None: ...
 
 class Error(Object):
     def __init__(self, kind: str, message: str, backtrace: str) -> None: ...
+    def __reduce__(self) -> Any: ...
     def update_backtrace(self, backtrace: str) -> None: ...
     def py_error(self) -> BaseException: ...
     @property
diff --git a/python/tvm_ffi/cython/error.pxi b/python/tvm_ffi/cython/error.pxi
index 16390ed3..224cf4d2 100644
--- a/python/tvm_ffi/cython/error.pxi
+++ b/python/tvm_ffi/cython/error.pxi
@@ -69,6 +69,25 @@ cdef class Error(CObject):
             raise MemoryError("Failed to create error object")
         (<CObject>self).chandle = out
 
+    def __reduce__(self):
+        """Pickle by value from ``(kind, message, backtrace)``.
+
+        ``ffi.Error`` has no JSON-graph creator, so the inherited
+        :py:meth:`CObject.__reduce__` cannot serialize it. Because an error is
+        fully described by its three strings, reconstruct it through
+        :py:meth:`__init__` instead.
+
+        Notes
+        -----
+        :py:attr:`extra_context` is intentionally dropped: it may hold 
arbitrary
+        native payloads that have no value representation. Preserving it only
+        when it happens to be serializable would make pickling an error depend
+        on where the error came from.
+        """
+        if self.chandle == NULL:
+            return (_new_object, (Error,))
+        return (Error, (self.kind, self.message, self.backtrace))
+
     def update_backtrace(self, backtrace: str) -> None:
         """Replace the stored backtrace string with ``backtrace``.
 
diff --git a/tests/python/test_error.py b/tests/python/test_error.py
index 70d1f0a2..5e48973e 100644
--- a/tests/python/test_error.py
+++ b/tests/python/test_error.py
@@ -16,7 +16,9 @@
 # under the License.
 
 
+import copy
 import gc
+import pickle
 import weakref
 from typing import NoReturn
 
@@ -151,3 +153,92 @@ def test_error_no_cyclic_reference() -> None:
     finally:
         # re-enable gc whenever exception occurs
         gc.enable()
+
+
+def _raise_from_cxx() -> BaseException:
+    """Return a Python exception produced by a C++-side FFI error."""
+    test_raise_error = tvm_ffi.get_global_func("testing.test_raise_error")
+    try:
+        test_raise_error("ValueError", "error XYZ")
+    except ValueError as e:
+        return e
+    raise AssertionError("expected the FFI call to raise")
+
+
+def test_exception_pickle_roundtrip() -> None:
+    """Exceptions carrying ``__tvm_ffi_error__`` must survive pickling.
+
+    Regression test: ``ffi.Error`` has no JSON-graph creator, so the inherited
+    ``CObject.__reduce__`` used to fail with a ``ToJSONGraph`` ``TypeError``
+    whenever a test harness pickled an FFI-originated exception.
+    """
+    err = _raise_from_cxx()
+    restored = pickle.loads(pickle.dumps(err))
+
+    assert isinstance(restored, ValueError)
+    assert restored.args == err.args
+    ffi_error = restored.__tvm_ffi_error__  # ty: ignore[unresolved-attribute]
+    assert isinstance(ffi_error, tvm_ffi.core.Error)
+    assert ffi_error.kind == "ValueError"
+    assert ffi_error.message == "error XYZ"
+    assert ffi_error.backtrace.find("TestRaiseError") != -1
+
+
+def test_exception_deepcopy_roundtrip() -> None:
+    """``copy.deepcopy`` goes through ``__reduce_ex__`` and must work too."""
+    err = _raise_from_cxx()
+    restored = copy.deepcopy(err)
+
+    assert isinstance(restored, ValueError)
+    assert restored.__tvm_ffi_error__.kind == "ValueError"  # ty: 
ignore[unresolved-attribute]
+    assert restored.__tvm_ffi_error__.message == "error XYZ"  # ty: 
ignore[unresolved-attribute]
+
+
+def test_ffi_error_pickle_roundtrip() -> None:
+    """A bare :class:`tvm_ffi.core.Error` round-trips by value."""
+    error = tvm_ffi.core.Error("TypeError", "boom", 'File "a.py", line 1, in 
f\n')
+    restored = pickle.loads(pickle.dumps(error))
+
+    assert isinstance(restored, tvm_ffi.core.Error)
+    assert restored.kind == "TypeError"
+    assert restored.message == "boom"
+    assert restored.backtrace == 'File "a.py", line 1, in f\n'
+    # a fresh object, not the original handle
+    assert not restored.same_as(error)
+
+
+def test_ffi_error_pickle_null_handle() -> None:
+    """An ``Error`` with a NULL handle round-trips without dereferencing it."""
+    error = tvm_ffi.core.Error.__new__(tvm_ffi.core.Error)
+    assert error.__chandle__() == 0
+
+    restored = pickle.loads(pickle.dumps(error))
+    assert isinstance(restored, tvm_ffi.core.Error)
+    assert restored.__chandle__() == 0
+
+
+def test_ffi_error_pickle_drops_extra_context() -> None:
+    """``extra_context`` is intentionally not preserved across pickling.
+
+    It may hold arbitrary native payloads with no value representation, so
+    dropping it keeps pickling an error independent of where it came from.
+    """
+    error = tvm_ffi.core.Error("ValueError", "boom", "")
+    restored = pickle.loads(pickle.dumps(error))
+    assert restored.extra_context is None
+
+
+def test_restored_exception_can_propagate_through_ffi() -> None:
+    """An unpickled exception still works as an FFI error payload.
+
+    ``set_last_ffi_error`` calls ``update_backtrace`` on ``__tvm_ffi_error__``,
+    so the attribute must survive as a live ``Error``, not as ``None``.
+    """
+    restored = pickle.loads(pickle.dumps(_raise_from_cxx()))
+
+    def callback(_: int) -> NoReturn:
+        raise restored
+
+    fapply = tvm_ffi.convert(callback)
+    with pytest.raises(ValueError, match="error XYZ"):
+        fapply(1)

Reply via email to