Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 1a016f0

Browse files
FIX: TkAgg memory leaks and test for memory growth regressions (#22002)
tkinter variables get cleaned up with normal `destroy` and `gc` semantics but tkinter's implementation of trace is effectively global and keeps the callback object alive until the trace is removed. Additionally extend and clean up the tests. Closes #20490 Co-authored-by: Elliott Sales de Andrade <[email protected]>
1 parent 9c590de commit 1a016f0

File tree

6 files changed

+86
-19
lines changed

6 files changed

+86
-19
lines changed

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies:
4747
- nbformat!=5.0.0,!=5.0.1
4848
- pandas!=0.25.0
4949
- pikepdf
50+
- psutil
5051
- pre-commit
5152
- pydocstyle>=5.1.0
5253
- pytest!=4.6.0,!=5.4.0

lib/matplotlib/_pylab_helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ def destroy(cls, num):
6565
if hasattr(manager, "_cidgcf"):
6666
manager.canvas.mpl_disconnect(manager._cidgcf)
6767
manager.destroy()
68+
del manager, num
69+
# Full cyclic garbage collection may be too expensive to do on every
70+
# figure destruction, so we collect only the youngest two generations.
71+
# see: https://github.com/matplotlib/matplotlib/pull/3045
6872
gc.collect(1)
6973

7074
@classmethod

lib/matplotlib/backends/_backend_tk.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,12 @@ def __init__(self, canvas, num, window):
423423
# to store the DPI, which will be updated by the C code, and the trace
424424
# will handle it on the Python side.
425425
window_frame = int(window.wm_frame(), 16)
426-
window_dpi = tk.IntVar(master=window, value=96,
427-
name=f'window_dpi{window_frame}')
426+
self._window_dpi = tk.IntVar(master=window, value=96,
427+
name=f'window_dpi{window_frame}')
428+
self._window_dpi_cbname = ''
428429
if _tkagg.enable_dpi_awareness(window_frame, window.tk.interpaddr()):
429-
self._window_dpi = window_dpi # Prevent garbage collection.
430-
window_dpi.trace_add('write', self._update_window_dpi)
430+
self._window_dpi_cbname = self._window_dpi.trace_add(
431+
'write', self._update_window_dpi)
431432

432433
self._shown = False
433434

@@ -472,20 +473,26 @@ def destroy(self, *args):
472473
self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id)
473474
if self.canvas._event_loop_id:
474475
self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id)
476+
if self._window_dpi_cbname:
477+
self._window_dpi.trace_remove('write', self._window_dpi_cbname)
475478

476479
# NOTE: events need to be flushed before issuing destroy (GH #9956),
477-
# however, self.window.update() can break user code. This is the
478-
# safest way to achieve a complete draining of the event queue,
479-
# but it may require users to update() on their own to execute the
480-
# completion in obscure corner cases.
480+
# however, self.window.update() can break user code. An async callback
481+
# is the safest way to achieve a complete draining of the event queue,
482+
# but it leaks if no tk event loop is running. Therefore we explicitly
483+
# check for an event loop and choose our best guess.
481484
def delayed_destroy():
482485
self.window.destroy()
483486

484487
if self._owns_mainloop and not Gcf.get_num_fig_managers():
485488
self.window.quit()
486489

487-
# "after idle after 0" avoids Tcl error/race (GH #19940)
488-
self.window.after_idle(self.window.after, 0, delayed_destroy)
490+
if cbook._get_running_interactive_framework() == "tk":
491+
# "after idle after 0" avoids Tcl error/race (GH #19940)
492+
self.window.after_idle(self.window.after, 0, delayed_destroy)
493+
else:
494+
self.window.update()
495+
delayed_destroy()
489496

490497
def get_window_title(self):
491498
return self.window.wm_title()

lib/matplotlib/tests/test_backend_tk.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ def test_func():
5050
)
5151
except subprocess.TimeoutExpired:
5252
pytest.fail("Subprocess timed out")
53-
except subprocess.CalledProcessError:
54-
pytest.fail("Subprocess failed to test intended behavior")
53+
except subprocess.CalledProcessError as e:
54+
pytest.fail("Subprocess failed to test intended behavior\n"
55+
+ str(e.stderr))
5556
else:
5657
# macOS may actually emit irrelevant errors about Accelerated
5758
# OpenGL vs. software OpenGL, so suppress them.
@@ -146,6 +147,7 @@ def target():
146147
thread.join()
147148

