Repository: python-trio/trio

Issue:
Check CapacityLimiter's handling of _pending_borrowers
I was just reading the code for an unrelated reason, and realized that this code looks very suspicious:

https://github.com/python-trio/trio/blob/65729b121c55ff0aa1f5d43bd9cdcd6a81e54f0c/trio/_sync.py#L286-L296

Specifically, if the call to `await self._lot.park()` gets cancelled, it looks like we don't clear `self._pending_borrowers[task]`, which could be a memory leak (since it pins task objects in memory).

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 33c0f098..e571c41e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -26,6 +26,15 @@ matrix:
     - python: 3.8-dev
       dist: xenial
       sudo: required
+    - os: osx
+      language: generic
+      env: MACPYTHON=3.5.4
+    - os: osx
+      language: generic
+      env: MACPYTHON=3.6.6
+    - os: osx
+      language: generic
+      env: MACPYTHON=3.7.0
 
 script:
   - ci/travis.sh
diff --git a/Jenkinsfile b/Jenkinsfile
index d94d4df1..50acf14b 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,7 +1,8 @@
 def configs = [
     [
         label: 'sierra',
-        pyversions: ['python3.5', 'python3.6'],
+        // pyversions: ['python3.5', 'python3.6'],
+        pyversions: ['python3.5'],
     ],
 ]
 
diff --git a/ci/travis.sh b/ci/travis.sh
index 3e0fde36..c856b9ab 100755
--- a/ci/travis.sh
+++ b/ci/travis.sh
@@ -6,6 +6,18 @@ YAPF_VERSION=0.20.1
 
 git rev-parse HEAD
 
+if [ "$TRAVIS_OS_NAME" = "osx" ]; then
+    curl -Lo macpython.pkg https://www.python.org/ftp/python/${MACPYTHON}/python-${MACPYTHON}-macosx10.6.pkg
+    sudo installer -pkg macpython.pkg -target /
+    ls /Library/Frameworks/Python.framework/Versions/*/bin/
+    PYTHON_EXE=/Library/Frameworks/Python.framework/Versions/*/bin/python3
+    # The pip in older MacPython releases doesn't support a new enough TLS
+    curl https://bootstrap.pypa.io/get-pip.py | sudo $PYTHON_EXE
+    sudo $PYTHON_EXE -m pip install virtualenv
+    $PYTHON_EXE -m virtualenv testenv
+    source testenv/bin/activate
+fi
+
 if [ "$USE_PYPY_NIGHTLY" = "1" ]; then
     curl -fLo pypy.tar.bz2 http://buildbot.pypy.org/nightly/py3.5/pypy-c-jit-latest-linux64.tar.bz2
     if [ ! -s pypy.tar.bz2 ]; then
diff --git a/newsfragments/548.bugfix.rst b/newsfragments/548.bugfix.rst
new file mode 100644
index 00000000..90c549af
--- /dev/null
+++ b/newsfragments/548.bugfix.rst
@@ -0,0 +1,1 @@
+Fix a memory leak in :class:`trio.CapacityLimiter`, that could occurr when ``acquire`` or ``acquire_on_behalf_of`` was cancelled.
diff --git a/trio/_ssl.py b/trio/_ssl.py
index 80f56714..74bf0628 100644
--- a/trio/_ssl.py
+++ b/trio/_ssl.py
@@ -839,7 +839,9 @@ class SSLStream(Stream):
 class SSLListener(Listener):
     """A :class:`~trio.abc.Listener` for SSL/TLS-encrypted servers.
 
-    :class:`SSLListener` allows you to wrap
+    :class:`SSLListener` wraps around another Listener, and converts
+    all incoming connections to encrypted connections by wrapping them
+    in a :class:`SSLStream`.
 
     Args:
       transport_listener (~trio.abc.Listener): The listener whose incoming
diff --git a/trio/_sync.py b/trio/_sync.py
index fe806dd3..a99f75a3 100644
--- a/trio/_sync.py
+++ b/trio/_sync.py
@@ -288,7 +288,11 @@ class CapacityLimiter:
         except _core.WouldBlock:
             task = _core.current_task()
             self._pending_borrowers[task] = borrower
-            await self._lot.park()
+            try:
+                await self._lot.park()
+            except _core.Cancelled:
+                self._pending_borrowers.pop(task)
+                raise
         except:
             await _core.cancel_shielded_checkpoint()
             raise


Test patch:
diff --git a/trio/tests/test_sync.py b/trio/tests/test_sync.py
index bded1b05..64aa3235 100644
--- a/trio/tests/test_sync.py
+++ b/trio/tests/test_sync.py
@@ -147,6 +147,21 @@ async def test_CapacityLimiter_change_total_tokens():
         assert c.statistics().tasks_waiting == 0
 
 
+# regression test for issue #548
+async def test_CapacityLimiter_memleak_548():
+    limiter = CapacityLimiter(total_tokens=1)
+    await limiter.acquire()
+
+    async with _core.open_nursery() as n:
+        n.start_soon(limiter.acquire)
+        await wait_all_tasks_blocked()  # give it a chance to run the task
+        n.cancel_scope.cancel()
+
+    # if this is 1, the acquire call (despite being killed) is still there in the task, and will
+    # leak memory all the while the limiter is active
+    assert len(limiter._pending_borrowers) == 0
+
+
 async def test_Semaphore():
     with pytest.raises(TypeError):
         Semaphore(1.0)