Skip to content

Commit 75c0c3d

Browse files
committed
Better handle timeout in execute_together
Sometimes paramiko hangs on remote status event wait in parallel calls (when remote side already closed connection)
1 parent 6f3713d commit 75c0c3d

File tree

6 files changed

+91
-53
lines changed

6 files changed

+91
-53
lines changed

doc/source/conf.py

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import pkg_resources
1717

1818
release = pkg_resources.get_distribution("exec-helpers").version
19-
version = '.'.join(release.split('.')[:2])
19+
version = ".".join(release.split(".")[:2])
2020

2121
# If extensions (or modules to document with autodoc) are in another directory,
2222
# add these directories to sys.path here. If the directory is relative to the
@@ -36,32 +36,32 @@
3636
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
3737
# ones.
3838
extensions = [
39-
'sphinx.ext.autodoc',
40-
'sphinx.ext.doctest',
41-
'sphinx.ext.coverage',
42-
'sphinx.ext.viewcode',
39+
"sphinx.ext.autodoc",
40+
"sphinx.ext.doctest",
41+
"sphinx.ext.coverage",
42+
"sphinx.ext.viewcode",
4343
]
4444

4545
# Add any paths that contain templates here, relative to this directory.
46-
templates_path = ['_templates']
46+
templates_path = ["_templates"]
4747

4848
# The suffix(es) of source filenames.
4949
# You can specify multiple suffix as a list of string:
5050
#
5151
# source_suffix = ['.rst', '.md']
52-
source_suffix = '.rst'
52+
source_suffix = ".rst"
5353

5454
# The encoding of source files.
5555
#
5656
# source_encoding = 'utf-8-sig'
5757

5858
# The master toctree document.
59-
master_doc = 'index'
59+
master_doc = "index"
6060

6161
# General information about the project.
62-
project = 'Exec-helpers'
63-
copyright = '2018, Alexey Stepanov'
64-
author = 'Alexey Stepanov'
62+
project = "Exec-helpers"
63+
copyright = "2018, Alexey Stepanov"
64+
author = "Alexey Stepanov"
6565

6666
# The version info for the project you're documenting, acts as replacement for
6767
# |version| and |release|, also used in various other places throughout the
@@ -112,7 +112,7 @@
112112
# show_authors = False
113113

114114
# The name of the Pygments (syntax highlighting) style to use.
115-
pygments_style = 'sphinx'
115+
pygments_style = "sphinx"
116116

117117
# A list of ignored prefixes for module index sorting.
118118
# modindex_common_prefix = []
@@ -129,14 +129,14 @@
129129
# The theme to use for HTML and HTML Help pages. See the documentation for
130130
# a list of builtin themes.
131131
#
132-
html_theme = 'alabaster'
132+
html_theme = "alabaster"
133133

134134
# Theme options are theme-specific and customize the look and feel of a theme
135135
# further. For a list of options available for each theme, see the
136136
# documentation.
137137
#
138138
html_theme_options = {
139-
'page_width': 'auto',
139+
"page_width": "auto",
140140
}
141141

142142
# Add any paths that contain custom themes here, relative to this directory.
@@ -165,7 +165,7 @@
165165
# Add any paths that contain custom static files (such as style sheets) here,
166166
# relative to this directory. They are copied after the builtin static files,
167167
# so a file named "default.css" will overwrite the builtin "default.css".
168-
html_static_path = ['_static']
168+
html_static_path = ["_static"]
169169

170170
# Add any extra paths that contain custom files (such as robots.txt or
171171
# .htaccess) here, relative to this directory. These files are copied
@@ -245,35 +245,30 @@
245245
# html_search_scorer = 'scorer.js'
246246

247247
# Output file base name for HTML help builder.
248-
htmlhelp_basename = 'exec_helpers_doc'
248+
htmlhelp_basename = "exec_helpers_doc"
249249

250250
# -- Options for LaTeX output ---------------------------------------------
251251

252252
latex_elements = {
253-
# The paper size ('letterpaper' or 'a4paper').
254-
#
255-
# 'papersize': 'letterpaper',
256-
257-
# The font size ('10pt', '11pt' or '12pt').
258-
#
259-
# 'pointsize': '10pt',
260-
261-
# Additional stuff for the LaTeX preamble.
262-
#
263-
# 'preamble': '',
264-
265-
# Latex figure (float) alignment
266-
#
267-
# 'figure_align': 'htbp',
253+
# The paper size ('letterpaper' or 'a4paper').
254+
#
255+
# 'papersize': 'letterpaper',
256+
# The font size ('10pt', '11pt' or '12pt').
257+
#
258+
# 'pointsize': '10pt',
259+
# Additional stuff for the LaTeX preamble.
260+
#
261+
# 'preamble': '',
262+
# Latex figure (float) alignment
263+
#
264+
# 'figure_align': 'htbp',
268265
}
269266

