Skip to content

Commit 7836ac5

Browse files
committed
Add GDI handle load test and stress test
Add scripts/bench_gdi_loadtest.py: a standalone benchmark that simulates the workload of a large test suite (e.g. DataLab's) by repeatedly creating, rendering and destroying QwtPlot windows with most PythonQwt QObject-derived classes (QwtPlotCurve, QwtPlotGrid, QwtPlotMarker, QwtLegend, QwtScaleWidget, QwtSymbol, QwtText with varied fonts, etc.). On Windows, it tracks GDI handle count via GetGuiResources to detect resource leaks caused by unbounded caches. Add qwt/tests/test_issue107_gdi_leak.py: a pytest-based stress test that runs 50 create/render/destroy cycles in unattended mode. Usage: python scripts/bench_gdi_loadtest.py --cycles 200 --threshold 500 Exit code 1 if GDI growth exceeds threshold.
1 parent 2455093 commit 7836ac5

2 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the Qwt License
4+
# (see LICENSE file for more details)
5+
6+
"""
7+
Test for issue 107: GDI handle exhaustion on Windows.
8+
"""
9+
10+
import numpy as np
11+
import pytest
12+
from qtpy import QtCore as QC
13+
from qtpy import QtWidgets as QW
14+
15+
from qwt import QwtPlot, QwtPlotCurve
16+
from qwt.tests.utils import TestEnvironment
17+
18+
19+
def run_stress_cycle():
20+
"""Run one cycle of plot creation, rendering and destruction."""
21+
plot = QwtPlot()
22+
plot.resize(QC.QSize(800, 600))
23+
plot.show()
24+
25+
# Create many curves with different fonts (via titles) to pressure caches
26+
for i in range(20):
27+
curve = QwtPlotCurve(f"Curve {i}")
28+
x = np.linspace(0, 10, 100)
29+
y = np.sin(x + i / 10.0)
30+
curve.setData(x, y)
31+
curve.attach(plot)
32+
33+
plot.replot()
34+
QW.QApplication.processEvents()
35+
36+
plot.close()
37+
plot.deleteLater()
38+
QW.QApplication.processEvents()
39+
40+
41+
def test_gdi_leak_stability():
42+
"""
43+
Repeatedly create/render/destroy plots to check for GDI leak stability.
44+
On Windows, without the fix, this would crash after a certain number of cycles
45+
due to GDI handle exhaustion.
46+
"""
47+
env = TestEnvironment()
48+
if not env.unattended:
49+
pytest.skip("This test is for CI/unattended mode only")
50+
51+
n_cycles = 50
52+
for i in range(n_cycles):
53+
run_stress_cycle()
54+
55+
56+
if __name__ == "__main__":
57+
app = QW.QApplication.instance() or QW.QApplication([])
58+
test_gdi_leak_stability()

scripts/bench_gdi_loadtest.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (c) 2026 CEA, Codra
4+
# Licensed under the terms of the MIT License
5+
# (see LICENSE file for more details)
6+
7+
"""GDI handle load test for PythonQwt (issue #107).
8+
9+
Simulates the workload of a large test suite (e.g. DataLab's) by repeatedly
10+
creating, rendering, and destroying QwtPlot windows with a variety of
11+
PythonQwt objects attached. On Windows, tracks GDI handle count via the
12+
Windows API to detect handle leaks caused by unbounded caches in
13+
QwtPlainTextEngine.
14+
15+
Background
16+
----------
17+
Before the fix for issue #107, ``QwtPlainTextEngine`` kept an unbounded font
18+
metrics cache that grew with every distinct font configuration. Over hundreds
19+
of plot create/destroy cycles (as in a large test suite), this exhausted the
20+
per-process GDI handle limit (~10 000), causing Qt paint failures or crashes.
21+
22+
Usage
23+
-----
24+
::
25+
26+
# Run with default 100 cycles, threshold 500 GDI handles:
27+
python scripts/bench_gdi_loadtest.py
28+
29+
# Run 500 cycles with verbose output:
30+
python scripts/bench_gdi_loadtest.py --cycles 500 --verbose
31+
32+
# Custom threshold:
33+
python scripts/bench_gdi_loadtest.py --cycles 200 --threshold 300
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import argparse
39+
import os
40+
import sys
41+
import time
42+
43+
import numpy as np
44+
45+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
46+
47+
# ---------------------------------------------------------------------------
48+
# GDI monitoring (Windows only)
49+
# ---------------------------------------------------------------------------
50+
51+
_IS_WINDOWS = sys.platform == "win32"
52+
53+
if _IS_WINDOWS:
54+
import ctypes
55+
import ctypes.wintypes
56+
57+
_user32 = ctypes.windll.user32
58+
_kernel32 = ctypes.windll.kernel32
59+
60+
_GR_GDIOBJECTS = 0
61+
_GR_USEROBJECTS = 1
62+
_PROCESS_QUERY_INFORMATION = 0x0400
63+
64+
def get_gdi_count(pid: int | None = None) -> int:
65+
"""Return the number of GDI objects for the given process."""
66+
if pid is None:
67+
pid = os.getpid()
68+
handle = _kernel32.OpenProcess(_PROCESS_QUERY_INFORMATION, False, pid)
69+
if not handle:
70+
return -1
71+
try:
72+
return _user32.GetGuiResources(handle, _GR_GDIOBJECTS)
73+
finally:
74+
_kernel32.CloseHandle(handle)
75+
76+
def get_user_object_count(pid: int | None = None) -> int:
77+
"""Return the number of USER objects for the given process."""
78+
if pid is None:
79+
pid = os.getpid()
80+
handle = _kernel32.OpenProcess(_PROCESS_QUERY_INFORMATION, False, pid)
81+
if not handle:
82+
return -1
83+
try:
84+
return _user32.GetGuiResources(handle, _GR_USEROBJECTS)
85+
finally:
86+
_kernel32.CloseHandle(handle)
87+
88+
else:
89+
90+
def get_gdi_count(pid: int | None = None) -> int: # noqa: ARG001
91+
return -1
92+
93+
def get_user_object_count(pid: int | None = None) -> int: # noqa: ARG001
94+
return -1
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# Stress cycle
99+
# ---------------------------------------------------------------------------
100+
101+
102+
def run_stress_cycle() -> None:
103+
"""Build a QMainWindow with a heavily populated QwtPlot and tear it down."""
104+
from qtpy import QtCore as QC
105+
from qtpy import QtGui as QG
106+
from qtpy import QtWidgets as QW
107+
108+
from qwt import (
109+
QwtLinearColorMap,
110+
QwtLogScaleEngine,
111+
QwtPlot,
112+
QwtPlotCurve,
113+
QwtPlotGrid,
114+
QwtPlotMarker,
115+
QwtPlotRenderer,
116+
QwtSymbol,
117+
QwtText,
118+
)
119+
from qwt.legend import QwtLegend
120+
from qwt.plot_directpainter import QwtPlotDirectPainter
121+
from qwt.scale_draw import QwtScaleDraw
122+
from qwt.scale_widget import QwtScaleWidget
123+
124+
win = QW.QMainWindow()
125+
win.resize(QC.QSize(1024, 768))
126+
127+
plot = QwtPlot(win)
128+
win.setCentralWidget(plot)
129+
130+
# Grid with minor lines
131+
grid = QwtPlotGrid()
132+
grid.enableXMin(True)
133+
grid.enableYMin(True)
134+
grid.attach(plot)
135+
136+
# Legend
137+
legend = QwtLegend()
138+
plot.insertLegend(legend, QwtPlot.BottomLegend)
139+
140+
# Log scale on Y
141+
plot.setAxisScaleEngine(QwtPlot.yLeft, QwtLogScaleEngine())
142+
143+
# Color map with stops
144+
cmap = QwtLinearColorMap(QG.QColor("blue"), QG.QColor("red"))
145+
cmap.addColorStop(0.25, QG.QColor("cyan"))
146+
cmap.addColorStop(0.5, QG.QColor("green"))
147+
cmap.addColorStop(0.75, QG.QColor("yellow"))
148+
149+
# 10 curves, some with symbols
150+
x = np.linspace(0.001, 20.0, 200)
151+
for i in range(10):
152+
curve = QwtPlotCurve(f"Curve {i}")
153+
offset = i * 0.5
154+
scale = 1.0 + i * 0.3
155+
y = np.abs(np.sin(x + offset) / x) * scale
156+
curve.setData(x, y)
157+
if i % 3 == 0:
158+
sym = QwtSymbol(QwtSymbol.Ellipse)
159+
sym.setSize(QC.QSize(6, 6))
160+
curve.setSymbol(sym)
161+
curve.attach(plot)
162+
163+
# 5 markers (4 VLine + 1 HLine)
164+
for i in range(4):
165+
marker = QwtPlotMarker()
166+
marker.setLineStyle(QwtPlotMarker.VLine)
167+
marker.setXValue(2.0 + i * 4.0)
168+
label = QwtText(f"V{i}")
169+
marker.setLabel(label)
170+
marker.attach(plot)
171+
hmarker = QwtPlotMarker()
172+
hmarker.setLineStyle(QwtPlotMarker.HLine)
173+
hmarker.setYValue(1.0)
174+
hmarker.setLabel(QwtText("H0"))
175+
hmarker.attach(plot)
176+
177+
# Standalone QwtScaleWidget
178+
sw = QwtScaleWidget(QwtScaleDraw.LeftScale, win)
179+
sw.setTitle(QwtText("Intensity"))
180+
181+
# Renderer
182+
renderer = QwtPlotRenderer()
183+
renderer.setDiscardFlag(QwtPlotRenderer.DiscardBackground, True)
184+
185+
# DirectPainter
186+
_dp = QwtPlotDirectPainter(plot)
187+
188+
# QwtText objects with varied fonts to pressure font caches
189+
for size in (8, 10, 12, 14, 16, 18):
190+
txt = QwtText(f"Sample text {size}pt")
191+
font = QG.QFont("Arial", size)
192+
txt.setFont(font)
193+
txt.textSize(font)
194+
195+
# Render cycle
196+
plot.replot()
197+
win.show()
198+
QW.QApplication.processEvents()
199+
win.close()
200+
win.deleteLater()
201+
QW.QApplication.processEvents()
202+
203+
204+
# ---------------------------------------------------------------------------
205+
# Main
206+
# ---------------------------------------------------------------------------
207+
208+
209+
def main() -> None:
210+
parser = argparse.ArgumentParser(
211+
description="GDI handle load test for PythonQwt (issue #107)."
212+
)
213+
parser.add_argument(
214+
"--cycles", type=int, default=100, help="Number of stress cycles (default 100)"
215+
)
216+
parser.add_argument(
217+
"--threshold",
218+
type=int,
219+
default=500,
220+
help="Max allowed GDI handle growth (default 500)",
221+
)
222+
parser.add_argument(
223+
"--verbose", action="store_true", help="Print GDI count every cycle"
224+
)
225+
args = parser.parse_args()
226+
227+
from qtpy import API_NAME, PYQT_VERSION, QT_VERSION
228+
from qtpy import QtWidgets as QW
229+
230+
pyver = ".".join(str(v) for v in sys.version_info[:3])
231+
print(
232+
f"GDI load test [Python {pyver}, Qt {QT_VERSION}, "
233+
f"{API_NAME} v{PYQT_VERSION}, cycles={args.cycles}]"
234+
)
235+
236+
app = QW.QApplication.instance() or QW.QApplication([])
237+
238+
if not _IS_WINDOWS:
239+
print("WARNING: Not on Windows — GDI tracking unavailable, running cycles only")
240+
241+
# Warm-up
242+
print("Warming up (2 cycles)...")
243+
for _ in range(2):
244+
run_stress_cycle()
245+
246+
baseline_gdi = get_gdi_count()
247+
peak_gdi = baseline_gdi
248+
print(f"Baseline GDI count: {baseline_gdi}")
249+
250+
t0 = time.perf_counter()
251+
for i in range(1, args.cycles + 1):
252+
run_stress_cycle()
253+
current_gdi = get_gdi_count()
254+
if current_gdi > peak_gdi:
255+
peak_gdi = current_gdi
256+
if args.verbose or i % 20 == 0:
257+
print(f" cycle {i:4d}/{args.cycles} GDI={current_gdi}")
258+
elapsed = time.perf_counter() - t0
259+
260+
final_gdi = get_gdi_count()
261+
growth = final_gdi - baseline_gdi if baseline_gdi > 0 else 0
262+
263+
# Summary
264+
print()
265+
print("=" * 50)
266+
print(f" Cycles: {args.cycles}")
267+
print(f" Elapsed: {elapsed:.1f} s")
268+
print(f" Baseline GDI: {baseline_gdi}")
269+
print(f" Final GDI: {final_gdi}")
270+
print(f" Peak GDI: {peak_gdi}")
271+
print(f" GDI growth: {growth}")
272+
print(f" Threshold: {args.threshold}")
273+
print("=" * 50)
274+
275+
if _IS_WINDOWS and growth > args.threshold:
276+
print(f"FAIL: GDI growth ({growth}) exceeds threshold ({args.threshold})")
277+
sys.exit(1)
278+
else:
279+
print("PASS")
280+
sys.exit(0)
281+
282+
283+
if __name__ == "__main__":
284+
main()

0 commit comments

Comments
 (0)