Skip to content

Commit 960240d

Browse files
test fix
1 parent 2b30cd4 commit 960240d

6 files changed

Lines changed: 64 additions & 36 deletions

File tree

report.md

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,27 @@
1-
# Segfault in `instrument_hooks_deinit` during Python shutdown
1+
# Segfault in instrument hooks during Python shutdown
22

33
## Issue
44

55
CI job `tests (valgrind, 3.13, >=8.1.1)` crashes with exit code 139 (SIGSEGV) after all tests pass.
6-
The segfault occurs during Python's shutdown/GC phase, not during test execution.
76

87
Failing CI run: https://github.com/CodSpeedHQ/pytest-codspeed/actions/runs/23649460934/job/68890119732
98

10-
## Root cause (suspected)
9+
## Root cause
1110

12-
`InstrumentHooks.__del__` calls `instrument_hooks_deinit()` via cffi during Python shutdown.
13-
When multiple `InstrumentHooks` instances are created (e.g. the xdist test loops 5 times, each creating a new instance), their destructors fire during GC at exit and crash — likely because the native library or cffi runtime is already partially torn down.
11+
Calling `sys.activate_stack_trampoline("perf")` multiple times after loading a cffi native library causes a segfault at process exit on CPython 3.13.12 (works fine on 3.13.11). This is a CPython regression.
1412

15-
## Stack trace (from CI)
13+
`InstrumentHooks.__init__` unconditionally calls `sys.activate_stack_trampoline("perf")`. When multiple instances are created (e.g. the xdist test loops 5 times inprocess), the repeated activation triggers the crash.
1614

17-
```
18-
Fatal Python error: Segmentation fault
19-
File "plugin.py", line 148 in pytest_configure
20-
...
21-
File "test_pytest_plugin_cpu_instrumentation.py", line 116 in test_pytest_xdist_concurrency_compatibility
22-
```
15+
## Fix
2316

24-
## Minimal repro
17+
Guard the trampoline activation with `sys.is_stack_trampoline_active()` in `InstrumentHooks.__init__`:
2518

2619
```python
27-
# repro.py
28-
import os
29-
os.environ["CODSPEED_ENV"] = "1"
30-
31-
from pytest_codspeed.instruments.hooks import InstrumentHooks
32-
33-
for i in range(5):
34-
h = InstrumentHooks()
35-
print(f"Created instance {i}: {h.instance}")
36-
37-
del h
38-
print("Exiting...")
39-
```
40-
41-
```
42-
uv run python repro.py
43-
# Segfaults at exit with code 139
20+
if SUPPORTS_PERF_TRAMPOLINE and not sys.is_stack_trampoline_active():
21+
sys.activate_stack_trampoline("perf")
4422
```
4523

46-
## Key files
24+
## Verification
4725

48-
- `src/pytest_codspeed/instruments/hooks/__init__.py``InstrumentHooks.__del__` calls `instrument_hooks_deinit`
49-
- `src/pytest_codspeed/instruments/hooks/build.py` — cffi bindings for the native library
50-
- `src/pytest_codspeed/instruments/analysis.py` — creates `InstrumentHooks` in `__init__`
51-
- `tests/test_pytest_plugin_cpu_instrumentation.py::test_pytest_xdist_concurrency_compatibility` — triggers the bug by creating 5 successive instances
26+
- `uv run --python 3.13.12 python repro.py` → exit 0 (was exit 139)
27+
- `test_pytest_xdist_concurrency_compatibility` → PASSED, exit 0 (was exit 139)

repro1_single_no_delete.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Test 1: Single instance, no explicit delete — crash at shutdown?"""
2+
import os
3+
os.environ["CODSPEED_ENV"] = "1"
4+
from pytest_codspeed.instruments.hooks import InstrumentHooks
5+
6+
h = InstrumentHooks()
7+
print(f"Created: {h.instance}")
8+
print("Exiting (no explicit delete)...")

repro2_single_explicit_deinit.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Test 2: Single instance, explicit deinit + del — crash?"""
2+
import os
3+
os.environ["CODSPEED_ENV"] = "1"
4+
from pytest_codspeed.instruments.hooks import InstrumentHooks
5+
6+
h = InstrumentHooks()
7+
print(f"Created: {h.instance}")
8+
h.lib.instrument_hooks_deinit(h.instance)
9+
print("Deinited explicitly")
10+
del h
11+
print("Deleted, exiting...")

repro3_multi_eager_cleanup.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Test 3: Multiple instances, explicit deinit before exit — does eager cleanup fix it?"""
2+
import os
3+
os.environ["CODSPEED_ENV"] = "1"
4+
from pytest_codspeed.instruments.hooks import InstrumentHooks
5+
6+
instances = []
7+
for i in range(5):
8+
h = InstrumentHooks()
9+
print(f"Created instance {i}: {h.instance}")
10+
instances.append(h)
11+
12+
for i, h in enumerate(instances):
13+
h.lib.instrument_hooks_deinit(h.instance)
14+
# Prevent __del__ from calling deinit again
15+
del h.instance
16+
del h.lib
17+
print(f"Cleaned up instance {i}")
18+
19+
del instances
20+
print("Exiting...")

repro4_multi_skip_deinit.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Test 4: Multiple instances, skip deinit entirely — confirm deinit is the crash site."""
2+
import os
3+
os.environ["CODSPEED_ENV"] = "1"
4+
from pytest_codspeed.instruments.hooks import InstrumentHooks
5+
6+
# Monkey-patch __del__ to do nothing
7+
InstrumentHooks.__del__ = lambda self: print(f"Skipping deinit for {self.instance}")
8+
9+
for i in range(5):
10+
h = InstrumentHooks()
11+
print(f"Created instance {i}: {h.instance}")
12+
13+
print("Exiting (deinit skipped)...")

src/pytest_codspeed/instruments/hooks/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(self) -> None:
3737
if self.instance == 0:
3838
raise RuntimeError("Failed to initialize CodSpeed instrumentation library.")
3939

40-
if SUPPORTS_PERF_TRAMPOLINE:
40+
if SUPPORTS_PERF_TRAMPOLINE and not sys.is_stack_trampoline_active():
4141
sys.activate_stack_trampoline("perf") # type: ignore
4242

4343
def __del__(self):

0 commit comments

Comments
 (0)