270267
# Grouping the document tree into LaTeX files. List of tuples
271268
# (source start file, target name, title,
272269
# author, documentclass [howto, manual, or own class]).
273270
latex_documents = [
274-
(master_doc, 'Exec-helpers.tex',
275-
'Exec helpers Documentation',
276-
'Alexey Stepanov', 'manual'),
271+
(master_doc, "Exec-helpers.tex", "Exec helpers Documentation", "Alexey Stepanov", "manual"),
277272
]
278273

279274
# The name of an image file (relative to this directory) to place at the top of
@@ -313,10 +308,7 @@
313308

314309
# One entry per manual page. List of tuples
315310
# (source start file, name, description, authors, manual section).
316-
man_pages = [
317-
(master_doc, 'exec-helpers', 'Exec helpers Documentation',
318-
[author], 1)
319-
]
311+
man_pages = [(master_doc, "exec-helpers", "Exec helpers Documentation", [author], 1)]
320312

321313
# If true, show URL addresses after external links.
322314
#
@@ -329,9 +321,15 @@
329321
# (source start file, target name, title, author,
330322
# dir menu entry, description, category)
331323
texinfo_documents = [
332-
(master_doc, 'exec-helpers', 'Exec helpers Documentation',
333-
author, 'Exec-helpers', 'One line description of project.',
334-
'Miscellaneous'),
324+
(
325+
master_doc,
326+
"exec-helpers",
327+
"Exec helpers Documentation",
328+
author,
329+
"Exec-helpers",
330+
"One line description of project.",
331+
"Miscellaneous",
332+
),
335333
]
336334

337335
# Documents to append as an appendix to all manuals.

exec_helpers/_ssh_base.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,7 @@ def _execute_async( # pylint: disable=arguments-differ
709709
get_pty: bool = False,
710710
width: int = 80,
711711
height: int = 24,
712+
timeout: OptionalTimeoutT = None,
712713
**kwargs: typing.Any,
713714
) -> SshExecuteAsyncResult:
714715
"""Execute command in async mode and return channel with IO objects.
@@ -729,6 +730,8 @@ def _execute_async( # pylint: disable=arguments-differ
729730
:type width: int
730731
:param height: PTY height
731732
:type height: int
733+
:param timeout: timeout before stop execution with TimeoutError (will be set on channel)
734+
:type timeout: typing.Union[int, float, None]
732735
:param kwargs: additional parameters for call.
733736
:type kwargs: typing.Any
734737
:return: Tuple with control interface and file-like objects for STDIN/STDERR/STDOUT
@@ -751,6 +754,8 @@ def _execute_async( # pylint: disable=arguments-differ
751754
.. versionchanged:: 4.1.0 support chroot
752755
"""
753756
chan: paramiko.Channel = self._ssh_transport.open_session()
757+
if timeout is not None:
758+
chan.settimeout(timeout)
754759

