Repository: pydicom/pynetdicom

Issue:
Possible memory leak when manually aborting an association
Hi,

I have investigated a memory leak, which seems to be related to attempts of manually aborting associations.

Minimal example:

```python
def _on_pdu_recv(self, event):
    # Method bound to `EVT_PDU_RECV` event.
    if isinstance(event.pdu, A_RELEASE_RQ):
        self._on_handle_a_release_rq(event)

def _on_handle_a_release_rq(self, event):
    try:
        do_something_with_event(event)
    except Exception:
        # The following line of code kills the Association thread. The DUL's 
        # FSM is stuck in `Sta6`, thus it is not killed by the Association 
        # thread's `kill()` method, which is invoked by the calling to `abort()` 
        # below. As a result, the `kill()` method is stuck in a `while` loop
        # trying to stop the DUL.
        event.assoc.abort()
        # Due to the above, the line below is never executed.
        do_something_else()
```

The above seems like a straight-forward way of aborting, but leads to leaking of DUL threads. 

I have found that the below alternative works better:

```python
def _on_pdu_recv(self, event):
    # Method bound to `EVT_PDU_RECV` event.
    if isinstance(event.pdu, A_RELEASE_RQ):
        self._on_handle_a_release_rq(event)

def _on_handle_a_release_rq(self, event):
    try:
        do_something_with_event(event)
    except Exception:
        # Manually create the PDU to abort.
        abort_pdu = A_ABORT_RQ()
        abort_pdu.source = 0x02
        abort_pdu.reason_diagnostic = 0x00
        event.assoc.dul.socket.send(abort_pdu.encode())
        event.assoc.dul.assoc.is_aborted = True
        event.assoc.dul.assoc.is_established = False
        # Hard shutdown of the Association and DUL reactors.
        event.assoc.dul.assoc._kill = True
        event.assoc.dul._kill_thread = True
        # The line below is called.
        do_something_else()
```

