Skip to content

Commit 3654bb8

Browse files
authored
Merge pull request #756 from justin-stephenson/retry_ssh_again
Retry on libssh `SSH_AGAIN` return code
2 parents 3d6cbc9 + 717e4ca commit 3654bb8

File tree

9 files changed

+116
-11
lines changed

9 files changed

+116
-11
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Added a ``pylibsshext.session.connect()`` parameter
2+
``open_session_retries`` -- by :user:`justin-stephenson`.
3+
4+
The ``open_session_retries`` session ``connect()``
5+
parameter allows a configurable number of retries if
6+
libssh ``ssh_channel_open_session()`` returns ``SSH_AGAIN``.
7+
The default option value is 0, no retries will be
8+
attempted.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Added a ``pylibsshext.session.connect()`` parameter
2+
``timeout_usec`` to set ``SSH_OPTIONS_TIMEOUT_USEC``.
3+
4+
This allows setting the ``SSH_OPTIONS_TIMEOUT_USEC``
5+
ssh option, though ``SSH_OPTIONS_TIMEOUT`` is a more
6+
practical option.
7+
8+
-- by :user:`justin-stephenson`

src/pylibsshext/channel.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ cdef class Channel:
2323
cdef _session
2424
cdef libssh.ssh_channel _libssh_channel
2525
cdef libssh.ssh_session _libssh_session
26+
cdef _open_session_with_retries(self, libssh.ssh_channel test_channel)
2627

2728
cdef class ChannelCallback:
2829
cdef callbacks.ssh_channel_callbacks_struct callback

src/pylibsshext/channel.pyx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ from libc.string cimport memset
2424

2525
from pylibsshext.errors cimport LibsshChannelException
2626
from pylibsshext.errors import LibsshChannelReadFailure
27-
from pylibsshext.session cimport get_libssh_session
27+
from pylibsshext.session cimport get_libssh_session, get_session_retries
2828

2929
from subprocess import CompletedProcess
3030

@@ -63,12 +63,8 @@ cdef class Channel:
6363

6464
if self._libssh_channel is NULL:
6565
raise MemoryError
66-
rc = libssh.ssh_channel_open_session(self._libssh_channel)
6766

68-
if rc != libssh.SSH_OK:
69-
libssh.ssh_channel_free(self._libssh_channel)
70-
self._libssh_channel = NULL
71-
raise LibsshChannelException("Failed to open_session: [%d]" % rc)
67+
self._open_session_with_retries(self._libssh_channel)
7268

7369
def __dealloc__(self):
7470
if self._libssh_channel is not NULL:
@@ -158,16 +154,28 @@ cdef class Channel:
158154
response = recv_buff.getvalue()
159155
return response
160156

157+
cdef _open_session_with_retries(self, libssh.ssh_channel channel):
158+
retry = get_session_retries(self._session)
159+
160+
for attempt in range(retry + 1):
161+
rc = libssh.ssh_channel_open_session(channel)
162+
if rc == libssh.SSH_OK:
163+
break
164+
if rc == libssh.SSH_AGAIN and attempt < retry:
165+
continue
166+
# either SSH_ERROR, or SSH_AGAIN with final attempt
167+
if rc != libssh.SSH_OK:
168+
self._libssh_channel = NULL
169+
libssh.ssh_channel_free(channel)
170+
raise LibsshChannelException(f"Failed to open_session: [{rc}]")
171+
161172
def exec_command(self, command):
162173
# request_exec requires a fresh channel each run, so do not use the existing channel
163174
cdef libssh.ssh_channel channel = libssh.ssh_channel_new(self._libssh_session)
164175
if channel is NULL:
165176
raise MemoryError
166177

167-
rc = libssh.ssh_channel_open_session(channel)
168-
if rc != libssh.SSH_OK:
169-
libssh.ssh_channel_free(channel)
170-
raise LibsshChannelException("Failed to open_session: [{0}]".format(rc))
178+
self._open_session_with_retries(channel)
171179

172180
result = CompletedProcess(args=command, returncode=-1, stdout=b'', stderr=b'')
173181

src/pylibsshext/session.pxd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ cdef class Session:
2626
cdef _hash_py
2727
cdef _fingerprint_py
2828
cdef _keytype_py
29+
cdef _retries
2930
cdef _channel_callbacks
3031

3132
cdef libssh.ssh_session get_libssh_session(Session session)
33+
cdef int get_session_retries(Session session)

