Skip to content

Commit baa2ab8

Browse files
authored
Merge pull request #658 from Jakuje/crash-exec
Prevent `SEGFAULT`s on consecutive `exec_command()` invocations
2 parents 80bccfe + 6ff7c9e commit baa2ab8

File tree

12 files changed

+66
-34
lines changed

12 files changed

+66
-34
lines changed

.github/workflows/ci-cd.yml

-9
Original file line numberDiff line numberDiff line change
@@ -1101,15 +1101,6 @@ jobs:
11011101
&& '4.0.0-2'
11021102
|| '2.6.0-1'
11031103
}}${{ steps.distribution-meta.outputs.dist-tag }}.noarch.rpm
1104-
https://rpmfind.net/linux/epel/"$(
1105-
rpm --eval '%{rhel}'
1106-
)"/Everything/x86_64/Packages/p/python3-pytest-forked-${{
1107-
contains(matrix.target-container.tag, 'ubi9')
1108-
&& '1.4.0'
1109-
|| '1.0.2'
1110-
}}-1${{
1111-
steps.distribution-meta.outputs.dist-tag
1112-
}}.noarch.rpm
11131104
https://rpmfind.net/linux/epel/"$(
11141105
rpm --eval '%{rhel}'
11151106
)"/Everything/x86_64/Packages/p/python3-pytest-xdist-${{
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Repetitive calls to ``exec_channel()`` no longer crash and return reliable output -- by :user:`Jakuje`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The ``pytest-forked`` dependency of build, development and test environments was removed -- by :user:`Jakuje`.

packaging/rpm/ansible-pylibssh.spec

-4
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ Source14: %{pypi_source typing_extensions 4.12.2}
5656
%endif
5757
Source15: %{pypi_source pytest 6.2.4}
5858
Source16: %{pypi_source pytest-cov 2.12.1}
59-
Source17: %{pypi_source pytest-forked 1.3.0}
6059
Source18: %{pypi_source pytest-xdist 2.3.0}
6160
Source19: %{pypi_source iniconfig 1.1.1}
6261
Source20: %{pypi_source attrs 20.3.0}
@@ -77,7 +76,6 @@ BuildRequires: openssh-clients
7776
%if 0%{?rhel}
7877
BuildRequires: python3dist(pytest)
7978
BuildRequires: python3dist(pytest-cov)
80-
BuildRequires: python3dist(pytest-forked)
8179
BuildRequires: python3dist(pytest-xdist)
8280
BuildRequires: python3dist(tox)
8381
%endif
@@ -166,8 +164,6 @@ PYTHONPATH="$(pwd)/bin" \
166164
PYTHONPATH="$(pwd)/bin" \
167165
%{__python3} -m pip install --no-deps -t bin %{SOURCE16}
168166
PYTHONPATH="$(pwd)/bin" \
169-
%{__python3} -m pip install --no-deps -t bin %{SOURCE17}
170-
PYTHONPATH="$(pwd)/bin" \
171167
%{__python3} -m pip install --no-deps -t bin %{SOURCE18}
172168
PYTHONPATH="$(pwd)/bin" \
173169
%{__python3} -m pip install --no-deps -t bin %{SOURCE19}

pytest.ini

-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ faulthandler_timeout = 30
4949
filterwarnings =
5050
error
5151
ignore:Coverage disabled via --no-cov switch!:pytest.PytestWarning:pytest_cov.plugin
52-
ignore:pytest-forked xfail support is incomplete at the moment and may output a misleading reason message:RuntimeWarning:pytest_forked
5352

5453
# FIXME: drop this once `pytest-cov` is updated.
5554
# Ref: https://github.com/pytest-dev/pytest-cov/issues/557

src/pylibsshext/channel.pxd

+4
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ cdef class Channel:
2323
cdef _session
2424
cdef libssh.ssh_channel _libssh_channel
2525
cdef libssh.ssh_session _libssh_session
26+
27+
cdef class ChannelCallback:
28+
cdef callbacks.ssh_channel_callbacks_struct callback
29+
cdef _userdata

src/pylibsshext/channel.pyx

