Skip to content

Commit f2bce51

Browse files
encukoucodenamenam
andauthored
pythongh-140691: urllib.request: Close FTP control socket if data socket can't connect (pythonGH-140835)
Co-authored-by: codenamenam <[email protected]>
1 parent 335d83e commit f2bce51

File tree

4 files changed

+70
-19
lines changed

4 files changed

+70
-19
lines changed

Lib/_py_warnings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,9 @@ def __str__(self):
646646
"line : %r}" % (self.message, self._category_name,
647647
self.filename, self.lineno, self.line))
648648

649+
def __repr__(self):
650+
return f'<{type(self).__qualname__} {self}>'
651+
649652

650653
class catch_warnings(object):
651654

Lib/test/test_urllib2net.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import contextlib
12
import errno
3+
import sysconfig
24
import unittest
5+
from unittest import mock
36
from test import support
47
from test.support import os_helper
58
from test.support import socket_helper
69
from test.support import ResourceDenied
10+
from test.support.warnings_helper import check_no_resource_warning
711

812
import os
913
import socket
@@ -143,6 +147,43 @@ def test_ftp(self):
143147
]
144148
self._test_urls(urls, self._extra_handlers())
145149

150+
@support.requires_resource('walltime')
151+
@unittest.skipIf(sysconfig.get_platform() == 'linux-ppc64le',
152+
'leaks on PPC64LE (gh-140691)')
153+
def test_ftp_no_leak(self):
154+
# gh-140691: When the data connection (but not control connection)
155+
# cannot be made established, we shouldn't leave an open socket object.
156+
157+
class MockError(OSError):
158+
pass
159+
160+
orig_create_connection = socket.create_connection
161+
def patched_create_connection(address, *args, **kwargs):
162+
"""Simulate REJECTing connections to ports other than 21"""
163+
host, port = address
164+
if port != 21:
165+
raise MockError()
166+
return orig_create_connection(address, *args, **kwargs)
167+
168+
url = 'ftp://www.pythontest.net/README'
169+
entry = url, None, urllib.error.URLError
170+
no_cache_handlers = [urllib.request.FTPHandler()]
171+
cache_handlers = self._extra_handlers()
172+
with mock.patch('socket.create_connection', patched_create_connection):
173+
with check_no_resource_warning(self):
174+
# Try without CacheFTPHandler
175+
self._test_urls([entry], handlers=no_cache_handlers,
176+
retry=False)
177+
with check_no_resource_warning(self):
178+
# Try with CacheFTPHandler (uncached)
179+
self._test_urls([entry], cache_handlers, retry=False)
180+
with check_no_resource_warning(self):
181+
# Try with CacheFTPHandler (cached)
182+
self._test_urls([entry], cache_handlers, retry=False)
183+
# Try without the mock: the handler should not use a closed connection
184+
with check_no_resource_warning(self):
185+
self._test_urls([url], cache_handlers, retry=False)
186+
146187
def test_file(self):
147188
TESTFN = os_helper.TESTFN
148189
f = open(TESTFN, 'w')
@@ -234,18 +275,16 @@ def _test_urls(self, urls, handlers, retry=True):
234275
else:
235276
req = expected_err = None
236277

278+
if expected_err:
279+
context = self.assertRaises(expected_err)
280+
else:
281+
context = contextlib.nullcontext()
282+
237283
with socket_helper.transient_internet(url):
238-
try:
284+
f = None
285+
with context:
239286
f = urlopen(url, req, support.INTERNET_TIMEOUT)
240-
# urllib.error.URLError is a subclass of OSError
241-
except OSError as err:
242-
if expected_err:
243-
msg = ("Didn't get expected error(s) %s for %s %s, got %s: %s" %
244-
(expected_err, url, req, type(err), err))
245-
self.assertIsInstance(err, expected_err, msg)
246-
else:
247-
raise
248-
else:
287+
if f is not None:
249288
try:
250289
with time_out, \
251290
socket_peer_reset, \

Lib/urllib/request.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,7 @@ def ftp_open(self, req):
15351535
dirs, file = dirs[:-1], dirs[-1]
15361536
if dirs and not dirs[0]:
15371537
dirs = dirs[1:]
1538+
fw = None
15381539
try:
15391540
fw = self.connect_ftp(user, passwd, host, port, dirs, req.timeout)
15401541
type = file and 'I' or 'D'
@@ -1552,8 +1553,12 @@ def ftp_open(self, req):
15521553
headers += "Content-length: %d\n" % retrlen
15531554
headers = email.message_from_string(headers)
15541555
return addinfourl(fp, headers, req.full_url)
1555-
except ftplib.all_errors as exp:
1556-
raise URLError(f"ftp error: {exp}") from exp
1556+
except Exception as exp:
1557+
if fw is not None and not fw.keepalive:
1558+
fw.close()
1559+
if isinstance(exp, ftplib.all_errors):
1560+
raise URLError(f"ftp error: {exp}") from exp
1561+
raise
15571562

15581563
def connect_ftp(self, user, passwd, host, port, dirs, timeout):
15591564
return ftpwrapper(user, passwd, host, port, dirs, timeout,
@@ -1577,14 +1582,15 @@ def setMaxConns(self, m):
15771582

15781583
def connect_ftp(self, user, passwd, host, port, dirs, timeout):
15791584
key = user, host, port, '/'.join(dirs), timeout
1580-
if key in self.cache:
1581-
self.timeout[key] = time.time() + self.delay
1582-
else:
1583-
self.cache[key] = ftpwrapper(user, passwd, host, port,
1584-
dirs, timeout)
1585-
self.timeout[key] = time.time() + self.delay
1585+
conn = self.cache.get(key)
1586+
if conn is None or not conn.keepalive:
1587+
if conn is not None:
1588+
conn.close()
1589+
conn = self.cache[key] = ftpwrapper(user, passwd, host, port,
1590+
dirs, timeout)
1591+
self.timeout[key] = time.time() + self.delay
15861592
self.check_cache()
1587-
return self.cache[key]
1593+
return conn
15881594

15891595
def check_cache(self):
15901596
# first check for old ones
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
In :mod:`urllib.request`, when opening a FTP URL fails because a data
2+
connection cannot be made, the control connection's socket is now closed to
3+
avoid a :exc:`ResourceWarning`.

0 commit comments

Comments
 (0)