|
8 | 8 | unpickled in a fresh Python interpreter. |
9 | 9 | """ |
10 | 10 |
|
| 11 | +import functools |
| 12 | +import io |
11 | 13 | import pickle |
12 | 14 | import re |
13 | 15 | import subprocess |
|
17 | 19 | import pytest |
18 | 20 |
|
19 | 21 | from sklearn.callback import ProgressBar, ScoringMonitor |
| 22 | +from sklearn.callback._transport import _message_consumers |
20 | 23 | from sklearn.callback.tests._common.estimators import MaxIterEstimator |
21 | 24 | from sklearn.datasets import make_regression |
22 | 25 |
|
@@ -139,3 +142,78 @@ def test_callbacks_refit_after_load_in_fresh_process(tmp_path, capsys): |
139 | 142 | stdout = result.stdout.decode() |
140 | 143 | assert re.search(r"MaxIterEstimator - fit", stdout) |
141 | 144 | assert re.search(r"100%", stdout) |
| 145 | + |
| 146 | + |
| 147 | +class _TraversalRecorder(pickle.Pickler): |
| 148 | + """A pickler that records the id of every object it walks through. |
| 149 | +
|
| 150 | + `persistent_id` is called for every object the pickler encounters, so an object is |
| 151 | + recorded whichever path leads to it, not only when it is a direct attribute of the |
| 152 | + object being pickled, |
| 153 | + see https://docs.python.org/3/library/pickle.html#pickle.Pickler.persistent_id. |
| 154 | + """ |
| 155 | + |
| 156 | + def __init__(self): |
| 157 | + super().__init__(io.BytesIO(), protocol=pickle.HIGHEST_PROTOCOL) |
| 158 | + self.walked_through = set() |
| 159 | + |
| 160 | + def persistent_id(self, obj): |
| 161 | + self.walked_through.add(id(obj)) |
| 162 | + return None # pickle `obj` as usual |
| 163 | + |
| 164 | + |
| 165 | +def _checked(hook): |
| 166 | + """Wrap a callback hook so that it first checks the callback it is called on. |
| 167 | +
|
| 168 | + The check is that the pickler does not walk through the objects that listener |
| 169 | + threads mutate. They are found through the registered consumers: a consumer is |
| 170 | + normally a method bound to the container it fills, e.g. `self._log.append` for |
| 171 | + ScoringMonitor or `queue.put` for ProgressBar, so the container is what it is bound |
| 172 | + to. A consumer bound to nothing, e.g. a closure, is skipped, since there is then no |
| 173 | + way to tell what it mutates. |
| 174 | + """ |
| 175 | + |
| 176 | + # preserve the signature of the hook because callbacks are validated against it |
| 177 | + @functools.wraps(hook) |
| 178 | + def checked_hook(self, *args, **kwargs): |
| 179 | + # snapshot because another thread may register a listener concurrently |
| 180 | + consumers = list(_message_consumers.values()) |
| 181 | + # the containers are held, not just their ids, which could be reused once freed |
| 182 | + watched = [c.__self__ for c in consumers if hasattr(c, "__self__")] |
| 183 | + |
| 184 | + recorder = _TraversalRecorder() |
| 185 | + recorder.dump(self) |
| 186 | + |
| 187 | + offenders = recorder.walked_through & {id(container) for container in watched} |
| 188 | + assert not offenders, ( |
| 189 | + f"Pickling {self.__class__.__name__} walks through a container that a" |
| 190 | + " listener thread mutates concurrently. Keep it away from the pickler," |
| 191 | + " either by handing over a copy of it in the callback's `__getstate__`, or" |
| 192 | + " by storing it outside of the callback instance." |
| 193 | + ) |
| 194 | + |
| 195 | + return hook(self, *args, **kwargs) |
| 196 | + |
| 197 | + return checked_hook |
| 198 | + |
| 199 | + |
| 200 | +@pytest.mark.parametrize("factory", CALLBACK_FACTORIES) |
| 201 | +def test_listener_state_is_not_walked_by_the_pickler(factory, monkeypatch): |
| 202 | + """Check that pickling a callback never traverses state its listener mutates. |
| 203 | +
|
| 204 | + An estimator carrying a callback can be pickled by a background thread, e.g. loky's |
| 205 | + queue feeder dispatching a task to a worker, while the listener thread of that same |
| 206 | + callback mutates the callback's state as messages come in. Pickling a container that |
| 207 | + another thread mutates breaks the dump, which joblib reports as "Could not pickle |
| 208 | + the task to send it to the workers". |
| 209 | +
|
| 210 | + The check runs from a hook, i.e. while the listeners are up, which is when such a |
| 211 | + dispatch would happen. |
| 212 | + """ |
| 213 | + callback = factory() |
| 214 | + |
| 215 | + for hook_name in ("on_fit_task_begin", "on_fit_task_end"): |
| 216 | + hook = getattr(callback.__class__, hook_name) |
| 217 | + monkeypatch.setattr(callback.__class__, hook_name, _checked(hook)) |
| 218 | + |
| 219 | + MaxIterEstimator(max_iter=3).set_callbacks(callback).fit() |
0 commit comments