Skip to content

Commit 6829160

Browse files
cursoragentogrisel
andcommitted
Add Windows ETW integration tests and drop mock-heavy unit tests
Replace mocked ETW lifecycle tests with subprocess integration checks that spawn real Python, OpenMP, and BLAS threads while the kernel tracer is attached. Keep lightweight payload parsing unit tests for all platforms. Add windows_etw_admin_available() and a short flush delay on tracer stop to improve reliability on CI runners. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
1 parent 4bf929f commit 6829160

4 files changed

Lines changed: 160 additions & 111 deletions

File tree

threadpoolctl/_thread_tracer/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,22 @@ def windows_tracer_available():
1111
return sys.platform == "win32"
1212

1313

14+
def windows_etw_admin_available():
15+
"""Return whether the current process can start kernel ETW sessions."""
16+
if not windows_tracer_available():
17+
return False
18+
try:
19+
import ctypes
20+
21+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
22+
except (AttributeError, OSError):
23+
return False
24+
25+
1426
__all__ = [
1527
"ThreadSpawnStats",
1628
"ThreadTracerError",
1729
"WindowsThreadSpawnTracer",
30+
"windows_etw_admin_available",
1831
"windows_tracer_available",
1932
]

threadpoolctl/_thread_tracer/_windows_etw.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import ctypes.wintypes as wt
1212
import sys
1313
import threading
14+
import time
1415
import uuid
1516

