Skip to content

Commit 827ce55

Browse files
committed
fix: enforce thread safety for collector.data
Testing on nogil failed with: ``` 2024-06-13T07:27:44.0416467Z def _clear_data(self) -> None: 2024-06-13T07:27:44.0417153Z """Clear out existing data, but stay ready for more collection.""" 2024-06-13T07:27:44.0417837Z # We used to use self.data.clear(), but that would remove filename 2024-06-13T07:27:44.0418583Z # keys and data values that were still in use higher up the stack 2024-06-13T07:27:44.0419145Z # when we are called as part of switch_context. 2024-06-13T07:27:44.0419639Z > for d in self.data.values(): 2024-06-13T07:27:44.0420219Z E RuntimeError: dictionary changed size during iteration 2024-06-13T07:27:44.0420572Z 2024-06-13T07:27:44.0420753Z coverage/collector.py:258: RuntimeError ```
1 parent af3172b commit 827ce55

File tree

8 files changed

+80
-11
lines changed

8 files changed

+80
-11
lines changed

CHANGES.rst

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ Unreleased
3030
the problem. They are now changed to mention "branch coverage data" and
3131
"statement coverage data."
3232

33+
- Started testing on 3.13 free-threading (nogil) builds of Python. I'm not
34+
claiming full support yet.
35+
3336

3437
.. scriv-start-here
3538

coverage/collector.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import contextlib
89
import functools
910
import os
1011
import sys
@@ -255,14 +256,17 @@ def _clear_data(self) -> None:
255256
# We used to use self.data.clear(), but that would remove filename
256257
# keys and data values that were still in use higher up the stack
257258
# when we are called as part of switch_context.
258-
for d in self.data.values():
259-
d.clear()
259+
with self.data_lock or contextlib.nullcontext():
260+
for d in self.data.values():
261+
d.clear()
260262

261263
for tracer in self.tracers:
262264
tracer.reset_activity()
263265

264266
def reset(self) -> None:
265267
"""Clear collected data, and prepare to collect more."""
268+
self.data_lock = self.threading.Lock() if self.threading else None
269+
266270
# The trace data we are collecting.
267271
self.data: TTraceData = {}
268272

@@ -305,10 +309,22 @@ def reset(self) -> None:
305309

306310
self._clear_data()
307311

312+
def lock_data(self) -> None:
313+
"""Lock self.data_lock, for use by the C tracer."""
314+
if self.data_lock is not None:
315+
self.data_lock.acquire()
316+
317+
def unlock_data(self) -> None:
318+
"""Unlock self.data_lock, for use by the C tracer."""
319+
if self.data_lock is not None:
320+
self.data_lock.release()
321+
308322
def _start_tracer(self) -> TTraceFn | None:
309323
"""Start a new Tracer object, and store it in self.tracers."""
310324
tracer = self._trace_class(**self._core_kwargs)
311325
tracer.data = self.data
326+
tracer.lock_data = self.lock_data
327+
tracer.unlock_data = self.unlock_data
312328
tracer.trace_arcs = self.branch
313329
tracer.should_trace = self.should_trace
314330
tracer.should_trace_cache = self.should_trace_cache

coverage/ctracer/tracer.c

+37-5
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ CTracer_dealloc(CTracer *self)
9696
Py_XDECREF(self->should_trace_cache);
9797
Py_XDECREF(self->should_start_context);
9898
Py_XDECREF(self->switch_context);
99+
Py_XDECREF(self->lock_data);
100+
Py_XDECREF(self->unlock_data);
99101
Py_XDECREF(self->context);
100102
Py_XDECREF(self->disable_plugin);
101103

@@ -470,26 +472,39 @@ CTracer_handle_call(CTracer *self, PyFrameObject *frame)
470472
}
471473

472474
if (tracename != Py_None) {
473-
PyObject * file_data = PyDict_GetItem(self->data, tracename);
475+
PyObject * file_data;
476+
BOOL had_error = FALSE;
477+
PyObject * res;
478+
479+
res = PyObject_CallFunctionObjArgs(self->lock_data, NULL);
480+
if (res == NULL) {
481+
goto error;
482+
}
483+
484+
file_data = PyDict_GetItem(self->data, tracename);
474485

475486
if (file_data == NULL) {
476487
if (PyErr_Occurred()) {
477-
goto error;
488+
had_error = TRUE;
489+
goto unlock;
478490
}
479491
file_data = PySet_New(NULL);
480492
if (file_data == NULL) {
481-
goto error;
493+
had_error = TRUE;
494+
goto unlock;
482495
}
483496
ret2 = PyDict_SetItem(self->data, tracename, file_data);
484497
if (ret2 < 0) {
485-
goto error;
498+
had_error = TRUE;
499+
goto unlock;
486500
}
487501

488502
/* If the disposition mentions a plugin, record that. */
489503
if (file_tracer != Py_None) {
490504
ret2 = PyDict_SetItem(self->file_tracers, tracename, plugin_name);
491505
if (ret2 < 0) {
492-
goto error;
506+
had_error = TRUE;
507+
goto unlock;
493508
}
494509
}
495510
}
@@ -498,6 +513,17 @@ CTracer_handle_call(CTracer *self, PyFrameObject *frame)
498513
Py_INCREF(file_data);
499514
}
500515

