Skip to content

Commit c7d446e

Browse files
authored
Wait finished timeout (#216)
* Added timeout handling in native SSHClient wait_finished. * Updated libssh client wait_finished timeout to be separate from session timeout - resolves #182. * Added single client timeout tests. * Updated changelog, documentation. * Added timeout, finished and unfinished commands as timeout exception arguments on parallel client join. * Updated tunnel cleanup, error handling, timeout test.
1 parent a0b0967 commit c7d446e

File tree

8 files changed

+77
-41
lines changed

8 files changed

+77
-41
lines changed

Changelog.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
Change Log
22
============
33

4-
1.13.0 (unreleased)
5-
++++++++++++++++++++
4+
1.13.0
5+
++++++
66

77
Changes
88
--------
99

1010
* Added ``pssh.config.HostConfig`` for providing per-host configuration. Replaces dictionary ``host_config`` which is now deprecated. See `per-host configuration <https://parallel-ssh.readthedocs.io/en/latest/advanced.html#per-host-configuration>`_ documentation.
1111
* ``ParallelSSHClient.scp_send`` and ``scp_recv`` with directory target path will now copy source file to directory keeping existing name instead of failing when recurse is off - #183.
12+
* ``pssh.clients.ssh.SSHClient`` ``wait_finished`` timeout is now separate from ``SSHClient(timeout=<timeout>)`` session timeout.
13+
* ``ParallelSSHClient.join`` with timeout now has finished and unfinished commands as ``Timeout`` exception arguments for use by client code.
1214

1315
Fixes
1416
------
@@ -17,6 +19,7 @@ Fixes
1719
* ``ParallelSSHClient.copy_file`` and ``scp_recv`` with recurse enabled would not create remote directories when copying empty local directories.
1820
* ``ParallelSSHClient.scp_send`` would require SFTP when recurse is off and remote destination path contains directory - #157.
1921
* ``ParallelSSHClient.scp_recv`` could block infinitely on large - 200-300MB or more - files.
22+
* ``SSHClient.wait_finished`` would not apply timeout value given.
2023

2124

2225
1.12.1

pssh/clients/base/parallel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ def join(self, output, consume_output=False, timeout=None,
343343
if unfinished_cmds:
344344
raise Timeout(
345345
"Timeout of %s sec(s) reached with commands "
346-
"still running")
346+
"still running", timeout, finished_cmds, unfinished_cmds)
347347

348348
def _join(self, host_out, consume_output=False, timeout=None,
349349
encoding="utf-8"):

pssh/clients/native/single.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from collections import deque
2121
from warnings import warn
2222

23-
from gevent import sleep, spawn, get_hub
23+
from gevent import sleep, spawn, get_hub, Timeout as GTimeout
2424
from ssh2.error_codes import LIBSSH2_ERROR_EAGAIN
2525
from ssh2.exceptions import SFTPHandleError, SFTPProtocolError, \
2626
Timeout as SSH2Timeout, AgentConnectionError, AgentListIdentitiesError, \
@@ -264,14 +264,23 @@ def wait_finished(self, channel, timeout=None):
264264
265265
:param channel: The channel to use.
266266
:type channel: :py:class:`ssh2.channel.Channel`
267+
:param timeout: Timeout value in seconds - defaults to no timeout.
268+
:type timeout: float
269+
270+
:raises: :py:class:`pssh.exceptions.Timeout` after <timeout> seconds if
271+
timeout given.
267272
"""
268273
if channel is None:
269274
return
270275
# If wait_eof() returns EAGAIN after a select with a timeout, it means
271276
# it reached timeout without EOF and _select_timeout will raise
272277
# timeout exception causing the channel to appropriately
273278
# not be closed as the command is still running.
274-
self._eagain(channel.wait_eof)
279+
if timeout is not None:
280+
with GTimeout(seconds=timeout, exception=Timeout):
281+
self._eagain(channel.wait_eof)
282+
else:
283+
self._eagain(channel.wait_eof)
275284
# Close channel to indicate no more commands will be sent over it
276285
self.close_channel(channel)
277286

pssh/clients/native/tunnel.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ def _read_forward_sock(self, forward_sock, channel):
110110
return
111111
try:
112112
data = forward_sock.recv(1024)
113-
except Exception:
114-
logger.exception("Forward socket read error:")
113+
except Exception as ex:
114+
logger.error("Forward socket read error: %s", ex)
115115
sleep(1)
116116
continue
117117
data_len = len(data)
@@ -121,8 +121,8 @@ def _read_forward_sock(self, forward_sock, channel):
121121
while data_written < data_len:
122122
try:
123123
rc, bytes_written = channel.write(data[data_written:])
124-
except Exception:
125-
logger.exception("Channel write error:")
124+
except Exception as ex:
125+
logger.error("Channel write error: %s", ex)
126126
sleep(1)
127127
continue
128128
data_written += bytes_written
@@ -184,16 +184,16 @@ def _init_tunnel_client(self):
184184
self.tunnel_open.set()
185185

186186
def cleanup(self):
187-
for _sock in self._sockets:
188-
if not _sock:
189-
continue
190-
try:
187+
for i in range(len(self._sockets)):
188+
_sock = self._sockets[i]
189+
if _sock is not None and not _sock.closed:
191190
_sock.close()
192-
except Exception as ex:
193-
logger.error("Exception while closing sockets - %s", ex)
194-
if self.session is not None:
191+
self._sockets[i] = None
192+
self._sockets = None
193+
if self.client is not None and self.session is not None:
195194
self.client.disconnect()
196195
self.session = None
196+
self.client = None
197197

198198
def _consume_q(self):
199199
while True:
@@ -259,8 +259,8 @@ def _start_tunnel(self, fw_host, fw_port):
259259
try:
260260
channel = self._open_channel_retries(fw_host, fw_port, local_port)
261261
except Exception as ex:
262-
logger.exception("Could not establish channel to %s:%s:",
263-
fw_host, fw_port)
262+
logger.error("Could not establish channel to %s:%s: %s",
263+
fw_host, fw_port, ex)
264264
self.exception = ex
265265
forward_sock.close()
266266
listen_socket.close()

pssh/clients/ssh/single.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -333,22 +333,27 @@ def wait_finished(self, channel, timeout=None):
333333
334334
:param channel: The channel to use.
335335
:type channel: :py:class:`ssh.channel.Channel`
336+
:param timeout: Timeout value in seconds - defaults to no timeout.
337+
:type timeout: float
338+
339+
:raises: :py:class:`pssh.exceptions.Timeout` after <timeout> seconds if
340+
timeout given.
336341
"""
337342
if channel is None:
338343
return
339-
timeout = timeout if timeout else self.timeout
340344
logger.debug("Sending EOF on channel %s", channel)
341-
eagain(self.session, channel.send_eof, timeout=timeout)
342-
try:
343-
self._stdout_reader.get(timeout=timeout)
344-
self._stderr_reader.get(timeout=timeout)
345-
except GeventTimeout as ex:
346-
logger.debug("Timed out waiting for readers..")
347-
raise Timeout(ex)
345+
eagain(self.session, channel.send_eof, timeout=self.timeout)
346+
if timeout is not None:
347+
with GeventTimeout(seconds=timeout, exception=Timeout):
348+
logger.debug("Waiting for readers, timeout %s", timeout)
349+
self._stdout_reader.get(timeout=timeout)
350+
self._stderr_reader.get(timeout=timeout)
348351
else:
349-
logger.debug("Readers finished, closing channel")
350-
# Close channel
351-
self.close_channel(channel)
352+
self._stdout_reader.get()
353+
self._stderr_reader.get()
354+
logger.debug("Readers finished, closing channel")
355+
# Close channel
356+
self.close_channel(channel)
352357

353358
def finished(self, channel):
354359
"""Checks if remote command has finished - has server sent client

tests/native/test_single_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import subprocess
2323
import shutil
2424
from hashlib import sha256
25+
from datetime import datetime
2526

2627
from gevent import socket, sleep, spawn
2728

@@ -239,6 +240,17 @@ def test_finished(self):
239240
self.assertTrue(self.client.finished(channel))
240241
self.assertListEqual(stdout, [b'me'])
241242

243+
def test_wait_finished_timeout(self):
244+
channel = self.client.execute('sleep 2')
245+
timeout = 1
246+
self.assertFalse(self.client.finished(channel))
247+
start = datetime.now()
248+
self.assertRaises(Timeout, self.client.wait_finished, channel, timeout=timeout)
249+
dt = datetime.now() - start
250+
self.assertTrue(timeout*1.05 > dt.total_seconds() > timeout)
251+
self.client.wait_finished(channel)
252+
self.assertTrue(self.client.finished(channel))
253+
242254
def test_scp_abspath_recursion(self):
243255
cur_dir = os.path.dirname(__file__)
244256
dir_name_to_copy = 'a_dir'

tests/native/test_tunnel.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,7 @@ def test_tunnel_init(self):
151151
self.assertTrue(tunnel.tunnel_open.is_set())
152152
self.assertIsNotNone(tunnel.client)
153153
tunnel.cleanup()
154-
for _sock in tunnel._sockets:
155-
self.assertTrue(_sock.closed)
154+
self.assertIsNone(tunnel._sockets)
156155
finally:
157156
server.stop()
158157

@@ -302,9 +301,12 @@ def test_tunnel_remote_host_timeout(self):
302301
for _server in (server, remote_server):
303302
_server.stop()
304303
_server.join()
305-
# Gevent timeout cannot be caught by stop_on_errors
306-
self.assertRaises(GTimeout, client.run_command, self.cmd,
307-
greenlet_timeout=1, stop_on_errors=False)
304+
try:
305+
client.run_command(self.cmd, greenlet_timeout=1)
306+
except (GTimeout, Exception):
307+
pass
308+
else:
309+
raise Exception("Command neither failed nor timeout raised")
308310
finally:
309311
for _server in (server, remote_server):
310312
_server.stop()

tests/ssh/test_single_client.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import unittest
1919
import logging
2020

21+
from datetime import datetime
22+
2123
from ssh.session import Session
2224
# from ssh.exceptions import SocketDisconnectError
2325
from pssh.exceptions import AuthenticationException, ConnectionErrorException, \
@@ -66,13 +68,16 @@ def test_long_running_cmd(self):
6668
exit_code = channel.get_exit_status()
6769
self.assertEqual(exit_code, 2)
6870

69-
def test_client_wait_finished_timeout(self):
70-
client = SSHClient(self.host, port=self.port,
71-
pkey=self.user_key,
72-
num_retries=1,
73-
timeout=0.6)
74-
chan = client.execute('sleep 1')
75-
self.assertRaises(Timeout, client.wait_finished, chan)
71+
def test_wait_finished_timeout(self):
72+
channel = self.client.execute('sleep 2')
73+
timeout = 1
74+
self.assertFalse(self.client.finished(channel))
75+
start = datetime.now()
76+
self.assertRaises(Timeout, self.client.wait_finished, channel, timeout=timeout)
77+
dt = datetime.now() - start
78+
self.assertTrue(timeout*1.05 > dt.total_seconds() > timeout)
79+
self.client.wait_finished(channel)
80+
self.assertTrue(self.client.finished(channel))
7681

7782
def test_client_exec_timeout(self):
7883
client = SSHClient(self.host, port=self.port,

0 commit comments

Comments
 (0)