148149

150+
@pytest.mark.backend('TkAgg', skip_on_importerror=True)
149151
@pytest.mark.flaky(reruns=3)
150152
@_isolated_tk_test(success_count=0)
151153
def test_never_update():
@@ -159,14 +161,12 @@ def test_never_update():
159161

160162
plt.draw() # Test FigureCanvasTkAgg.
161163
fig.canvas.toolbar.configure_subplots() # Test NavigationToolbar2Tk.
164+
# Test FigureCanvasTk filter_destroy callback
165+
fig.canvas.get_tk_widget().after(100, plt.close, fig)
162166

163167
# Check for update() or update_idletasks() in the event queue, functionally
164168
# equivalent to tkinter.Misc.update.
165-
# Must pause >= 1 ms to process tcl idle events plus extra time to avoid
166-
# flaky tests on slow systems.
167-
plt.pause(0.1)
168-
169-
plt.close(fig) # Test FigureCanvasTk filter_destroy callback
169+
plt.show(block=True)
170170

171171
# Note that exceptions would be printed to stderr; _isolated_tk_test
172172
# checks them.

lib/matplotlib/tests/test_backends_interactive.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,10 @@ def _test_thread_impl():
195195
future = ThreadPoolExecutor().submit(fig.canvas.draw)
196196
plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176)
197197
future.result() # Joins the thread; rethrows any exception.
198-
plt.close()
199-
fig.canvas.flush_events() # pause doesn't process events after close
198+
plt.close() # backend is responsible for flushing any events here
199+
if plt.rcParams["backend"].startswith("WX"):
200+
# TODO: debug why WX needs this only on py3.8
201+
fig.canvas.flush_events()
200202

201203

202204
_thread_safe_backends = _get_testable_interactive_backends()
@@ -503,3 +505,55 @@ def test_blitting_events(env):
503505
# blitting is not properly implemented
504506
ndraws = proc.stdout.count("DrawEvent")
505507
assert 0 < ndraws < 5
508+
509+
510+
# The source of this function gets extracted and run in another process, so it
511+
# must be fully self-contained.
512+
def _test_figure_leak():
513+
import gc
514+
import sys
515+
516+
import psutil
517+
from matplotlib import pyplot as plt
518+
# Second argument is pause length, but if zero we should skip pausing
519+
t = float(sys.argv[1])
520+
p = psutil.Process()
521+
522+
# Warmup cycle, this reasonably allocates a lot
523+
for _ in range(2):
524+
fig = plt.figure()
525+
if t:
526+
plt.pause(t)
527+
plt.close(fig)
528+
mem = p.memory_info().rss
529+
gc.collect()
530+
531+
for _ in range(5):
532+
fig = plt.figure()
533+
if t:
534+
plt.pause(t)
535+
plt.close(fig)
536+
gc.collect()
537+
growth = p.memory_info().rss - mem
538+
539+
print(growth)
540+
541+
542+
# TODO: "0.1" memory threshold could be reduced 10x by fixing tkagg
543+
@pytest.mark.parametrize("env", _get_testable_interactive_backends())
544+
@pytest.mark.parametrize("time_mem", [(0.0, 2_000_000), (0.1, 30_000_000)])
545+
def test_figure_leak_20490(env, time_mem):
546+
pytest.importorskip("psutil", reason="psutil needed to run this test")
547+
548+
# We haven't yet directly identified the leaks so test with a memory growth
549+
# threshold.
550+
pause_time, acceptable_memory_leakage = time_mem
551+
if env["MPLBACKEND"] == "macosx":
552+
acceptable_memory_leakage += 10_000_000
553+
554+
result = _run_helper(
555+
_test_figure_leak, str(pause_time), timeout=_test_timeout, **env
556+
)
557+
558+
growth = int(result.stdout)
559+
assert growth <= acceptable_memory_leakage

requirements/testing/all.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
certifi
44
coverage<6.3
5+
psutil
56
pytest!=4.6.0,!=5.4.0
67
pytest-cov
78
pytest-rerunfailures

0 commit comments

Comments
 (0)