Skip to content

Commit d6423c6

Browse files
bjandrebandre-ucar
authored andcommittedMar 28, 2018
Bugfix: timeout limit for subprocesses
For security reasons svn and git do not use standard in/out/error to request and process authentication. Instead, they use lower level system interfaces. Python calling a subprocess can only interact with stdio, and can not intercept or detect the lower level calls. On some systems, these requests are visible to the user, on some systems they are not. When subprocess commands expect user input they will wait and manage_externals appears to hang. Change the subprocess calls to polling the running process and checking the current run time. If the run time exceeds a timeout limit, then we display an error message suggesting that the user verify authentication and then declare a fatal error. The default timeout interval of 5 minutes is probably too long and can be shortened. But since svn requires network interaction and some large checkouts can take a long time, this is a 'conservative' initial value. Breakup up the subprocess call routine so it is easier to understand. Testing: make test - python2/python3 - all tests pass. manually test with very short timeout to ensure error message is raised.
1 parent 7998f60 commit d6423c6

File tree

1 file changed

+97
-22
lines changed

1 file changed

+97
-22
lines changed
 

‎manic/utils.py

+97-22
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313
import subprocess
1414
import sys
15+
import time
1516

1617
from .global_constants import LOCAL_PATH_INDICATOR, LOG_FILE_NAME
1718

@@ -198,6 +199,82 @@ def expand_local_url(url, field):
198199
# subprocess
199200
#
200201
# ---------------------------------------------------------------------
202+
_TIMEOUT_MSG = """ Timout errors typically occur when svn or git requires
203+
authentication to access a private repository. On some systems, svn
204+
and git requests for authentication information will not be displayed
205+
to the user. In this case, the program will appear to hang and
206+
generate a timeout error. Ensure you can run svn and git manually and
207+
access all repositories without entering your authentication
208+
information."""
209+
210+
_TIMEOUT_SEC = 300
211+
_POLL_DELTA_SEC = 0.02
212+
213+
214+
def _poll_subprocess(commands, status_to_caller, output_to_caller,
215+
timeout_sec=_TIMEOUT_SEC):
216+
"""Create a subprocess and poll the process until complete.
217+
218+
Impose a timeout limit and checkout process output for known
219+
conditions that require user interaction.
220+
221+
NOTE: the timeout_delta has significant impact on run time. If it
222+
is too long, and the many quick local subprocess calls will
223+
drastically increase the run time, especially in tests.
224+
225+
NOTE: This function is broken out into for ease of
226+
understanding. It does no error checking. It should only be called
227+
from execute_subprocess, never directly.
228+
229+
"""
230+
logging.info(' '.join(commands))
231+
output = []
232+
start = time.time()
233+
234+
proc = subprocess.Popen(commands,
235+
shell=False,
236+
stdout=subprocess.PIPE,
237+
stderr=subprocess.STDOUT,
238+
universal_newlines=True)
239+
while proc.poll() is None:
240+
time.sleep(_POLL_DELTA_SEC)
241+
if time.time() - start > timeout_sec:
242+
proc.kill()
243+
time.sleep(_POLL_DELTA_SEC * 5)
244+
msg = ("subprocess call to '{0}' has exceeded timeout limit of "
245+
"{1} seconds.\n{2}".format(commands[0], timeout_sec,
246+
_TIMEOUT_MSG))
247+
fatal_error(msg)
248+
finish = time.time()
249+
250+
run_time_msg = "run time : {0:.2f} seconds".format(finish - start)
251+
logging.info(run_time_msg)
252+
output = proc.stdout.read()
253+
log_process_output(output)
254+
status = proc.returncode
255+
256+
# NOTE(bja, 2018-03) need to cleanup open files. In python3 use
257+
# "with subprocess.Popen(...) as proc:", but that is not available
258+
# with python2 unless we create a context manager.
259+
proc.stdout.close()
260+
261+
if status != 0:
262+
raise subprocess.CalledProcessError(returncode=status,
263+
cmd=commands,
264+
output=output)
265+
266+
if status_to_caller and output_to_caller:
267+
ret_value = (status, output)
268+
elif status_to_caller:
269+
ret_value = status
270+
elif output_to_caller:
271+
ret_value = output
272+
else:
273+
ret_value = None
274+
275+
return ret_value
276+
277+
201278
def execute_subprocess(commands, status_to_caller=False,
202279
output_to_caller=False):
203280
"""Wrapper around subprocess.check_output to handle common
@@ -211,20 +288,30 @@ def execute_subprocess(commands, status_to_caller=False,
211288
return code, otherwise execute_subprocess treats non-zero return
212289
status as an error and raises an exception.
213290
291+
NOTE(bja, 2018-03) if the user doesn't have credentials setup
292+
correctly, then svn and git will prompt for a username/password or
293+
accepting the domain as trusted. We need to detect this and print
294+
enough info for the user to determine what happened and enter the
295+
appropriate information. When we detect some pre-determined
296+
conditions, we turn on screen output so the user can see what is
297+
needed. There doesn't appear to be a way to detect if the user
298+
entered any information in the terminal. So there is no way to
299+
turn off output.
300+
301+
NOTE(bja, 2018-03) we are polling the running process to avoid
302+
having it hang indefinitely if there is input that we don't
303+
detect. Some large checkouts are multiple minutes long. For now we
304+
are setting the timeout interval to five minutes.
305+
214306
"""
215307
msg = 'In directory: {0}\nexecute_subprocess running command:'.format(
216308
os.getcwd())
217309
logging.info(msg)
218310
logging.info(commands)
219311
return_to_caller = status_to_caller or output_to_caller
220-
status = -1
221-
output = ''
222312
try:
223-
logging.info(' '.join(commands))
224-
output = subprocess.check_output(commands, stderr=subprocess.STDOUT,
225-
universal_newlines=True)
226-
log_process_output(output)
227-
status = 0
313+
ret_value = _poll_subprocess(
314+
commands, status_to_caller, output_to_caller)
228315
except OSError as error:
229316
msg = failed_command_msg(
230317
'Command execution failed. Does the executable exist?',
@@ -246,24 +333,12 @@ def execute_subprocess(commands, status_to_caller=False,
246333
if not return_to_caller:
247334
msg_context = ('Process did not run successfully; '
248335
'returned status {0}'.format(error.returncode))
249-
msg = failed_command_msg(
250-
msg_context,
251-
commands,
252-
output=error.output)
336+
msg = failed_command_msg(msg_context, commands,
337+
output=error.output)
253338
logging.error(error)
254-
logging.error(msg)
255339
log_process_output(error.output)
256340
fatal_error(msg)
257-
status = error.returncode
258-
259-
if status_to_caller and output_to_caller:
260-
ret_value = (status, output)
261-
elif status_to_caller:
262-
ret_value = status
263-
elif output_to_caller:
264-
ret_value = output
265-
else:
266-
ret_value = None
341+
ret_value = error.returncode
267342

268343
return ret_value
269344

0 commit comments

Comments
 (0)