src/pylibsshext/session.pyx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ OPTS_MAP = {
3131
"user": libssh.SSH_OPTIONS_USER,
3232
"port": libssh.SSH_OPTIONS_PORT,
3333
"timeout": libssh.SSH_OPTIONS_TIMEOUT,
34+
"timeout_usec": libssh.SSH_OPTIONS_TIMEOUT_USEC,
3435
"knownhosts": libssh.SSH_OPTIONS_KNOWNHOSTS,
3536
"proxycommand": libssh.SSH_OPTIONS_PROXYCOMMAND,
3637
"key_exchange_algorithms": libssh.SSH_OPTIONS_KEY_EXCHANGE,
@@ -108,6 +109,7 @@ cdef class Session(object):
108109
self._hash_py = None
109110
self._fingerprint_py = None
110111
self._keytype_py = None
112+
self._retries = 0
111113
# Due to delayed freeing of channels, some older libssh versions might expect
112114
# the callbacks to be around even after we free the underlying channels so
113115
# we should free them only when we terminate the session.
@@ -175,7 +177,7 @@ cdef class Session(object):
175177
elif key == "port":
176178
value_uint = value
177179
libssh.ssh_options_set(self._libssh_session, key_m, &value_uint)
178-
elif key == "timeout":
180+
elif key in {"timeout", "timeout_usec"}:
179181
value_long = value
180182
libssh.ssh_options_set(self._libssh_session, key_m, &value_long)
181183
else:
@@ -235,9 +237,17 @@ cdef class Session(object):
235237
file should be validated. It defaults to True
236238
:type host_key_checking: boolean
237239
240+
:param open_session_retries: The number of retries to attempt when libssh
241+
channel function ``ssh_channel_open_session()`` returns ``SSH_AGAIN``. It defaults
242+
to 0, no retries attempted.
243+
:type open_session_retries: integer
244+
238245
:param timeout: The timeout in seconds for the TCP connect
239246
:type timeout: long integer
240247
248+
:param timeout_usec: The timeout in microseconds for the TCP connect
249+
:type timeout_usec: long integer
250+
241251
:param port: The ssh server port to connect to
242252
:type port: integer
243253
@@ -261,6 +271,9 @@ cdef class Session(object):
261271
libssh.ssh_disconnect(self._libssh_session)
262272
raise
263273

274+
if 'open_session_retries' in kwargs:
275+
self._retries = kwargs['open_session_retries']
276+
264277
# We need to userauth_none before we can query the available auth types
265278
rc = libssh.ssh_userauth_none(self._libssh_session, NULL)
266279
if rc == libssh.SSH_AUTH_SUCCESS:
@@ -553,3 +566,6 @@ cdef class Session(object):
553566

554567
cdef libssh.ssh_session get_libssh_session(Session session):
555568
return session._libssh_session
569+
570+
cdef int get_session_retries(Session session):
571+
return session._retries

tests/_service_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def wait_for_svc_ready_state(
6969

7070
def ensure_ssh_session_connected( # noqa: WPS317
7171
ssh_session, sshd_addr, ssh_clientkey_path, # noqa: WPS318
72+
ssh_session_retries=0,
7273
):
7374
"""Attempt connecting to the SSH server until successful.
7475
@@ -89,4 +90,5 @@ def ensure_ssh_session_connected( # noqa: WPS317
8990
private_key=ssh_clientkey_path.read_bytes(),
9091
host_key_checking=False,
9192
look_for_keys=False,
93+
open_session_retries=ssh_session_retries,
9294
)

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ def ssh_client_session(ssh_session_connect):
126126
del ssh_session # noqa: WPS420
127127

128128

129+
@pytest.fixture
130+
def ssh_session_connect_retries(sshd_addr, ssh_clientkey_path):
131+
"""
132+
Authenticate existing session object against SSHD with a private SSH key.
133+
134+
This sets ``open_session_retries=100`` and it returns a function
135+
that takes session as parameter.
136+
137+
:returns: Function that will connect the session.
138+
:rtype: Callback
139+
"""
140+
return partial(
141+
ensure_ssh_session_connected,
142+
sshd_addr=sshd_addr,
143+
ssh_clientkey_path=ssh_clientkey_path,
144+
ssh_session_retries=100,
145+
)
146+
147+
129148
@pytest.fixture
130149
def ssh_session_connect(sshd_addr, ssh_clientkey_path):
131150
"""

tests/unit/channel_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@
88

99
import pytest
1010

11+
from pylibsshext.errors import LibsshChannelException
1112
from pylibsshext.session import Session
1213

1314

1415
COMMAND_TIMEOUT = 30
1516
POLL_EXIT_CODE_TIMEOUT = 5
1617
POLL_TIMEOUT = 5000
18+
# Lowest possible timeout_usec is 1000 because timeout_usec / 1000 must be > 0,
19+
# otherwise libssh sets timeout to 10000
20+
SMALL_TIMEOUT_USEC = 1000
21+
LARGE_TIMEOUT_SEC = 10
1722

1823

1924
@pytest.fixture
@@ -33,6 +38,42 @@ def ssh_channel(ssh_client_session):
3338
chan.close()
3439

3540

41+
def test_open_session_small_timeout(ssh_session_connect):
42+
"""Test opening a new channel with a small timeout value.
43+
44+
This generates an exception from ``ssh_channel_open_session()``
45+
returning ``SSH_AGAIN`` with the ``usec`` timeout and default
46+
``open_session_retries`` value of ``0``.
47+
"""
48+
ssh_session = Session()
49+
ssh_session_connect(ssh_session)
50+
ssh_session.set_ssh_options('timeout_usec', SMALL_TIMEOUT_USEC)
51+
error_msg = '^Failed to open_session'
52+
with pytest.raises(LibsshChannelException, match=error_msg):
53+
ssh_session.new_channel()
54+
ssh_session.close()
55+
56+
57+
def test_open_session_large_timeout(ssh_session_connect):
58+
"""Test opening a new channel with a large timeout value."""
59+
ssh_session = Session()
60+
ssh_session_connect(ssh_session)
61+
ssh_session.set_ssh_options('timeout', LARGE_TIMEOUT_SEC)
62+
ssh_channel = ssh_session.new_channel()
63+
ssh_channel.close()
64+
ssh_session.close()
65+
66+
67+
def test_open_session_small_timeout_with_retries(ssh_session_connect_retries):
68+
"""Test with a small timeout value and retries set."""
69+
ssh_session = Session()
70+
ssh_session_connect_retries(ssh_session)
71+
ssh_session.set_ssh_options('timeout_usec', SMALL_TIMEOUT_USEC)
72+
ssh_channel = ssh_session.new_channel()
73+
ssh_channel.close()
74+
ssh_session.close()
75+
76+
3677
def exec_second_command(ssh_channel):
3778
"""Check the standard output of ``exec_command()`` as a string."""
3879
u_cmd = ssh_channel.exec_command('echo -n Hello Again')

0 commit comments

Comments
 (0)