516+
unlock:
517+
518+
res = PyObject_CallFunctionObjArgs(self->unlock_data, NULL);
519+
if (res == NULL) {
520+
goto error;
521+
}
522+
523+
if (had_error) {
524+
goto error;
525+
}
526+
501527
Py_XDECREF(self->pcur_entry->file_data);
502528
self->pcur_entry->file_data = file_data;
503529
self->pcur_entry->file_tracer = file_tracer;
@@ -1039,6 +1065,12 @@ CTracer_members[] = {
10391065
{ "switch_context", T_OBJECT, offsetof(CTracer, switch_context), 0,
10401066
PyDoc_STR("Function for switching to a new context.") },
10411067

1068+
{ "lock_data", T_OBJECT, offsetof(CTracer, lock_data), 0,
1069+
PyDoc_STR("Function for locking access to self.data.") },
1070+
1071+
{ "unlock_data", T_OBJECT, offsetof(CTracer, unlock_data), 0,
1072+
PyDoc_STR("Function for unlocking access to self.data.") },
1073+
10421074
{ "disable_plugin", T_OBJECT, offsetof(CTracer, disable_plugin), 0,
10431075
PyDoc_STR("Function for disabling a plugin.") },
10441076

coverage/ctracer/tracer.h

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ typedef struct CTracer {
2727
PyObject * trace_arcs;
2828
PyObject * should_start_context;
2929
PyObject * switch_context;
30+
PyObject * lock_data;
31+
PyObject * unlock_data;
3032
PyObject * disable_plugin;
3133

3234
/* Has the tracer been started? */

coverage/pytracer.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ def __init__(self) -> None:
6868
self.should_trace_cache: dict[str, TFileDisposition | None]
6969
self.should_start_context: Callable[[FrameType], str | None] | None = None
7070
self.switch_context: Callable[[str | None], None] | None = None
71+
self.lock_data: Callable[[], None]
72+
self.unlock_data: Callable[[], None]
7173
self.warn: TWarnFn
7274

7375
# The threading module to use, if any.
@@ -209,8 +211,12 @@ def _trace(
209211
if disp.trace:
210212
tracename = disp.source_filename
211213
assert tracename is not None
212-
if tracename not in self.data:
213-
self.data[tracename] = set()
214+
self.lock_data()
215+
try:
216+
if tracename not in self.data:
217+
self.data[tracename] = set()
218+
finally:
219+
self.unlock_data()
214220
self.cur_file_data = self.data[tracename]
215221
else:
216222
frame.f_trace_lines = False

coverage/sysmon.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ def __init__(self, tool_id: int) -> None:
186186
# Change tests/testenv.py:DYN_CONTEXTS when this is updated.
187187
self.should_start_context: Callable[[FrameType], str | None] | None = None
188188
self.switch_context: Callable[[str | None], None] | None = None
189+
self.lock_data: Callable[[], None]
190+
self.unlock_data: Callable[[], None]
189191
# TODO: warn is unused.
190192
self.warn: TWarnFn
191193

@@ -317,8 +319,12 @@ def sysmon_py_start(self, code: CodeType, instruction_offset: int) -> MonitorRet
317319
if tracing_code:
318320
tracename = disp.source_filename
319321
assert tracename is not None
320-
if tracename not in self.data:
321-
self.data[tracename] = set()
322+
self.lock_data()
323+
try:
324+
if tracename not in self.data:
325+
self.data[tracename] = set()
326+
finally:
327+
self.unlock_data()
322328
file_data = self.data[tracename]
323329
b2l = bytes_to_lines(code)
324330
else:

coverage/tracer.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class CTracer(TracerCore):
2929
should_trace: Any
3030
should_trace_cache: Any
3131
switch_context: Any
32+
lock_data: Any
33+
unlock_data: Any
3234
trace_arcs: Any
3335
warn: Any
3436
def __init__(self) -> None: ...

coverage/types.py

+2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class TracerCore(Protocol):
8787
should_trace_cache: Mapping[str, TFileDisposition | None]
8888
should_start_context: Callable[[FrameType], str | None] | None
8989
switch_context: Callable[[str | None], None] | None
90+
lock_data: Callable[[], None]
91+
unlock_data: Callable[[], None]
9092
warn: TWarnFn
9193

9294
def __init__(self) -> None:

0 commit comments

Comments
 (0)