755760
if get_pty:
756761
# Open PTY
@@ -1401,6 +1406,7 @@ def get_result(remote: SSHClientBase) -> exec_result.ExecResult:
14011406
14021407
:param remote: SSH connection instance
14031408
:return: execution result
1409+
:raises ExecHelperTimeoutError: Timeout exceeded
14041410
"""
14051411
# pylint: disable=protected-access
14061412
cmd_for_log: str = remote._mask_command(cmd=cmd, log_mask_re=log_mask_re)
@@ -1417,20 +1423,28 @@ def get_result(remote: SSHClientBase) -> exec_result.ExecResult:
14171423
log_mask_re=log_mask_re,
14181424
open_stdout=open_stdout,
14191425
open_stderr=open_stderr,
1426+
timeout=timeout,
14201427
**kwargs,
14211428
)
14221429
# pylint: enable=protected-access
14231430

1424-
async_result.interface.status_event.wait(timeout)
1425-
exit_code = async_result.interface.recv_exit_status()
1431+
done = async_result.interface.status_event.wait(timeout)
14261432

14271433
res = exec_result.ExecResult(cmd=cmd_for_log, stdin=stdin, started=async_result.started)
14281434
res.read_stdout(src=async_result.stdout)
14291435
res.read_stderr(src=async_result.stderr)
1430-
res.exit_code = exit_code
1436+
if done:
1437+
res.exit_code = async_result.interface.recv_exit_status()
14311438

14321439
async_result.interface.close()
1433-
return res
1440+
if done:
1441+
return res
1442+
async_result.interface.status_event.set()
1443+
result.set_timestamp()
1444+
1445+
wait_err_msg: str = _log_templates.CMD_WAIT_ERROR.format(result=res, timeout=timeout)
1446+
remote.logger.debug(wait_err_msg)
1447+
raise exceptions.ExecHelperTimeoutError(result=res, timeout=timeout) # type: ignore
14341448

14351449
prep_expected: typing.Sequence[ExitCodeT] = proc_enums.exit_codes_to_enums(expected)
14361450
log_level: int = logging.INFO if verbose else logging.DEBUG
@@ -1443,14 +1457,15 @@ def get_result(remote: SSHClientBase) -> exec_result.ExecResult:
14431457
errors: typing.Dict[typing.Tuple[str, int], exec_result.ExecResult] = {}
14441458
raised_exceptions: typing.Dict[typing.Tuple[str, int], Exception] = {}
14451459

1446-
_, not_done = concurrent.futures.wait(list(futures.values()), timeout=timeout)
1460+
not_done: typing.Set[concurrent.futures.Future[exec_result.ExecResult]]
1461+
_done, not_done = concurrent.futures.wait(futures.values(), timeout=timeout)
14471462

14481463
for fut in not_done: # pragma: no cover
14491464
fut.cancel()
14501465

14511466
for remote, future in futures.items():
14521467
try:
1453-
result = future.result()
1468+
result = future.result(timeout=0.1)
14541469
results[(remote.hostname, remote.port)] = result
14551470
if result.exit_code not in prep_expected:
14561471
errors[(remote.hostname, remote.port)] = result

exec_helpers/exceptions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,9 @@ def __init__(
304304
305305
.. versionchanged:: 3.4.0 Expected is not optional, defaults os dependent
306306
"""
307-
exceptions_str: str = "\n\t".join(f"{host}:{port} - {exc} " for (host, port), exc in exceptions.items())
307+
exceptions_str: str = "\n\t".join(
308+
f"{host}:{port} - {str(exc) if str(exc) else repr(exc)}" for (host, port), exc in exceptions.items()
309+
)
308310
message: str = _message or f"Command {command!r} during execution raised exceptions: \n\t{exceptions_str}"
309311
super().__init__(
310312
command=command,

test/async_api/test_subprocess_special.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,17 @@ class MockParameters(typing.NamedTuple):
122122
"TimeoutError": dict(
123123
stdout=(),
124124
command_parameters=CommandParameters(),
125-
mock_parameters=MockParameters(ec=(asyncio.TimeoutError(), -9),),
125+
mock_parameters=MockParameters(
126+
ec=(asyncio.TimeoutError(), -9),
127+
),
126128
expect_exc=exec_helpers.ExecHelperTimeoutError,
127129
),
128130
"TimeoutError_no_kill": dict(
129131
stdout=(),
130132
command_parameters=CommandParameters(),
131-
mock_parameters=MockParameters(ec=(asyncio.TimeoutError(), None),),
133+
mock_parameters=MockParameters(
134+
ec=(asyncio.TimeoutError(), None),
135+
),
132136
expect_exc=exec_helpers.ExecHelperNoKillError,
133137
),
134138
"stdin_closed_PIPE_windows": dict(
@@ -238,7 +242,8 @@ def create_subprocess_shell(mocker, monkeypatch, run_parameters):
238242
mocker.patch("psutil.Process")
239243

240244
def create_mock(
241-
stdout: typing.Optional[typing.Tuple] = None, **kwargs,
245+
stdout: typing.Optional[typing.Tuple] = None,
246+
**kwargs,
242247
):
243248
"""Parametrized code."""
244249
proc = asynctest.CoroutineMock()

test/test_ssh_client_execute.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,13 +464,15 @@ def test_009_execute_together(ssh, ssh2, execute_async, exec_result, run_paramet
464464
log_mask_re=None,
465465
open_stdout=run_parameters["open_stdout"],
466466
open_stderr=run_parameters["open_stderr"],
467+
timeout=default_timeout,
467468
),
468469
mock.call(
469470
command,
470471
stdin=run_parameters["stdin"],
471472
log_mask_re=None,
472473
open_stdout=run_parameters["open_stdout"],
473474
open_stderr=run_parameters["open_stderr"],
475+
timeout=default_timeout,
474476
),
475477
)
476478
)
@@ -503,13 +505,15 @@ def test_010_execute_together_expected(ssh, ssh2, execute_async, exec_result, ru
503505
log_mask_re=None,
504506
open_stdout=run_parameters["open_stdout"],
505507
open_stderr=run_parameters["open_stderr"],
508+
timeout=default_timeout,
506509
),
507510
mock.call(
508511
command,
509512
stdin=run_parameters.get("stdin", None),
510513
log_mask_re=None,
511514
open_stdout=run_parameters["open_stdout"],
512515
open_stderr=run_parameters["open_stderr"],
516+
timeout=default_timeout,
513517
),
514518
)
515519
)

test/test_ssh_client_execute_special.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,22 @@ def get_patched_execute_async_retval() -> SshExecuteAsyncResult:
240240
results = exec_helpers.SSHClient.execute_together(remotes=remotes, command=cmd)
241241
execute_async.assert_has_calls(
242242
(
243-
mock.call(decoded_cmd, stdin=None, log_mask_re=None, open_stdout=True, open_stderr=True),
244-
mock.call(decoded_cmd, stdin=None, log_mask_re=None, open_stdout=True, open_stderr=True),
243+
mock.call(
244+
decoded_cmd,
245+
stdin=None,
246+
log_mask_re=None,
247+
open_stdout=True,
248+
open_stderr=True,
249+
timeout=default_timeout,
250+
),
251+
mock.call(
252+
decoded_cmd,
253+
stdin=None,
254+
log_mask_re=None,
255+
open_stdout=True,
256+
open_stderr=True,
257+
timeout=default_timeout,
258+
),
245259
)
246260
)
247261
for result in results.values():

0 commit comments

Comments
 (0)