From 5c3d52405b647bc69185f657ed4c180c02ac14f7 Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Thu, 12 Apr 2018 00:27:11 -0700 Subject: [PATCH 1/3] TST: Extract a helper function to test for reference cycles This also means we can now test that our test is actually able to detect the type of failure we expect Trying to give myself some tools to debug the failure at https://github.com/numpy/numpy/pull/10882/files#r180813166 --- numpy/lib/tests/test_io.py | 13 +------ numpy/testing/_private/utils.py | 64 ++++++++++++++++++++++++++++++- numpy/testing/tests/test_utils.py | 29 +++++++++++++- numpy/testing/utils.py | 2 +- 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/numpy/lib/tests/test_io.py b/numpy/lib/tests/test_io.py index 06c57d49cc2b..09a70a388947 100644 --- a/numpy/lib/tests/test_io.py +++ b/numpy/lib/tests/test_io.py @@ -23,7 +23,7 @@ from numpy.testing import ( assert_warns, assert_, SkipTest, assert_raises_regex, assert_raises, assert_allclose, assert_array_equal, temppath, tempdir, IS_PYPY, - HAS_REFCOUNT, suppress_warnings, + HAS_REFCOUNT, suppress_warnings, assert_no_gc_cycles, ) @@ -2369,14 +2369,5 @@ def test_load_refcount(): np.savez(f, [1, 2, 3]) f.seek(0) - assert_(gc.isenabled()) - gc.disable() - try: - gc.collect() + with assert_no_gc_cycles(): np.load(f) - # gc.collect returns the number of unreachable objects in cycles that - # were found -- we are checking that no cycles were created by np.load - n_objects_in_cycles = gc.collect() - finally: - gc.enable() - assert_equal(n_objects_in_cycles, 0) diff --git a/numpy/testing/_private/utils.py b/numpy/testing/_private/utils.py index 507ecb1e2740..4a113f12e4f3 100644 --- a/numpy/testing/_private/utils.py +++ b/numpy/testing/_private/utils.py @@ -7,6 +7,7 @@ import os import sys import re +import gc import operator import warnings from functools import partial, wraps @@ -35,7 +36,7 @@ 'assert_allclose', 'IgnoreException', 'clear_and_catch_warnings', 'SkipTest', 'KnownFailureException', 'temppath', 'tempdir', 'IS_PYPY', 'HAS_REFCOUNT', 'suppress_warnings', 'assert_array_compare', - '_assert_valid_refcount', '_gen_alignment_data', + '_assert_valid_refcount', '_gen_alignment_data', 'assert_no_gc_cycles', ] @@ -2272,3 +2273,64 @@ def new_func(*args, **kwargs): return func(*args, **kwargs) return new_func + + +@contextlib.contextmanager +def _assert_no_gc_cycles_context(name=None): + __tracebackhide__ = True # Hide traceback for py.test + + # not meaningful to test if there is no refcounting + if not HAS_REFCOUNT: + return + + assert_(gc.isenabled()) + gc.disable() + try: + gc.collect() + yield + # gc.collect returns the number of unreachable objects in cycles that + # were found -- we are checking that no cycles were created in the context + n_objects_in_cycles = gc.collect() + finally: + gc.enable() + + if n_objects_in_cycles: + name_str = " when calling %s" % name if name is not None else "" + raise AssertionError( + "Reference cycles were found{}: {} objects were collected" + .format(name_str, n_objects_in_cycles)) + + +def assert_no_gc_cycles(*args, **kwargs): + """ + Fail if the given callable produces any reference cycles. + + If called with all arguments omitted, may be used as a context manager: + + with assert_no_gc_cycles(): + do_something() + + .. versionadded:: 1.15.0 + + Parameters + ---------- + func : callable + The callable to test. + \\*args : Arguments + Arguments passed to `func`. + \\*\\*kwargs : Kwargs + Keyword arguments passed to `func`. + + Returns + ------- + Nothing. The result is deliberately discarded to ensure that all cycles + are found. + + """ + if not args: + return _assert_no_gc_cycles_context() + + func = args[0] + args = args[1:] + with _assert_no_gc_cycles_context(name=func.__name__): + func(*args, **kwargs) diff --git a/numpy/testing/tests/test_utils.py b/numpy/testing/tests/test_utils.py index 35f81d8a7a66..52726db6e027 100644 --- a/numpy/testing/tests/test_utils.py +++ b/numpy/testing/tests/test_utils.py @@ -14,7 +14,7 @@ assert_raises, assert_warns, assert_no_warnings, assert_allclose, assert_approx_equal, assert_array_almost_equal_nulp, assert_array_max_ulp, clear_and_catch_warnings, suppress_warnings, assert_string_equal, assert_, - tempdir, temppath, + tempdir, temppath, assert_no_gc_cycles, HAS_REFCOUNT ) @@ -1360,3 +1360,30 @@ def test_clear_and_catch_warnings_inherit(): warnings.simplefilter('ignore') warnings.warn('Some warning') assert_equal(my_mod.__warningregistry__, {}) + + +@pytest.mark.skipif(not HAS_REFCOUNT, reason="Python lacks refcounts") +def test_assert_no_gc_cycles(): + + def no_cycle(): + b = [] + b.append([]) + return b + + with assert_no_gc_cycles(): + no_cycle() + + assert_no_gc_cycles(no_cycle) + + def make_cycle(): + a = [] + a.append(a) + a.append(a) + return a + + with assert_raises(AssertionError): + with assert_no_gc_cycles(): + make_cycle() + + with assert_raises(AssertionError): + assert_no_gc_cycles(make_cycle) diff --git a/numpy/testing/utils.py b/numpy/testing/utils.py index 78cf405cfac4..184adcc74c68 100644 --- a/numpy/testing/utils.py +++ b/numpy/testing/utils.py @@ -25,5 +25,5 @@ 'assert_allclose', 'IgnoreException', 'clear_and_catch_warnings', 'SkipTest', 'KnownFailureException', 'temppath', 'tempdir', 'IS_PYPY', 'HAS_REFCOUNT', 'suppress_warnings', 'assert_array_compare', - '_assert_valid_refcount', '_gen_alignment_data', + '_assert_valid_refcount', '_gen_alignment_data', 'assert_no_gc_cycles' ] From d21ec05eb006c072e4fd8c5fe1bd63619378aded Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Thu, 12 Apr 2018 00:42:18 -0700 Subject: [PATCH 2/3] ENH: Show the full list of leaked objects An example output for the test added in the previous commit is: AssertionError: Reference cycles were found when calling make_cycle: 1 objects were collected, of which 1 are shown below: list object with id=2279664872136: [, ] --- numpy/testing/_private/utils.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/numpy/testing/_private/utils.py b/numpy/testing/_private/utils.py index 4a113f12e4f3..0c9fd644cb59 100644 --- a/numpy/testing/_private/utils.py +++ b/numpy/testing/_private/utils.py @@ -15,6 +15,7 @@ import contextlib from tempfile import mkdtemp, mkstemp from unittest.case import SkipTest +import pprint from numpy.core import( float32, empty, arange, array_repr, ndarray, isnat, array) @@ -2285,20 +2286,38 @@ def _assert_no_gc_cycles_context(name=None): assert_(gc.isenabled()) gc.disable() + gc_debug = gc.get_debug() try: gc.collect() + gc.set_debug(gc.DEBUG_SAVEALL) yield # gc.collect returns the number of unreachable objects in cycles that # were found -- we are checking that no cycles were created in the context n_objects_in_cycles = gc.collect() + objects_in_cycles = gc.garbage[:] finally: + del gc.garbage[:] + gc.set_debug(gc_debug) gc.enable() if n_objects_in_cycles: name_str = " when calling %s" % name if name is not None else "" raise AssertionError( - "Reference cycles were found{}: {} objects were collected" - .format(name_str, n_objects_in_cycles)) + "Reference cycles were found{}: {} objects were collected, " + "of which {} are shown below:{}" + .format( + name_str, + n_objects_in_cycles, + len(objects_in_cycles), + ''.join( + "\n {} object with id={}:\n {}".format( + type(o).__name__, + id(o), + pprint.pformat(o).replace('\n', '\n ') + ) for o in objects_in_cycles + ) + ) + ) def assert_no_gc_cycles(*args, **kwargs): From 3ff0c5c82b8abc4c94b1801a13f488778631f38a Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Thu, 12 Apr 2018 22:07:58 -0700 Subject: [PATCH 3/3] BUG: Ensure the garbage is clear first in assert_no_gc_cycles It's not always possible to guarantee this, so also adds a test to verify that we don't hang --- numpy/testing/_private/utils.py | 9 +++- numpy/testing/tests/test_utils.py | 83 ++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/numpy/testing/_private/utils.py b/numpy/testing/_private/utils.py index 0c9fd644cb59..b0c0b0c48f23 100644 --- a/numpy/testing/_private/utils.py +++ b/numpy/testing/_private/utils.py @@ -2288,7 +2288,14 @@ def _assert_no_gc_cycles_context(name=None): gc.disable() gc_debug = gc.get_debug() try: - gc.collect() + for i in range(100): + if gc.collect() == 0: + break + else: + raise RuntimeError( + "Unable to fully collect garbage - perhaps a __del__ method is " + "creating more reference cycles?") + gc.set_debug(gc.DEBUG_SAVEALL) yield # gc.collect returns the number of unreachable objects in cycles that diff --git a/numpy/testing/tests/test_utils.py b/numpy/testing/tests/test_utils.py index 52726db6e027..0592e62f8e1b 100644 --- a/numpy/testing/tests/test_utils.py +++ b/numpy/testing/tests/test_utils.py @@ -6,6 +6,7 @@ import itertools import textwrap import pytest +import weakref import numpy as np from numpy.testing import ( @@ -1363,27 +1364,73 @@ def test_clear_and_catch_warnings_inherit(): @pytest.mark.skipif(not HAS_REFCOUNT, reason="Python lacks refcounts") -def test_assert_no_gc_cycles(): +class TestAssertNoGcCycles(object): + """ Test assert_no_gc_cycles """ + def test_passes(self): + def no_cycle(): + b = [] + b.append([]) + return b - def no_cycle(): - b = [] - b.append([]) - return b + with assert_no_gc_cycles(): + no_cycle() - with assert_no_gc_cycles(): - no_cycle() + assert_no_gc_cycles(no_cycle) - assert_no_gc_cycles(no_cycle) - def make_cycle(): - a = [] - a.append(a) - a.append(a) - return a + def test_asserts(self): + def make_cycle(): + a = [] + a.append(a) + a.append(a) + return a - with assert_raises(AssertionError): - with assert_no_gc_cycles(): - make_cycle() + with assert_raises(AssertionError): + with assert_no_gc_cycles(): + make_cycle() + + with assert_raises(AssertionError): + assert_no_gc_cycles(make_cycle) + + + def test_fails(self): + """ + Test that in cases where the garbage cannot be collected, we raise an + error, instead of hanging forever trying to clear it. + """ + + class ReferenceCycleInDel(object): + """ + An object that not only contains a reference cycle, but creates new + cycles whenever it's garbage-collected and its __del__ runs + """ + make_cycle = True - with assert_raises(AssertionError): - assert_no_gc_cycles(make_cycle) + def __init__(self): + self.cycle = self + + def __del__(self): + # break the current cycle so that `self` can be freed + self.cycle = None + + if ReferenceCycleInDel.make_cycle: + # but create a new one so that the garbage collector has more + # work to do. + ReferenceCycleInDel() + + try: + w = weakref.ref(ReferenceCycleInDel()) + try: + with assert_raises(RuntimeError): + # this will be unable to get a baseline empty garbage + assert_no_gc_cycles(lambda: None) + except AssertionError: + # the above test is only necessary if the GC actually tried to free + # our object anyway, which python 2.7 does not. + if w() is not None: + pytest.skip("GC does not call __del__ on cyclic objects") + raise + + finally: + # make sure that we stop creating reference cycles + ReferenceCycleInDel.make_cycle = False