Skip to content

Commit 6374aff

Browse files
session: Add 'open_session_retries' option
Improve pylibssh handling when libssh ssh_channel_open_session() returns SSH_AGAIN. Add a new 'open_session_retries' session connect() parameter to allow a configurable number of retries. SSH_AGAIN may be returned when setting a low SSH options timeout value. The default option value is 0, no retries will be attempted.
1 parent 9cda69d commit 6374aff

File tree

7 files changed

+99
-10
lines changed

7 files changed

+99
-10
lines changed

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ cdef class Session(object):
109109
self._hash_py = None
110110
self._fingerprint_py = None
111111
self._keytype_py = None
112+
self._retries = 0
112113
# Due to delayed freeing of channels, some older libssh versions might expect
113114
# the callbacks to be around even after we free the underlying channels so
114115
# we should free them only when we terminate the session.
@@ -236,9 +237,17 @@ cdef class Session(object):
236237
file should be validated. It defaults to True
237238
:type host_key_checking: boolean
238239
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+
239245
:param timeout: The timeout in seconds for the TCP connect
240246
:type timeout: long integer
241247
248+
:param timeout_usec: The timeout in microseconds for the TCP connect
249+
:type timeout_usec: long integer
250+
242251
:param port: The ssh server port to connect to
243252
:type port: integer
244253
@@ -262,6 +271,9 @@ cdef class Session(object):
262271
libssh.ssh_disconnect(self._libssh_session)
263272
raise
264273

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

555567
cdef libssh.ssh_session get_libssh_session(Session session):
556568
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: 42 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,43 @@ 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+
"""
60+
ssh_session = Session()
61+
ssh_session_connect(ssh_session)
62+
ssh_session.set_ssh_options('timeout', LARGE_TIMEOUT_SEC)
63+
ssh_channel = ssh_session.new_channel()
64+
ssh_channel.close()
65+
ssh_session.close()
66+
67+
68+
def test_open_session_small_timeout_with_retries(ssh_session_connect_retries):
69+
"""Test with a small timeout value and retries set."""
70+
ssh_session = Session()
71+
ssh_session_connect_retries(ssh_session)
72+
ssh_session.set_ssh_options('timeout_usec', SMALL_TIMEOUT_USEC)
73+
ssh_channel = ssh_session.new_channel()
74+
ssh_channel.close()
75+
ssh_session.close()
76+
77+
3678
def exec_second_command(ssh_channel):
3779
"""Check the standard output of ``exec_command()`` as a string."""
3880
u_cmd = ssh_channel.exec_command('echo -n Hello Again')

0 commit comments

Comments
 (0)