1617
from threadpoolctl._thread_tracer._parsing import (
@@ -338,6 +339,7 @@ def stop(self):
338339
return self._stats.snapshot()
339340

340341
self._stop_event.set()
342+
time.sleep(0.2)
341343
if self._trace_handle.value:
342344
CloseTrace(self._trace_handle)
343345
if self._consumer_thread is not None:
Lines changed: 1 addition & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,16 @@
1-
import ctypes as ct
21
import struct
32
import sys
43

54
import pytest
65

7-
from threadpoolctl._thread_tracer import (
8-
ThreadSpawnStats,
9-
ThreadTracerError,
10-
WindowsThreadSpawnTracer,
11-
windows_tracer_available,
12-
)
136
from threadpoolctl._thread_tracer._parsing import (
147
EVENT_TRACE_TYPE_DCSTART,
158
EVENT_TRACE_TYPE_END,
169
EVENT_TRACE_TYPE_START,
1710
classify_kernel_thread_event,
1811
parse_kernel_thread_payload,
1912
)
13+
from threadpoolctl._thread_tracer import windows_tracer_available
2014

2115

2216
def _pack_thread_payload(process_id, thread_id):
@@ -52,107 +46,3 @@ def test_parse_kernel_thread_payload_rejects_short_buffers():
5246

5347
def test_windows_tracer_available_matches_platform():
5448
assert windows_tracer_available() == (sys.platform == "win32")
55-
56-
57-
def test_windows_tracer_start_requires_windows(monkeypatch):
58-
if sys.platform == "win32":
59-
pytest.skip("Platform-specific negative test")
60-
61-
monkeypatch.setattr(
62-
"threadpoolctl._thread_tracer._windows_etw.sys.platform",
63-
"linux",
64-
)
65-
monkeypatch.setattr(
66-
"threadpoolctl._thread_tracer._windows_etw._advapi32",
67-
None,
68-
)
69-
monkeypatch.setattr(
70-
"threadpoolctl._thread_tracer._windows_etw.StartTraceW",
71-
None,
72-
)
73-
74-
tracer = WindowsThreadSpawnTracer(1234)
75-
with pytest.raises(ThreadTracerError, match="only supported on Windows"):
76-
tracer.start()
77-
78-
79-
def test_windows_tracer_lifecycle_with_mocked_etw(monkeypatch):
80-
from threadpoolctl._thread_tracer import _windows_etw as etw
81-
82-
monkeypatch.setattr(etw, "_advapi32", object())
83-
monkeypatch.setattr(etw.sys, "platform", "win32")
84-
85-
spawn_payload = _pack_thread_payload(9001, 12)
86-
existing_payload = _pack_thread_payload(9001, 34)
87-
88-
def _make_record(opcode, payload):
89-
record = etw.EVENT_RECORD()
90-
record.EventHeader.EventDescriptor.Opcode = opcode
91-
record.UserDataLength = len(payload)
92-
buffer = ct.create_string_buffer(payload)
93-
record._payload_buffer = buffer
94-
record.UserData = ct.cast(buffer, ct.c_void_p)
95-
return ct.pointer(record)
96-
97-
events = [
98-
_make_record(etw.EVENT_TRACE_TYPE_DCSTART, existing_payload),
99-
_make_record(etw.EVENT_TRACE_TYPE_START, spawn_payload),
100-
_make_record(etw.EVENT_TRACE_TYPE_START, spawn_payload),
101-
]
102-
event_state = {"index": 0, "callback": None, "stop": False}
103-
104-
def fake_start_trace(session_handle, logger_name, props):
105-
session_handle._obj.value = 1
106-
return etw.ERROR_SUCCESS
107-
108-
def fake_open_trace(trace_logfile):
109-
event_state["callback"] = trace_logfile._obj.EventRecordCallback
110-
return etw.TRACEHANDLE(2)
111-
112-
def fake_process_trace(trace_handle, count, start_time, end_time):
113-
if event_state["stop"]:
114-
return 1
115-
callback = event_state["callback"]
116-
while event_state["index"] < len(events):
117-
callback(events[event_state["index"]])
118-
event_state["index"] += 1
119-
return 1
120-
121-
def fake_close_trace(trace_handle):
122-
event_state["stop"] = True
123-
return etw.ERROR_SUCCESS
124-
125-
def fake_control_trace(session_handle, logger_name, props, control_code):
126-
return etw.ERROR_SUCCESS
127-
128-
monkeypatch.setattr(etw, "StartTraceW", fake_start_trace)
129-
monkeypatch.setattr(etw, "OpenTraceW", fake_open_trace)
130-
monkeypatch.setattr(etw, "ProcessTrace", fake_process_trace)
131-
monkeypatch.setattr(etw, "CloseTrace", fake_close_trace)
132-
monkeypatch.setattr(etw, "ControlTraceW", fake_control_trace)
133-
134-
tracer = WindowsThreadSpawnTracer(9001, include_existing_threads=True)
135-
tracer.start()
136-
stats = tracer.stop()
137-
138-
assert stats == ThreadSpawnStats(
139-
spawn_count=2,
140-
existing_thread_count=1,
141-
thread_ids=frozenset({12, 34}),
142-
)
143-
144-
145-
def test_windows_tracer_reports_existing_kernel_logger_session(monkeypatch):
146-
from threadpoolctl._thread_tracer import _windows_etw as etw
147-
148-
monkeypatch.setattr(etw, "_advapi32", object())
149-
monkeypatch.setattr(etw.sys, "platform", "win32")
150-
151-
def fake_start_trace(session_handle, logger_name, props):
152-
return etw.ERROR_ALREADY_EXISTS
153-
154-
monkeypatch.setattr(etw, "StartTraceW", fake_start_trace)
155-
156-
tracer = WindowsThreadSpawnTracer(1234)
157-
with pytest.raises(ThreadTracerError, match="already in use"):
158-
tracer.start()
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import os
2+
import subprocess
3+
import sys
4+
import textwrap
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from threadpoolctl._thread_tracer import (
10+
ThreadTracerError,
11+
WindowsThreadSpawnTracer,
12+
windows_etw_admin_available,
13+
windows_tracer_available,
14+
)
15+
16+
pytestmark = [
17+
pytest.mark.skipif(
18+
not windows_tracer_available(),
19+
reason="Windows ETW integration tests require Windows",
20+
),
21+
pytest.mark.skipif(
22+
not windows_etw_admin_available(),
23+
reason="Windows ETW kernel tracing requires an elevated process",
24+
),
25+
]
26+
27+
REPO_ROOT = Path(__file__).resolve().parents[2]
28+
TRACER_ATTACH_DELAY_SECONDS = 0.5
29+
TRACER_FLUSH_DELAY_SECONDS = 0.3
30+
SUBPROCESS_TIMEOUT_SECONDS = 60
31+
32+
33+
def _child_script(body):
34+
return textwrap.dedent(
35+
"""
36+
import time
37+
38+
time.sleep({attach_delay})
39+
{body}
40+
time.sleep({flush_delay})
41+
"""
42+
).format(
43+
attach_delay=TRACER_ATTACH_DELAY_SECONDS,
44+
body=textwrap.indent(textwrap.dedent(body).strip(), " "),
45+
flush_delay=TRACER_FLUSH_DELAY_SECONDS,
46+
)
47+
48+
49+
def _run_traced_child(body, minimum_spawn_count):
50+
env = os.environ.copy()
51+
pythonpath = env.get("PYTHONPATH", "")
52+
env["PYTHONPATH"] = (
53+
str(REPO_ROOT)
54+
if not pythonpath
55+
else str(REPO_ROOT) + os.pathsep + pythonpath
56+
)
57+
58+
proc = subprocess.Popen(
59+
[sys.executable, "-c", _child_script(body)],
60+
cwd=str(REPO_ROOT),
61+
env=env,
62+
)
63+
tracer = WindowsThreadSpawnTracer(proc.pid)
64+
try:
65+
tracer.start()
66+
except ThreadTracerError as exc:
67+
proc.kill()
68+
proc.wait(timeout=SUBPROCESS_TIMEOUT_SECONDS)
69+
pytest.fail("failed to start Windows ETW tracer: {0}".format(exc))
70+
71+
try:
72+
return_code = proc.wait(timeout=SUBPROCESS_TIMEOUT_SECONDS)
73+
except subprocess.TimeoutExpired:
74+
proc.kill()
75+
proc.wait(timeout=SUBPROCESS_TIMEOUT_SECONDS)
76+
tracer.stop()
77+
pytest.fail("timed out waiting for traced child process")
78+
79+
stats = tracer.stop()
80+
assert return_code == 0, "traced child exited with code {0}".format(return_code)
81+
assert stats.spawn_count >= minimum_spawn_count, (
82+
"expected at least {expected} thread spawn events, got {actual} ({stats})"
83+
.format(
84+
expected=minimum_spawn_count,
85+
actual=stats.spawn_count,
86+
stats=stats,
87+
)
88+
)
89+
return stats
90+
91+
92+
def test_tracer_counts_python_thread_spawns():
93+
body = """
94+
import threading
95+
96+
num_threads = 3
97+
started = threading.Barrier(num_threads)
98+
99+
def work():
100+
started.wait(timeout=5)
101+
102+
threads = [threading.Thread(target=work) for _ in range(num_threads)]
103+
for thread in threads:
104+
thread.start()
105+
for thread in threads:
106+
thread.join(timeout=5)
107+
"""
108+
_run_traced_child(body, minimum_spawn_count=3)
109+
110+
111+
def test_tracer_counts_openmp_thread_spawns():
112+
try:
113+
from threadpoolctl.tests._openmp_test_helper import check_openmp_n_threads # noqa: F401
114+
except ImportError:
115+
pytest.skip("OpenMP test helper is not built")
116+
117+
body = """
118+
import os
119+
120+
os.environ["OMP_NUM_THREADS"] = "4"
121+
from threadpoolctl.tests._openmp_test_helper import check_openmp_n_threads
122+
123+
used = check_openmp_n_threads(1000)
124+
assert used >= 1
125+
"""
126+
_run_traced_child(body, minimum_spawn_count=4)
127+
128+
129+
def test_tracer_counts_blas_thread_spawns():
130+
pytest.importorskip("numpy")
131+
body = """
132+
import os
133+
134+
os.environ["OMP_NUM_THREADS"] = "1"
135+
os.environ["OPENBLAS_NUM_THREADS"] = "4"
136+
os.environ["MKL_NUM_THREADS"] = "4"
137+
138+
import numpy as np
139+
140+
rng = np.random.RandomState(0)
141+
a = rng.rand(2000, 2000)
142+
np.dot(a, a)
143+
"""
144+
_run_traced_child(body, minimum_spawn_count=2)

0 commit comments

Comments
 (0)