Repository: pydicom/pynetdicom

Issue:
Association related memory leak
### Description
There seems to be an association-related memory-leak somewhere.

### Expected behaviour
The memory usage when acting as an SCP should remain constant over time.

### Actual behaviour
Memory usage increases with the total number of associations.

Storage SCP starts around 27.3 MB and increases by about 30 MB per 1000 associations.

* Looks like `AssociationServer._children` isn't being cleaned up (when commented out memory usage starts at 20.8 and stabilises around 28.8)

### Steps to reproduce
Start a storage SCP and associate with it a few thousand times

Discussion:

Patch:
diff --git a/docs/changelog/v1.3.0.rst b/docs/changelog/v1.3.0.rst
index 5304f72c6..a99244f42 100644
--- a/docs/changelog/v1.3.0.rst
+++ b/docs/changelog/v1.3.0.rst
@@ -17,7 +17,9 @@ Fixes
   of just re-using 1. (:issue:`315`)
 * The default ``logging`` configuration no longer uses a non-NullHandler
   (:issue:`321`)
-* Fix Unified Procedure Step's SOP Class UID variable names
+* Fixed Unified Procedure Step's SOP Class UID variable names
+* Fixed memory leak caused by keeping old ``Association`` threads around after
+  they died (:issue:`328`)
 
 
 Enhancements
diff --git a/pynetdicom/association.py b/pynetdicom/association.py
index 94d08f7c5..68ad63067 100644
--- a/pynetdicom/association.py
+++ b/pynetdicom/association.py
@@ -1,6 +1,7 @@
 """
 Defines the Association class which handles associating with peers.
 """
+import gc
 from io import BytesIO
 import logging
 import threading
@@ -130,6 +131,10 @@ class Association(threading.Thread):
         self._ae = ae
         self.mode = mode
 
+        # If acceptor this is the parent AssociationServer, used to identify
+        #   the thread when updating bound event-handlers
+        self._server = None
+
         # Represents the association requestor and acceptor users
         self.requestor = ServiceUser(self, MODE_REQUESTOR)
         self.acceptor = ServiceUser(self, MODE_ACCEPTOR)
diff --git a/pynetdicom/pdu_items.py b/pynetdicom/pdu_items.py
index 5f65a2064..4570836a8 100644
--- a/pynetdicom/pdu_items.py
+++ b/pynetdicom/pdu_items.py
@@ -125,7 +125,8 @@ class PDUItem(object):
         if other is self:
             return True
 
-        if isinstance(other, self.__class__):
+        if isinstance(other, type(self)):
+            # Use the values of the class attributes that get encoded
             self_dict = {
                 en[0]: getattr(self, en[0]) for en in self._encoders if en[0]
             }
@@ -216,11 +217,7 @@ class PDUItem(object):
     @property
     def item_type(self):
         """Return the item's *Item Type* field value as an int."""
-        key_val = PDU_ITEM_TYPES.items()
-        keys = [key for (key, val) in key_val]
-        vals = [val for (key, val) in key_val]
-
-        return keys[vals.index(self.__class__)]
+        return {vv: kk for kk, vv in PDU_ITEM_TYPES.items()}[type(self)]
 
     def __len__(self):
         """Return the total length of the encoded item as an int."""
diff --git a/pynetdicom/transport.py b/pynetdicom/transport.py
index 7b517efd3..fd7a80420 100644
--- a/pynetdicom/transport.py
+++ b/pynetdicom/transport.py
@@ -342,6 +342,7 @@ class RequestHandler(BaseRequestHandler):
         from pynetdicom.association import Association
 
         assoc = Association(self.ae, MODE_ACCEPTOR)
+        assoc._server = self.server
 
         # Set the thread name
         timestamp = datetime.strftime(datetime.now(), "%Y%m%d%H%M%S")
@@ -385,9 +386,6 @@ class RequestHandler(BaseRequestHandler):
 
         assoc.start()
 
-        # Track the server's associations
-        self.server._children.append(assoc)
-
     @property
     def local(self):
         """Return a 2-tuple of the local server's ``(host, port)`` address."""