The above approach is based on [this](https://github.com/pydicom/pynetdicom/blob/master/pynetdicom/dul.py#L419-L434).

I am wondering what is actually the best approach to abort an association at any time. Thoughts?

_Originally posted by @pchristos in https://github.com/pydicom/pynetdicom/issues/652#issuecomment-1387319121_
           
pydicom  2.3.1
pynetdicom  2.0.2

Discussion:
@scaramallion: Yeah, there's some iffy behaviour in shutting the DUL down properly that will be fixed in the next release.@pchristos: Thanks! ETA for the next release?@scaramallion: It'll be quite a while, I think. I've been focussing on pydicom's v3.0 release.@pchristos: In that case, what's your recommended approach? Is the proposed alternative a valid approach for now?@pchristos: Hey, any updates on this?@scaramallion: Nothing so far, but probably soonish. @pchristos: Any updates?@scaramallion: Issue is due to `Association.abort()` being blocking, so while it's active the DUL thread is dead-locked and can't do anything. 

This is pretty annoying, I ended up adding a `block` optional parameter and making it so that when `abort()` is called during an event handler it defaults to non-blocking mode and when called outside of an event handler it defaults to blocking mode.



Solution patch:
diff --git a/docs/changelog/v3.0.0.rst b/docs/changelog/v3.0.0.rst
index 92272fb81..8b16ebace 100644
--- a/docs/changelog/v3.0.0.rst
+++ b/docs/changelog/v3.0.0.rst
@@ -49,6 +49,10 @@ Changes
     <pynetdicom.pdu_primitives.A_ASSOCIATE.called_presentation_address>` are both now
     set using an :class:`~pynetdicom.transport.AddressInformation` instance
 
+* Added the `block` optional argument to :meth:`Association.abort()
+  <pynetdicom.association.Association.abort>` to allow for operation in non-blocking
+  mode.
+
 
 Enhancements
 ------------
@@ -69,3 +73,5 @@ Fixes
   (:issue:`979`)
 * Handle an exception due to the socket being discarded while trying to send an abort
   while waiting for the socket to be discarded 🙃 (:issue:`806`)
+* Fixed :meth:`Association.abort()<pynetdicom.association.Association.abort>` causing
+  a deadlock when called within event handlers (:issue:`912`)
diff --git a/pynetdicom/acse.py b/pynetdicom/acse.py
index 44617827d..5eae833c6 100644
--- a/pynetdicom/acse.py
+++ b/pynetdicom/acse.py
@@ -82,6 +82,8 @@ def _check_async_ops(self) -> AsynchronousOperationsWindowNegotiation | None:
             (1, 1).
         """
         # pylint: disable=broad-except
+        setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
+
         try:
             # Response is always ignored as async ops is not supported
             inv, perf = self.requestor.asynchronous_operations
@@ -89,11 +91,14 @@ def _check_async_ops(self) -> AsynchronousOperationsWindowNegotiation | None:
                 self.assoc, evt.EVT_ASYNC_OPS, {"nr_invoked": inv, "nr_performed": perf}
             )
         except NotImplementedError:
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
             return None
         except Exception as exc:
             LOGGER.error("Exception raised in handler bound to 'evt.EVT_ASYNC_OPS'")
             LOGGER.exception(exc)
 
+        setattr(self.assoc, "abort", self.assoc._abort_blocking)
+
         item = AsynchronousOperationsWindowNegotiation()
         item.maximum_number_operations_invoked = 1
         item.maximum_number_operations_performed = 1
@@ -112,6 +117,8 @@ def _check_sop_class_common_extended(
             the accepted SOP Class Common Extended negotiation items.
         """
         # pylint: disable=broad-except
+        setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
+
         try:
             rsp = evt.trigger(
                 self.assoc,
@@ -119,10 +126,13 @@ def _check_sop_class_common_extended(
                 {"items": self.requestor.sop_class_common_extended},
             )
         except Exception as exc:
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
+
             LOGGER.error("Exception raised in handler bound to 'evt.EVT_SOP_COMMON'")
             LOGGER.exception(exc)
             return {}
 
+        setattr(self.assoc, "abort", self.assoc._abort_blocking)
         rsp = cast(dict[UID, SOPClassCommonExtendedNegotiation], rsp)
 
         try:
@@ -149,6 +159,8 @@ def _check_sop_class_extended(self) -> list[SOPClassExtendedNegotiation]:
             The SOP Class Extended Negotiation items to be sent in response
         """
         # pylint: disable=broad-except
+        setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
+
         try:
             user_response = evt.trigger(
                 self.assoc,
@@ -160,6 +172,8 @@ def _check_sop_class_extended(self) -> list[SOPClassExtendedNegotiation]:
             LOGGER.error("Exception raised in handler bound to 'evt.EVT_SOP_EXTENDED'")
             LOGGER.exception(exc)
 
+        setattr(self.assoc, "abort", self.assoc._abort_blocking)
+
         if not isinstance(user_response, (type(None), dict)):
             LOGGER.error(
                 "Invalid type returned by handler bound to 'evt.EVT_SOP_EXTENDED'"
@@ -197,6 +211,8 @@ def _check_user_identity(self) -> tuple[bool, UserIdentityNegotiation | None]:
             otherwise None.
         """
         # pylint: disable=broad-except
+        setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
+
         # The UserIdentityNegotiation (request) item
         req = self.requestor.user_identity
         if req is None:
@@ -213,16 +229,19 @@ def _check_user_identity(self) -> tuple[bool, UserIdentityNegotiation | None]:
                 },
             )
         except NotImplementedError:
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
             # If the user hasn't implemented identity negotiation then
             #   default to accepting the association
             return True, None
         except Exception as exc:
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
             # If the user has implemented identity negotiation but an exception
             #   occurred then reject the association
             LOGGER.error("Exception in handler bound to 'evt.EVT_USER_ID'")
             LOGGER.exception(exc)
             return False, None
 
+        setattr(self.assoc, "abort", self.assoc._abort_blocking)
         identity_verified, response = cast(tuple[bool, bytes | None], rsp)
 
         if not identity_verified:
diff --git a/pynetdicom/association.py b/pynetdicom/association.py
index 52f8bc27a..4b9748ef5 100644
--- a/pynetdicom/association.py
+++ b/pynetdicom/association.py
@@ -204,25 +204,51 @@ def __init__(self, ae: "ApplicationEntity", mode: str) -> None:
         threading.Thread.__init__(self, target=make_target(self.run_reactor))
         self.daemon: bool = True
 
-    def abort(self) -> None:
+    def abort(self, block: bool = True) -> None:
         """Abort the :class:`Association` by sending an A-ABORT to the remote
         AE.
+
+        .. versionchanged:: 3.0
+
+            Added the `block` keyword parameter.
+
+        Parameters
+        ----------
+        block : bool, optional
+
+            * If ``True`` then this function blocks until the A-ABORT PDU has been sent
+              to  the peer, the connection shutdown and the state machine returned to
+              State 1 (idle). This is the default when ``abort()`` is called outside
+              of an event handler.
+            * If ``False`` then the function returns after adding an A-ABORT request
+              primitive to the outgoing queue. This is the default when ``abort()``
+              is called inside an event handler.
         """
+        return self._abort_blocking(block)
+
+    def _abort_blocking(self, block: bool = True) -> None:
+        """Blocking implementation of Association.abort()"""
         # Only allow a single abort message to be sent
         if self._sent_abort:
             return
 
-        if not self.is_released:
-            # Set before restarting the reactor to prevent race condition
-            self._sent_abort = True
-            # Ensure the reactor is running so it can be exited
-            self._reactor_checkpoint.set()
-            LOGGER.info("Aborting Association")
-            self.acse.send_abort(0x00)
+        if self.is_released:
+            return
 
-            # Event handler - association aborted
-            evt.trigger(self, evt.EVT_ABORTED, {})
-            self.kill()
+        # Set before restarting the reactor to prevent race condition
+        self._sent_abort = True
+        # Ensure the reactor is running so it can be exited
+        self._reactor_checkpoint.set()
+        LOGGER.info("Aborting Association")
+        self.acse.send_abort(0x00)
+
+        # Event handler - association aborted
+        evt.trigger(self, evt.EVT_ABORTED, {})
+
+        if block is False:
+            return
+
+        self.kill()
 
         # Ensure socket is shutdown and closed
         try:
@@ -233,6 +259,10 @@ def abort(self) -> None:
         # Add short delay to ensure everything shuts down
         time.sleep(0.1)
 
+    def _abort_nonblocking(self, block: bool = False) -> None:
+        """Non-blocking implementation of Association.abort()"""
+        return self._abort_blocking(block)
+
     @property
     def accepted_contexts(self) -> list[PresentationContext]:
         """Return a :class:`list` of accepted
@@ -721,7 +751,7 @@ def _run_reactor(self) -> None:
                 self._serve_request(msg, cast(int, context_id))
 
             # Check for release request from the peer
-            if self.acse.is_release_requested():
+            if self.is_established and self.acse.is_release_requested():
                 # Send A-RELEASE response
                 self.acse.send_release(is_response=True)
                 LOGGER.info("Association Released")
diff --git a/pynetdicom/events.py b/pynetdicom/events.py
index 928413ed4..a5b35deae 100644
--- a/pynetdicom/events.py
+++ b/pynetdicom/events.py
@@ -357,6 +357,11 @@ def trigger(
     if not handlers or handlers[0] is None:
         return None
 
+    # Use the non-blocking abort during during notification event handlers
+    #   The trigger for intervention event handlers sets the abort method
+    if isinstance(event, NotificationEvent):
+        setattr(assoc, "abort", assoc._abort_nonblocking)
+
     evt = Event(assoc, event, attrs or {})
 
     try:
@@ -376,6 +381,8 @@ def trigger(
             else:
                 func(evt)
     except Exception as exc:
+        setattr(assoc, "abort", assoc._abort_blocking)
+
         # Intervention exceptions get raised
         if isinstance(event, InterventionEvent):
             raise
@@ -387,6 +394,8 @@ def trigger(
         )
         LOGGER.exception(exc)
 
+    setattr(assoc, "abort", assoc._abort_blocking)
+
     return None
 
 
diff --git a/pynetdicom/service_class.py b/pynetdicom/service_class.py
index 93e9e51f8..14c4b20ff 100644
--- a/pynetdicom/service_class.py
+++ b/pynetdicom/service_class.py
@@ -88,7 +88,11 @@ class attempt:
     """
 
     def __init__(
-        self, rsp: "DimseServiceType", dimse: "DIMSEServiceProvider", cx_id: int
+        self,
+        rsp: "DimseServiceType",
+        dimse: "DIMSEServiceProvider",
+        cx_id: int,
+        assoc: "Association | None" = None,
     ) -> None:
         self._success = True
         # Should be customised within the context
@@ -98,8 +102,12 @@ def __init__(
         self._rsp = rsp
         self._dimse = dimse
         self._cx_id = cx_id
+        self._assoc = assoc
 
     def __enter__(self) -> "attempt":
+        if self._assoc is not None:
+            setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
+
         return self
 
     def __exit__(
@@ -108,6 +116,9 @@ def __exit__(
         exc_val: BaseException | None,
         exc_tb: TracebackType | None,
     ) -> bool | None:
+        if self._assoc is not None:
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
+
         if exc_type is None:
             # No exceptions raised
             return None
@@ -122,6 +133,13 @@ def __exit__(
         # Suppress any exceptions
         return True
 
+    @property
+    def assoc(self) -> "Association":
+        if not self._assoc:
+            raise ValueError("No association instance has been set")
+
+        return self._assoc
+
     @property
     def success(self) -> bool:
         """Return ``True`` if the code within the context executed without
@@ -255,11 +273,11 @@ def _c_find_scp(self, req: C_FIND, context: "PresentationContext") -> None:
                 pass
 
         # Try and trigger EVT_C_FIND
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in handler bound to 'evt.EVT_C_FIND'"
             ctx.error_status = 0xC311
             generator = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_C_FIND,
                 {
                     "request": req,
@@ -576,11 +594,11 @@ def _n_action_scp(self, req: N_ACTION, context: "PresentationContext") -> None:
         rsp.AffectedSOPInstanceUID = req.RequestedSOPInstanceUID
         rsp.ActionTypeID = req.ActionTypeID
 
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in the handler bound to 'evt.EVT_N_ACTION'"
             ctx.error_status = 0x0110
             user_response = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_N_ACTION,
                 {"request": req, "context": context.as_tuple},
             )
@@ -728,11 +746,11 @@ def _n_create_scp(self, req: N_CREATE, context: "PresentationContext") -> None:
         rsp.AffectedSOPClassUID = req.AffectedSOPClassUID
         rsp.AffectedSOPInstanceUID = req.AffectedSOPInstanceUID
 
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in the handler bound to 'evt.EVT_N_CREATE'"
             ctx.error_status = 0x0110
             user_response = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_N_CREATE,
                 {"request": req, "context": context.as_tuple},
             )
@@ -861,11 +879,11 @@ def _n_delete_scp(self, req: N_DELETE, context: "PresentationContext") -> None:
         rsp.AffectedSOPClassUID = req.RequestedSOPClassUID
         rsp.AffectedSOPInstanceUID = req.RequestedSOPInstanceUID
 
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in the handler bound to 'evt.EVT_N_DELETE'"
             ctx.error_status = 0x0110
             status = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_N_DELETE,
                 {"request": req, "context": context.as_tuple},
             )
@@ -966,11 +984,11 @@ def _n_event_report_scp(
         rsp.AffectedSOPInstanceUID = req.AffectedSOPInstanceUID
         rsp.EventTypeID = req.EventTypeID
 
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in the handler bound to 'evt.EVT_N_EVENT_REPORT'"
             ctx.error_status = 0x0110
             user_response = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_N_EVENT_REPORT,
                 {"request": req, "context": context.as_tuple},
             )
@@ -1114,11 +1132,11 @@ def _n_get_scp(self, req: N_GET, context: "PresentationContext") -> None:
         rsp.AffectedSOPClassUID = req.RequestedSOPClassUID
         rsp.AffectedSOPInstanceUID = req.RequestedSOPInstanceUID
 
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in the handler bound to 'evt.EVT_N_GET'"
             ctx.error_status = 0x0110
             user_response = evt.trigger(
-                self.assoc, evt.EVT_N_GET, {"request": req, "context": context.as_tuple}
+                ctx.assoc, evt.EVT_N_GET, {"request": req, "context": context.as_tuple}
             )
 
         # Exception in context or handler aborted/released
@@ -1271,11 +1289,11 @@ def _n_set_scp(self, req: N_SET, context: "PresentationContext") -> None:
         rsp.AffectedSOPClassUID = req.RequestedSOPClassUID
         rsp.AffectedSOPInstanceUID = req.RequestedSOPInstanceUID
 
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in the handler bound to 'evt.EVT_N_SET'"
             ctx.error_status = 0x0110
             user_response = evt.trigger(
-                self.assoc, evt.EVT_N_SET, {"request": req, "context": context.as_tuple}
+                ctx.assoc, evt.EVT_N_SET, {"request": req, "context": context.as_tuple}
             )
 
         # Exception in context or handler aborted/released
@@ -1444,12 +1462,14 @@ def SCP(self, req: C_ECHO, context: "PresentationContext") -> None:
         rsp.MessageIDBeingRespondedTo = req.MessageID
         rsp.AffectedSOPClassUID = req.AffectedSOPClassUID
 
+        setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
         try:
             status = evt.trigger(
                 self.assoc,
                 evt.EVT_C_ECHO,
                 {"request": req, "context": context.as_tuple},
             )
+
             # Event handler has aborted or released
             if not self.assoc.is_established:
                 return
@@ -1487,6 +1507,8 @@ def SCP(self, req: C_ECHO, context: "PresentationContext") -> None:
             LOGGER.exception(ex)
             rsp.Status = 0x0000
 
+        setattr(self.assoc, "abort", self.assoc._abort_blocking)
+
         # Check Status validity
         if not self.is_valid_status(cast(int, rsp.Status)):
             LOGGER.warning(
@@ -1524,11 +1546,11 @@ def SCP(self, req: C_STORE, context: "PresentationContext") -> None:
         cx_id = cast(int, context.context_id)
 
         # Try and trigger EVT_C_STORE
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in handler bound to 'evt.EVT_C_STORE'"
             ctx.error_status = 0xC211
             rsp_status = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_C_STORE,
                 {"request": req, "context": context.as_tuple},
             )
@@ -1684,11 +1706,11 @@ def _get_scp(self, req: C_GET, context: "PresentationContext") -> None:
                 pass
 
         # Try and trigger EVT_C_GET
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in handler bound to 'evt.EVT_C_GET'"
             ctx.error_status = 0xC411
             generator = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_C_GET,
                 {
                     "request": req,
@@ -2049,11 +2071,11 @@ def _move_scp(self, req: C_MOVE, context: "PresentationContext") -> None:
                 pass
 
         # Try and trigger EVT_C_MOVE
-        with attempt(rsp, self.dimse, cx_id) as ctx:
+        with attempt(rsp, self.dimse, cx_id, self.assoc) as ctx:
             ctx.error_msg = "Exception in handler bound to 'evt.EVT_C_MOVE'"
             ctx.error_status = 0xC511
             generator = evt.trigger(
-                self.assoc,
+                ctx.assoc,
                 evt.EVT_C_MOVE,
                 {
                     "request": req,
@@ -2542,6 +2564,7 @@ def SCP(self, req: C_FIND, context: "PresentationContext") -> None:
                 # The user should deal with decoding failures
                 pass
 
+        setattr(self.assoc, "abort", self.assoc._abort_nonblocking)
         try:
             responses = evt.trigger(
                 self.assoc,
@@ -2555,6 +2578,7 @@ def SCP(self, req: C_FIND, context: "PresentationContext") -> None:
             responses = cast(Iterator[UserReturnType], responses)
             (rsp_status, rsp_identifier) = next(responses)
         except (StopIteration, TypeError):
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
             # Event handler has aborted or released - before any yields
             if not self.assoc.is_established:
                 return
@@ -2566,12 +2590,15 @@ def SCP(self, req: C_FIND, context: "PresentationContext") -> None:
             self.dimse.send_msg(rsp, cx_id)
             return
         except Exception as ex:
+            setattr(self.assoc, "abort", self.assoc._abort_blocking)
             LOGGER.error("Exception in handler bound to 'evt.EVT_C_FIND'")
             LOGGER.exception(ex)
             rsp.Status = 0xC311
             self.dimse.send_msg(rsp, cx_id)
             return
 
+        setattr(self.assoc, "abort", self.assoc._abort_blocking)
+
         # Event handler has aborted or released
         if not self.assoc.is_established:
             return


Test patch:
diff --git a/pynetdicom/tests/test_assoc.py b/pynetdicom/tests/test_assoc.py
index abe2e0a68..d30810a57 100644
--- a/pynetdicom/tests/test_assoc.py
+++ b/pynetdicom/tests/test_assoc.py
@@ -45,6 +45,7 @@
 from pynetdicom.dsutils import encode, decode
 from pynetdicom.events import Event
 from pynetdicom._globals import MODE_REQUESTOR
+from pynetdicom.pdu import A_RELEASE_RQ
 from pynetdicom.pdu_primitives import (
     UserIdentityNegotiation,
     SOPClassExtendedNegotiation,
@@ -659,6 +660,35 @@ def test_ipv6(self):
 
         scp.shutdown()
 
+    def test_abort_dul_shutdown(self):
+        """Test for #912"""
+
+        made_it = []
+
+        def on_pdu_recv(event):
+            if isinstance(event.pdu, A_RELEASE_RQ):
+                event.assoc.abort()
+                made_it.append(True)
+
+        hh = [(evt.EVT_PDU_RECV, on_pdu_recv)]
+
+        self.ae = ae = AE()
+        ae.acse_timeout = 5
+        ae.dimse_timeout = 5
+        ae.network_timeout = 5
+        ae.add_supported_context(Verification)
+        ae.add_requested_context(Verification)
+        scp = ae.start_server(("localhost", 11112), block=False, evt_handlers=hh)
+
+        assoc = ae.associate("localhost", 11112)
+        assoc.release()
+
+        assert assoc.is_aborted
+
+        scp.shutdown()
+
+        assert len(made_it) > 0
+
 
 class TestCStoreSCP:
     """Tests for Association._c_store_scp()."""
diff --git a/pynetdicom/tests/test_service_class.py b/pynetdicom/tests/test_service_class.py
index 7bd3d8779..90538521f 100644
--- a/pynetdicom/tests/test_service_class.py
+++ b/pynetdicom/tests/test_service_class.py
@@ -8,7 +8,7 @@
 
 from pynetdicom import build_context
 from pynetdicom.dimse_primitives import C_STORE, C_GET, C_MOVE, C_CANCEL
-from pynetdicom.service_class import StorageServiceClass, ServiceClass
+from pynetdicom.service_class import StorageServiceClass, ServiceClass, attempt
 
 
 class DummyAssoc:
@@ -142,3 +142,10 @@ def test_is_cancelled_match(self):
         assert service.is_cancelled(2) is False
         assert service.is_cancelled(3) is False
         assert cancel not in assoc.dimse.cancel_req.values()
+
+
+def test_attempt_non_assoc():
+    with attempt(None, None, 1) as ctx:
+        msg = "No association instance has been set"
+        with pytest.raises(ValueError, match=msg):
+            ctx.assoc
diff --git a/pynetdicom/tests/test_transport.py b/pynetdicom/tests/test_transport.py
index 112a5c560..7324bb2c0 100644
--- a/pynetdicom/tests/test_transport.py
+++ b/pynetdicom/tests/test_transport.py
@@ -331,6 +331,12 @@ def handle_close(event):
     def test_get_local_addr(self):
         """Test get_local_addr()."""
         sock = AssociationSocket(self.assoc, address=AddressInformation("", 11112))
+
+        # Normal use
+        with pytest.warns(DeprecationWarning, match="get_local_addr"):
+            addr = sock.get_local_addr(("", 11113))
+
+        # Exception
         with pytest.warns(DeprecationWarning, match="get_local_addr"):
             addr = sock.get_local_addr(("", 111111))