From ff851c0ee020a8a4fa83ac2120e3b494b93d940c Mon Sep 17 00:00:00 2001 From: Alexandre Vincent Date: Thu, 28 Aug 2025 11:14:21 +0200 Subject: [PATCH 1/4] [fix] Workaround channel.recv_exit_status not accepting timeout param --- openwisp_controller/connection/connectors/ssh.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openwisp_controller/connection/connectors/ssh.py b/openwisp_controller/connection/connectors/ssh.py index 67240837f..9eaba6877 100644 --- a/openwisp_controller/connection/connectors/ssh.py +++ b/openwisp_controller/connection/connectors/ssh.py @@ -196,7 +196,12 @@ def exec_command( logger.exception(e) raise e # store command exit status - exit_status = stdout.channel.recv_exit_status() + # workaround https://github.com/paramiko/paramiko/issues/1815 + # workaround https://github.com/paramiko/paramiko/issues/1787 + # Ref. https://docs.paramiko.org/en/stable/api/channel.html#paramiko.channel.Channel.recv_exit_status # noqa + stdout.channel.status_event.wait(timeout=timeout) + assert stdout.channel.status_event.is_set() + exit_status = stdout.channel.exit_status # log standard output # try to decode to UTF-8, ignoring unconvertible characters # https://docs.python.org/3/howto/unicode.html#the-string-type From 8c5a06e2120d7ef4cf794ef1616c8a092e93788f Mon Sep 17 00:00:00 2001 From: Alexandre Vincent Date: Fri, 29 Aug 2025 10:37:58 +0200 Subject: [PATCH 2/4] [fix] Update _exec_command_return_value mock after _exec_command update --- openwisp_controller/connection/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index 6db7162e3..cdec04b03 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -46,7 +46,7 @@ def _exec_command_return_value( stderr_ = mock.Mock() stdin_.read().decode.return_value = stdin stdout_.read().decode.return_value = stdout - stdout_.channel.recv_exit_status.return_value = exit_code + stdout_.channel.exit_status = exit_code stderr_.read().decode.return_value = stderr return (stdin_, stdout_, stderr_) From f2c76e7c868c9407e54c405d2ae1421c23e63b71 Mon Sep 17 00:00:00 2001 From: Alexandre Vincent Date: Fri, 29 Aug 2025 11:32:30 +0200 Subject: [PATCH 3/4] [change] Use PropertyMock for stdout.channel.exit_status on exec_command --- openwisp_controller/connection/tests/test_models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index cdec04b03..14693dfbd 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -1,5 +1,6 @@ import socket from unittest import mock +from unittest.mock import PropertyMock import paramiko from django.contrib.auth.models import ContentType @@ -46,7 +47,7 @@ def _exec_command_return_value( stderr_ = mock.Mock() stdin_.read().decode.return_value = stdin stdout_.read().decode.return_value = stdout - stdout_.channel.exit_status = exit_code + type(stdout_.channel).exit_status = PropertyMock(return_value=exit_code) stderr_.read().decode.return_value = stderr return (stdin_, stdout_, stderr_) @@ -1009,7 +1010,7 @@ def _assert_applying_conf_test_command(mocked_exec): # 1. Checking openwisp_config returns with 0 # 2. Testing presence of /tmp/openwisp/applying_conf returns with 1 # 3. Restarting openwisp_config returns with 0 exit code - stdout.channel.recv_exit_status.side_effect = [0, 1, 1] + type(stdout.channel).exit_status = PropertyMock(side_effect=[0, 1, 1]) mocked_exec_command.return_value = (stdin, stdout, stderr) conf.save() self.assertEqual(mocked_exec_command.call_count, 3) From 24ec5022647298f9d139ecb4652ce288bab1a6bc Mon Sep 17 00:00:00 2001 From: Alexandre Vincent Date: Fri, 29 Aug 2025 12:14:19 +0200 Subject: [PATCH 4/4] [change] Adjust exec_command timeout logic to not wait the double amount --- openwisp_controller/connection/connectors/ssh.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openwisp_controller/connection/connectors/ssh.py b/openwisp_controller/connection/connectors/ssh.py index 9eaba6877..61850054e 100644 --- a/openwisp_controller/connection/connectors/ssh.py +++ b/openwisp_controller/connection/connectors/ssh.py @@ -1,5 +1,6 @@ import logging import socket +import time from io import BytesIO, StringIO import paramiko @@ -186,6 +187,7 @@ def exec_command( logger.info("Executing command: {0}".format(command)) # execute commmand try: + start_cmd = time.perf_counter() stdin, stdout, stderr = self.shell.exec_command(command, timeout=timeout) # re-raise socket.timeout to avoid being catched # by the subsequent `except Exception as e` block @@ -199,7 +201,9 @@ def exec_command( # workaround https://github.com/paramiko/paramiko/issues/1815 # workaround https://github.com/paramiko/paramiko/issues/1787 # Ref. https://docs.paramiko.org/en/stable/api/channel.html#paramiko.channel.Channel.recv_exit_status # noqa - stdout.channel.status_event.wait(timeout=timeout) + stdout.channel.status_event.wait( + timeout=timeout - int(time.perf_counter() - start_cmd) + ) assert stdout.channel.status_event.is_set() exit_status = stdout.channel.exit_status # log standard output