@@ -448,10 +446,8 @@ class AssociationServer(TCPServer):
 
         self.timeout = 60
 
-        # Tracks child Association acceptors
-        self._children = []
         # Stores all currently bound event handlers so future
-        #   children can be bound
+        #   Associations can be bound
         self._handlers = {}
         self._bind_defaults()
 
@@ -506,9 +502,11 @@ class AssociationServer(TCPServer):
     @property
     def active_associations(self):
         """Return the server's running ``Association`` acceptor instances"""
-        self._children = [
-            child for child in self._children if child.is_alive()]
-        return self._children
+        # Find all AcceptorThreads with `_server` as self
+        threads = [
+            tt for tt in threading.enumerate() if 'AcceptorThread' in tt.name
+        ]
+        return [tt for tt in threads if tt._server is self]
 
     def get_events(self):
         """Return a list of currently bound events."""


Test patch:
diff --git a/pynetdicom/tests/test_pdu_items.py b/pynetdicom/tests/test_pdu_items.py
index a2de773e0..1c84825a7 100644
--- a/pynetdicom/tests/test_pdu_items.py
+++ b/pynetdicom/tests/test_pdu_items.py
@@ -178,9 +178,9 @@ class TestPDU(object):
             item.item_length
 
     def test_item_type_raises(self):
-        """Test PDU.pdu_type raises ValueError."""
+        """Test PDUItem.item_type raises ValueError."""
         item = PDUItem()
-        with pytest.raises(ValueError):
+        with pytest.raises(KeyError):
             item.item_type
 
     def test_wrap_bytes(self):
diff --git a/pynetdicom/tests/test_transport.py b/pynetdicom/tests/test_transport.py
index 2650093de..7752eb5ac 100644
--- a/pynetdicom/tests/test_transport.py
+++ b/pynetdicom/tests/test_transport.py
@@ -169,9 +169,16 @@ def client_context(request):
 
 class TestTLS(object):
     """Test using TLS to wrap the association."""
+    def setup(self):
+        self.ae = None
+
+    def teardown(self):
+        if self.ae:
+            self.ae.shutdown()
+
     def test_tls_not_server_not_client(self):
         """Test associating with no TLS on either end."""
-        ae = AE()
+        self.ae = ae = AE()
         ae.add_supported_context('1.2.840.10008.1.1')
         server = ae.start_server(('', 11112), block=False)
 
@@ -183,10 +190,16 @@ class TestTLS(object):
 
         server.shutdown()
 
+        assert len(server.active_associations) == 0
+
     def test_tls_not_server_yes_client(self, client_context):
         """Test wrapping the requestor socket with TLS (but not server)."""
-        ae = AE()
+        self.ae = ae = AE()
+        ae.acse_timeout = 0.5
+        ae.dimse_timeout = 0.5
+        ae.network_timeout = 0.5
         ae.add_supported_context('1.2.840.10008.1.1')
+
         server = ae.start_server(('', 11112), block=False)
 
         ae.add_requested_context('1.2.840.10008.1.1')
@@ -195,9 +208,13 @@ class TestTLS(object):
 
         server.shutdown()
 
+        time.sleep(0.5)
+
+        assert len(server.active_associations) == 0
+
     def test_tls_yes_server_not_client(self, server_context):
         """Test wrapping the requestor socket with TLS (and server)."""
-        ae = AE()
+        self.ae = ae = AE()
         ae.add_supported_context('1.2.840.10008.1.1')
         server = ae.start_server(
             ('', 11112),
@@ -211,9 +228,11 @@ class TestTLS(object):
 
         server.shutdown()
 
+        assert len(server.active_associations) == 0
+
     def test_tls_yes_server_yes_client(self, server_context, client_context):
         """Test associating with no TLS on either end."""
-        ae = AE()
+        self.ae = ae = AE()
         ae.add_supported_context('1.2.840.10008.1.1')
         server = ae.start_server(
             ('', 11112),
@@ -229,6 +248,8 @@ class TestTLS(object):
 
         server.shutdown()
 
+        assert len(server.active_associations) == 0
+
 
 class TestAssociationServer(object):
     def setup(self):