Repository: python-trio/trio

Issue:
MultiError is un-unpicklable
```python
import trio
import pickle
me = trio.MultiError([ValueError()])
pickle.loads(pickle.dumps(eg))
```
throws a
```shell
----> 1 pickle.loads(pickle.dumps(me))

TypeError: MultiError.__new__() takes 2 positional arguments but 3 were given
```
error, I think because of some conflict with how ExceptionGroups - which expect a message as well as an error list - are constructed.

trio version 0.22.0, python 3.10.6


Discussion:
I cannot reproduce this, tried it on all python versions 3.7-3.11
is it still an issue @andyljones ?
closing this one, feel free to reopen if it can be reproduced
The key to the repro is having 2 exceptions in `MultiError`, otherwise `MultiError.__new__` will just return that one exception
```
me = trio.MultiError([ValueError(), ValueError()])
pickle.loads(pickle.dumps(me))

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: MultiError.__new__() takes 2 positional arguments but 3 were given
```

Tested on master
```
commit 855f5fd72e9e126b8dae2348a918427d7a1f38cd (HEAD -> master, origin/master, origin/HEAD)
Author: John Litborn <11260241+jakkdl@users.noreply.github.com>
Date:   Tue May 16 14:09:02 2023 +0200
```
and Python 3.10.11

Resolving patch:
diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py
--- a/trio/_core/_multierror.py
+++ b/trio/_core/_multierror.py
@@ -213,6 +213,13 @@ def __new__(cls, exceptions, *, _collapse=True):
 
             return super().__new__(cls, "multiple tasks failed", exceptions)
 
+    def __reduce__(self):
+        return (
+            self.__new__,
+            (self.__class__, list(self.exceptions)),
+            {"collapse": self.collapse},
+        )
+
     def __str__(self):
         return ", ".join(repr(exc) for exc in self.exceptions)
 


Test patch:
diff --git a/trio/_core/_tests/test_multierror.py b/trio/_core/_tests/test_multierror.py
--- a/trio/_core/_tests/test_multierror.py
+++ b/trio/_core/_tests/test_multierror.py
@@ -1,15 +1,15 @@
 import gc
-import logging
 import os
 import subprocess
 from pathlib import Path
+import pickle
+import warnings
 
 import pytest
 
 from traceback import (
     extract_tb,
     print_exception,
-    format_exception,
 )
 from traceback import _cause_message  # type: ignore
 import sys
@@ -555,3 +555,36 @@ def test_apport_excepthook_monkeypatch_interaction():
         ["--- 1 ---", "KeyError", "--- 2 ---", "ValueError"],
         stdout,
     )
+
+
+@pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1))
+def test_pickle_multierror(protocol) -> None:
+    # use trio.MultiError to make sure that pickle works through the deprecation layer
+    import trio
+
+    my_except = ZeroDivisionError()
+
+    try:
+        1 / 0
+    except ZeroDivisionError as e:
+        my_except = e
+
+    # MultiError will collapse into different classes depending on the errors
+    for cls, errors in (
+        (ZeroDivisionError, [my_except]),
+        (NonBaseMultiError, [my_except, ValueError()]),
+        (MultiError, [BaseException(), my_except]),
+    ):
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", TrioDeprecationWarning)
+            me = trio.MultiError(errors)  # type: ignore[attr-defined]
+            dump = pickle.dumps(me, protocol=protocol)
+            load = pickle.loads(dump)
+        assert repr(me) == repr(load)
+        assert me.__class__ == load.__class__ == cls
+
+        assert me.__dict__.keys() == load.__dict__.keys()
+        for me_val, load_val in zip(me.__dict__.values(), load.__dict__.values()):
+            # tracebacks etc are not preserved through pickling for the default
+            # exceptions, so we only check that the repr stays the same
+            assert repr(me_val) == repr(load_val)