Skip to content

Commit 71c7fcd

Browse files
Kaivan Kamalialanking
authored andcommitted
[#345][#352] Fix connection destructor issue and fix the infinite loop issue when sock.recv() returns an empty buffer
1 parent f5f8c4e commit 71c7fcd

5 files changed

Lines changed: 138 additions & 16 deletions

File tree

irods/connection.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222

2323
ALLOW_PAM_LONG_TOKENS = True # True to fix [#279]
24-
24+
# Message to be logged when the connection
25+
# destructor is called. Used in a unit test
26+
DESTRUCTOR_MSG = "connection __del__() called"
2527

2628
from irods import (
2729
MAX_PASSWORD_LENGTH, RESPONSE_LEN,
@@ -80,8 +82,8 @@ def client_signature(self):
8082
return self._client_signature
8183

8284
def __del__(self):
83-
if self.socket and getattr(self,"_disconnected",False):
84-
self.disconnect()
85+
self.disconnect()
86+
logger.debug(DESTRUCTOR_MSG)
8587

8688
def send(self, message):
8789
string = message.pack()
@@ -107,7 +109,12 @@ def recv(self, into_buffer = None
107109
msg = iRODSMessage.recv(self.socket)
108110
else:
109111
msg = iRODSMessage.recv_into(self.socket, into_buffer)
110-
except socket.error:
112+
except (socket.error, socket.timeout) as e:
113+
# If _recv_message_in_len() fails in recv() or recv_into(),
114+
# it will throw a socket.error exception. The exception is
115+
# caught here, a critical message is logged, and is wrapped
116+
# in a NetworkException with a more user friendly message
117+
logger.critical(e)
111118
logger.error("Could not receive server response")
112119
self.release(True)
113120
raise NetworkException("Could not receive server response")
@@ -273,17 +280,24 @@ def _connect(self):
273280
return version_msg.get_main_message(VersionResponse)
274281

275282
def disconnect(self):
276-
disconnect_msg = iRODSMessage(msg_type='RODS_DISCONNECT')
277-
self.send(disconnect_msg)
278-
try:
279-
# SSL shutdown handshake
280-
self.socket = self.socket.unwrap()
281-
except AttributeError:
282-
pass
283-
self.socket.shutdown(socket.SHUT_RDWR)
284-
self.socket.close()
285-
self.socket = None
286-
self._disconnected = True
283+
# Moved the conditions to call disconnect() inside the function.
284+
# Added a new criteria for calling disconnect(); Only call
285+
# disconnect() if fileno is not -1 (fileno -1 indicates the socket
286+
# is already closed). This makes it safe to call disconnect multiple
287+
# times on the same connection. The first call cleans up the resources
288+
# and next calls are no-ops
289+
if self.socket and getattr(self, "_disconnected", False) == False and self.socket.fileno() != -1:
290+
disconnect_msg = iRODSMessage(msg_type='RODS_DISCONNECT')
291+
self.send(disconnect_msg)
292+
try:
293+
# SSL shutdown handshake
294+
self.socket = self.socket.unwrap()
295+
except AttributeError:
296+
pass
297+
self.socket.shutdown(socket.SHUT_RDWR)
298+
self.socket.close()
299+
self.socket = None
300+
self._disconnected = True
287301

288302
def recvall(self, n):
289303
# Helper function to recv n bytes or return None if EOF is hit

irods/message/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ def ET(xml_type = 0, server_version = None):
153153
def _recv_message_in_len(sock, size):
154154
size_left = size
155155
retbuf = None
156+
157+
# Get socket properties for debug and exception messages.
158+
host, port = sock.getpeername()
159+
is_blocking = sock.getblocking()
160+
timeout = sock.gettimeout()
161+
162+
logger.debug(f'host: {host}')
163+
logger.debug(f'port: {port}')
164+
logger.debug(f'is_blocking: {is_blocking}')
165+
logger.debug(f'timeout: {timeout}')
166+
156167
while size_left > 0:
157168
try:
158169
buf = sock.recv(size_left, socket.MSG_WAITALL)
@@ -163,11 +174,27 @@ def _recv_message_in_len(sock, size):
163174
if getattr(e, 'winerror', 0) != 10045:
164175
raise
165176
buf = sock.recv(size_left)
177+
178+
# This prevents an infinite loop. If the call to recv()
179+
# returns an empty buffer, break out of the loop.
180+
if len(buf) == 0:
181+
break
166182
size_left -= len(buf)
167183
if retbuf is None:
168184
retbuf = buf
169185
else:
170186
retbuf += buf
187+
188+
# This method is supposed to read and return 'size'
189+
# bytes from the socket. If it reads no bytes (retbuf
190+
# will be None), or if it reads less number of bytes
191+
# than 'size', throw a socket.error exception
192+
if retbuf is None or len(retbuf) != size:
193+
retbuf_size = len(retbuf) if retbuf is not None else 0
194+
msg = 'Read {} bytes from the socket (host {}, port {}) instead of expected {} bytes'.format(
195+
retbuf_size, host, port, size)
196+
raise socket.error(msg)
197+
171198
return retbuf
172199

173200

irods/pool.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ def get_connection(self):
6464
# release the connection (as its stale) and create a new one
6565
if self.refresh_connection and (curr_time - conn.create_time).total_seconds() > self.connection_refresh_time:
6666
logger.debug('Connection with id {} was created more than {} seconds ago. Releasing the connection and creating a new one.'.format(id(conn), self.connection_refresh_time))
67-
self.release_connection(conn, True)
67+
# Since calling disconnect() repeatedly is safe, we call disconnect()
68+
# here explicitly, instead of relying on the garbage collector to clean
69+
# up the object and call disconnect(). This makes the behavior of the
70+
# code more predictable as we are not relying on when garbage collector is called
71+
conn.disconnect()
6872
conn = Connection(self, self.account)
6973
logger.debug("Created new connection with id: {}".format(id(conn)))
7074
except KeyError:

irods/test/connection_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ def test_connection(self):
2424
def test_connection_destructor(self):
2525
conn = self.sess.pool.get_connection()
2626
conn.__del__()
27+
# These asserts confirm that disconnect() in connection destructor is called
28+
self.assertIsNone(conn.socket)
29+
self.assertTrue(conn._disconnected)
2730
conn.release(destroy=True)
2831

2932
def test_failed_connection(self):

irods/test/pool_test.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
#! /usr/bin/env python
22
from __future__ import absolute_import
33
import datetime
4+
import gc
5+
import logging
46
import os
57
import re
68
import sys
9+
import tempfile
710
import time
811
import json
912
import unittest
1013
import irods.test.helpers as helpers
14+
from irods.connection import DESTRUCTOR_MSG
1115

1216
# Regular expression to match common synonyms for localhost.
1317
#
@@ -237,6 +241,76 @@ def test_no_refresh_connection(self):
237241
self.assertEqual(0, len(self.sess.pool.active))
238242
self.assertEqual(0, len(self.sess.pool.idle))
239243

244+
# Test to confirm the connection destructor log message is actually
245+
# logged to file, to confirm the destructor is called
246+
def test_connection_destructor_called(self):
247+
# Set 'irods_connection_refresh_time' to '3' (in seconds) in
248+
# ~/.irods/irods_environment.json file. This means any connection
249+
# that was created more than 3 seconds ago will be dropped and
250+
# a new connection is created/returned. This is to avoid
251+
# issue with idle connections that are dropped.
252+
conn_obj_id_1 = None
253+
conn_obj_id_2 = None
254+
create_time_1 = None
255+
create_time_2 = None
256+
last_used_time_1 = None
257+
last_used_time_2 = None
258+
259+
try:
260+
261+
# Create a temporary log file
262+
my_log_file = tempfile.NamedTemporaryFile()
263+
264+
logging.getLogger('irods.connection').setLevel(logging.DEBUG)
265+
file_handler = logging.FileHandler(my_log_file.name, mode='a')
266+
file_handler.setLevel(logging.DEBUG)
267+
logging.getLogger('irods.connection').addHandler(file_handler)
268+
269+
with self.sess.pool.get_connection() as conn:
270+
conn_obj_id_1 = id(conn)
271+
curr_time = datetime.datetime.now()
272+
create_time_1 = conn.create_time
273+
last_used_time_1 = conn.last_used_time
274+
self.assertTrue(curr_time >= create_time_1)
275+
self.assertTrue(curr_time >= last_used_time_1)
276+
self.assertEqual(1, len(self.sess.pool.active))
277+
self.assertEqual(0, len(self.sess.pool.idle))
278+
279+
self.sess.pool.release_connection(conn)
280+
self.assertEqual(0, len(self.sess.pool.active))
281+
self.assertEqual(1, len(self.sess.pool.idle))
282+
283+
# Wait more than 'irods_connection_refresh_time' seconds,
284+
# which is set to 3. Connection object should have a different
285+
# object ID (as a new connection is created)
286+
time.sleep(5)
287+
288+
# Call garbage collector, so the unreferenced conn object is garbage collected
289+
gc.collect()
290+
291+
with self.sess.pool.get_connection() as conn:
292+
conn_obj_id_2 = id(conn)
293+
curr_time = datetime.datetime.now()
294+
create_time_2 = conn.create_time
295+
last_used_time_2 = conn.last_used_time
296+
self.assertTrue(curr_time >= create_time_2)
297+
self.assertTrue(curr_time >= last_used_time_2)
298+
self.assertNotEqual(conn_obj_id_1, conn_obj_id_2)
299+
self.assertTrue(create_time_2 > create_time_1)
300+
self.assertEqual(1, len(self.sess.pool.active))
301+
self.assertEqual(0, len(self.sess.pool.idle))
302+
303+
self.sess.pool.release_connection(conn, True)
304+
self.assertEqual(0, len(self.sess.pool.active))
305+
self.assertEqual(0, len(self.sess.pool.idle))
306+
307+
# Assert that connection destructor called
308+
with open(my_log_file.name, 'r') as fh:
309+
lines = fh.read().splitlines()
310+
self.assertTrue(DESTRUCTOR_MSG in lines)
311+
finally:
312+
# Remove irods.connection's file_handler that was added just for this test
313+
logging.getLogger('irods.connection').removeHandler(file_handler)
240314

241315
def test_get_connection_refresh_time_no_env_file_input_param(self):
242316
connection_refresh_time = self.sess.get_connection_refresh_time(first_name="Magic", last_name="Johnson")

0 commit comments

Comments
 (0)