+21-7
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ cdef int _process_outputs(libssh.ssh_session session,
4545
result.stdout += data_b
4646
return len
4747

48+
cdef class ChannelCallback:
49+
def __cinit__(self):
50+
memset(&self.callback, 0, sizeof(self.callback))
51+
callbacks.ssh_callbacks_init(&self.callback)
52+
self.callback.channel_data_function = <callbacks.ssh_channel_data_callback>&_process_outputs
53+
54+
def set_user_data(self, userdata):
55+
self._userdata = userdata
56+
self.callback.userdata = <void *>self._userdata
57+
4858
cdef class Channel:
4959
def __cinit__(self, session):
5060
self._session = session
@@ -159,19 +169,23 @@ cdef class Channel:
159169
libssh.ssh_channel_free(channel)
160170
raise LibsshChannelException("Failed to open_session: [{0}]".format(rc))
161171

172+
result = CompletedProcess(args=command, returncode=-1, stdout=b'', stderr=b'')
173+
174+
cb = ChannelCallback()
175+
cb.set_user_data(result)
176+
callbacks.ssh_set_channel_callbacks(channel, &cb.callback)
177+
# keep the callback around in the session object to avoid use after free
178+
self._session.push_callback(cb)
179+
162180
rc = libssh.ssh_channel_request_exec(channel, command.encode("utf-8"))
163181
if rc != libssh.SSH_OK:
164182
libssh.ssh_channel_close(channel)
165183
libssh.ssh_channel_free(channel)
166184
raise LibsshChannelException("Failed to execute command [{0}]: [{1}]".format(command, rc))
167-
result = CompletedProcess(args=command, returncode=-1, stdout=b'', stderr=b'')
168185

169-
cdef callbacks.ssh_channel_callbacks_struct cb
170-
memset(&cb, 0, sizeof(cb))
171-
cb.channel_data_function = <callbacks.ssh_channel_data_callback>&_process_outputs
172-
cb.userdata = <void *>result
173-
callbacks.ssh_callbacks_init(&cb)
174-
callbacks.ssh_set_channel_callbacks(channel, &cb)
186+
# wait before remote writes all data before closing the channel
187+
while not libssh.ssh_channel_is_eof(channel):
188+
libssh.ssh_channel_poll(channel, 0)
175189

176190
libssh.ssh_channel_send_eof(channel)
177191
result.returncode = libssh.ssh_channel_get_exit_status(channel)

src/pylibsshext/session.pxd

+1
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ cdef class Session:
2626
cdef _hash_py
2727
cdef _fingerprint_py
2828
cdef _keytype_py
29+
cdef _channel_callbacks
2930

3031
cdef libssh.ssh_session get_libssh_session(Session session)

src/pylibsshext/session.pyx

+7
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ cdef class Session(object):
108108
self._hash_py = None
109109
self._fingerprint_py = None
110110
self._keytype_py = None
111+
# Due to delayed freeing of channels, some older libssh versions might expect
112+
# the callbacks to be around even after we free the underlying channels so
113+
# we should free them only when we terminate the session.
114+
self._channel_callbacks = []
111115

112116
def __cinit__(self, host=None, **kwargs):
113117
self._libssh_session = libssh.ssh_new()
@@ -124,6 +128,9 @@ cdef class Session(object):
124128
libssh.ssh_free(self._libssh_session)
125129
self._libssh_session = NULL
126130

131+
def push_callback(self, callback):
132+
self._channel_callbacks.append(callback)
133+
127134
@property
128135
def port(self):
129136
cdef unsigned int port_i

tests/conftest.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
"""Pytest plugins and fixtures configuration."""
55

6+
import logging
67
import shutil
78
import socket
89
import subprocess
@@ -115,6 +116,8 @@ def ssh_client_session(ssh_session_connect):
115116
# noqa: DAR101
116117
"""
117118
ssh_session = Session()
119+
# TODO Adjust when #597 will be merged
120+
ssh_session.set_log_level(logging.CRITICAL)
118121
ssh_session_connect(ssh_session)
119122
try: # noqa: WPS501
120123
yield ssh_session
@@ -173,6 +176,7 @@ def sshd_addr(free_port_num, ssh_authorized_keys_path, sshd_hostkey_path, sshd_p
173176
'/usr/sbin/sshd',
174177
'-D',
175178
'-f', '/dev/null',
179+
'-E', '/dev/stderr',
176180
opt, 'LogLevel=DEBUG3',
177181
opt, 'HostKey={key!s}'.format(key=sshd_hostkey_path),
178182
opt, 'PidFile={pid!s}'.format(pid=sshd_path / 'sshd.pid'),
@@ -187,7 +191,6 @@ def sshd_addr(free_port_num, ssh_authorized_keys_path, sshd_hostkey_path, sshd_p
187191
opt, 'StrictModes=no',
188192
opt, 'PermitEmptyPasswords=yes',
189193
opt, 'PermitRootLogin=yes',
190-
opt, 'Protocol=2',
191194
opt, 'HostbasedAuthentication=no',
192195
opt, 'IgnoreUserKnownHosts=yes',
193196
opt, 'Port={port:d}'.format(port=free_port_num), # port before addr

tests/unit/channel_test.py

+27-10
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,36 @@ def ssh_channel(ssh_client_session):
3333
chan.close()
3434

3535

36-
@pytest.mark.xfail(
37-
reason='This test causes SEGFAULT, flakily. '
38-
'Ref: https://github.com/ansible/pylibssh/issues/57',
39-
strict=False,
40-
)
41-
@pytest.mark.forked
36+
def exec_second_command(ssh_channel):
37+
"""Check the standard output of ``exec_command()`` as a string."""
38+
u_cmd = ssh_channel.exec_command('echo -n Hello Again')
39+
assert u_cmd.returncode == 0
40+
assert u_cmd.stderr.decode() == '' # noqa: WPS302
41+
assert u_cmd.stdout.decode() == u'Hello Again' # noqa: WPS302
42+
43+
4244
def test_exec_command(ssh_channel):
4345
"""Test getting the output of a remotely executed command."""
44-
u_cmd_out = ssh_channel.exec_command('echo -n Hello World').stdout.decode()
45-
assert u_cmd_out == u'Hello World' # noqa: WPS302
46+
u_cmd = ssh_channel.exec_command('echo -n Hello World')
47+
assert u_cmd.returncode == 0
48+
assert u_cmd.stderr.decode() == ''
49+
assert u_cmd.stdout.decode() == u'Hello World' # noqa: WPS302
4650
# Test that repeated calls to exec_command do not segfault.
47-
u_cmd_out = ssh_channel.exec_command('echo -n Hello Again').stdout.decode()
48-
assert u_cmd_out == u'Hello Again' # noqa: WPS302
51+
52+
# NOTE: Call `exec_command()` once again from another function to
53+
# NOTE: force it to happen in another place of the call stack,
54+
# NOTE: making sure that the context is different from one in this
55+
# NOTE: this test function. The resulting call stack will end up
56+
# NOTE: being more random.
57+
exec_second_command(ssh_channel)
58+
59+
60+
def test_exec_command_stderr(ssh_channel):
61+
"""Test getting the stderr of a remotely executed command."""
62+
u_cmd = ssh_channel.exec_command('echo -n Hello World 1>&2')
63+
assert u_cmd.returncode == 0
64+
assert u_cmd.stderr.decode() == u'Hello World' # noqa: WPS302
65+
assert u_cmd.stdout.decode() == ''
4966

5067

5168
def test_double_close(ssh_channel):

tox.ini

-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ deps =
1818
coverage >= 5.3
1919
pytest
2020
pytest-cov
21-
pytest-forked
2221
pytest-xdist
2322
passenv =
2423
PYTHONPATH
@@ -44,7 +43,6 @@ deps =
4443
expandvars
4544
pytest
4645
pytest-cov
47-
pytest-forked
4846
pytest-xdist
4947
setenv =
5048
ANSIBLE_PYLIBSSH_CYTHON_TRACING = {env:ANSIBLE_PYLIBSSH_CYTHON_TRACING:1}

0 commit comments

Comments
 (0)