Skip to content

Commit 6dc5424

Browse files
authored
Merge pull request #4066 from Flamefire/fix-leaked-handle
fix leaked handles in set_columns, complete_cmd, run_cmd_qa, det_terminal_size functions + tests
2 parents 3c6067d + ed41fca commit 6dc5424

File tree

8 files changed

+202
-136
lines changed

8 files changed

+202
-136
lines changed

easybuild/base/generaloption.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343

4444
from easybuild.base.fancylogger import getLogger, setroot, setLogLevel, getDetailsLogLevels
4545
from easybuild.base.optcomplete import autocomplete, CompleterOption
46-
from easybuild.tools.py2vs3 import StringIO, configparser, string_type
46+
from easybuild.tools.py2vs3 import StringIO, configparser, string_type, subprocess_popen_text
4747
from easybuild.tools.utilities import mk_rst_table, nub, shell_quote
4848

4949
try:
@@ -80,7 +80,9 @@ def set_columns(cols=None):
8080
stty = '/usr/bin/stty'
8181
if os.path.exists(stty):
8282
try:
83-
cols = int(os.popen('%s size 2>/dev/null' % stty).read().strip().split(' ')[1])
83+
with open(os.devnull, 'w') as devnull:
84+
proc = subprocess_popen_text([stty, "size"], stderr=devnull)
85+
cols = int(proc.communicate()[0].strip().split(' ')[1])
8486
except (AttributeError, IndexError, OSError, ValueError):
8587
# do nothing
8688
pass

easybuild/tools/py2vs3/py2.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import ConfigParser as configparser # noqa
3434
import json
3535
import subprocess
36+
import time
3637
import urllib2 as std_urllib # noqa
3738
from collections import Mapping, OrderedDict # noqa
3839
from HTMLParser import HTMLParser # noqa
@@ -55,7 +56,25 @@
5556

5657
def subprocess_popen_text(cmd, **kwargs):
5758
"""Call subprocess.Popen with specified named arguments."""
58-
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
59+
kwargs.setdefault('stderr', subprocess.PIPE)
60+
return subprocess.Popen(cmd, stdout=subprocess.PIPE, **kwargs)
61+
62+
63+
def subprocess_terminate(proc, timeout):
64+
"""Terminate the subprocess if it hasn't finished after the given timeout"""
65+
res = None
66+
for pipe in (proc.stdout, proc.stderr, proc.stdin):
67+
if pipe:
68+
pipe.close()
69+
while timeout > 0:
70+
res = proc.poll()
71+
if res is not None:
72+
break
73+
delay = min(timeout, 0.1)
74+
time.sleep(delay)
75+
timeout -= delay
76+
if res is None:
77+
proc.terminate()
5978

6079

6180
def raise_with_traceback(exception_class, message, traceback):

easybuild/tools/py2vs3/py3.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,19 @@ def json_loads(body):
6969
def subprocess_popen_text(cmd, **kwargs):
7070
"""Call subprocess.Popen in text mode with specified named arguments."""
7171
# open stdout/stderr in text mode in Popen when using Python 3
72-
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, **kwargs)
72+
kwargs.setdefault('stderr', subprocess.PIPE)
73+
return subprocess.Popen(cmd, stdout=subprocess.PIPE, universal_newlines=True, **kwargs)
74+
75+
76+
def subprocess_terminate(proc, timeout):
77+
"""Terminate the subprocess if it hasn't finished after the given timeout"""
78+
try:
79+
proc.communicate(timeout=timeout)
80+
except subprocess.TimeoutExpired:
81+
for pipe in (proc.stdout, proc.stderr, proc.stdin):
82+
if pipe:
83+
pipe.close()
84+
proc.terminate()
7385

7486

7587
def raise_with_traceback(exception_class, message, traceback):

easybuild/tools/run.py

Lines changed: 112 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
:author: Toon Willems (Ghent University)
3434
:author: Ward Poelmans (Ghent University)
3535
"""
36+
import contextlib
3637
import functools
3738
import os
3839
import re
@@ -315,21 +316,24 @@ def complete_cmd(proc, cmd, owd, start_time, cmd_log, log_ok=True, log_all=False
315316

316317
stdouterr = output
317318

318-
ec = proc.poll()
319-
while ec is None:
320-
# need to read from time to time.
321-
# - otherwise the stdout/stderr buffer gets filled and it all stops working
322-
output = get_output_from_process(proc, read_size=read_size)
323-
if cmd_log:
324-
cmd_log.write(output)
325-
if stream_output:
326-
sys.stdout.write(output)
327-
stdouterr += output
319+
try:
328320
ec = proc.poll()
321+
while ec is None:
322+
# need to read from time to time.
323+
# - otherwise the stdout/stderr buffer gets filled and it all stops working
324+
output = get_output_from_process(proc, read_size=read_size)
325+
if cmd_log:
326+
cmd_log.write(output)
327+
if stream_output:
328+
sys.stdout.write(output)
329+
stdouterr += output
330+
ec = proc.poll()
331+
332+
# read remaining data (all of it)
333+
output = get_output_from_process(proc)
334+
finally:
335+
proc.stdout.close()
329336

330-
# read remaining data (all of it)
331-
output = get_output_from_process(proc)
332-
proc.stdout.close()
333337
if cmd_log:
334338
cmd_log.write(output)
335339
cmd_log.close()
@@ -397,6 +401,8 @@ def run_cmd_qa(cmd, qa, no_qa=None, log_ok=True, log_all=False, simple=False, re
397401
path = cwd
398402
dry_run_msg(" running interactive command \"%s\"" % cmd, silent=build_option('silent'))
399403
dry_run_msg(" (in %s)" % path, silent=build_option('silent'))
404+
if cmd_log:
405+
cmd_log.close()
400406
if simple:
401407
return True
402408
else:
@@ -442,6 +448,8 @@ def check_answers_list(answers):
442448
if isinstance(answers, string_type):
443449
answers = [answers]
444450
elif not isinstance(answers, list):
451+
if cmd_log:
452+
cmd_log.close()
445453
raise EasyBuildError("Invalid type for answer on %s, no string or list: %s (%s)",
446454
question, type(answers), answers)
447455
# list is manipulated when answering matching question, so return a copy
@@ -479,47 +487,48 @@ def check_answers_list(answers):
479487
if cmd_log:
480488
cmd_log.write("# output for interactive command: %s\n\n" % cmd)
481489

482-
try:
483-
proc = asyncprocess.Popen(cmd, shell=True, stdout=asyncprocess.PIPE, stderr=asyncprocess.STDOUT,
484-
stdin=asyncprocess.PIPE, close_fds=True, executable='/bin/bash')
485-
except OSError as err:
486-
raise EasyBuildError("run_cmd_qa init cmd %s failed:%s", cmd, err)
487-
488-
ec = proc.poll()
489-
stdout_err = ''
490-
old_len_out = -1
491-
hit_count = 0
492-
493-
while ec is None:
494-
# need to read from time to time.
495-
# - otherwise the stdout/stderr buffer gets filled and it all stops working
490+
# Make sure we close the proc handles and the cmd_log file
491+
@contextlib.contextmanager
492+
def get_proc():
496493
try:
497-
out = get_output_from_process(proc, asynchronous=True)
498-
494+
proc = asyncprocess.Popen(cmd, shell=True, stdout=asyncprocess.PIPE, stderr=asyncprocess.STDOUT,
495+
stdin=asyncprocess.PIPE, close_fds=True, executable='/bin/bash')
496+
except OSError as err:
499497
if cmd_log:
500-
cmd_log.write(out)
501-
stdout_err += out
502-
# recv_some used by get_output_from_process for getting asynchronous output may throw exception
503-
except (IOError, Exception) as err:
504-
_log.debug("run_cmd_qa cmd %s: read failed: %s", cmd, err)
505-
out = None
506-
507-
hit = False
508-
for question, answers in new_qa.items():
509-
res = question.search(stdout_err)
510-
if out and res:
511-
fa = answers[0] % res.groupdict()
512-
# cycle through list of answers
513-
last_answer = answers.pop(0)
514-
answers.append(last_answer)
515-
_log.debug("List of answers for question %s after cycling: %s", question.pattern, answers)
516-
517-
_log.debug("run_cmd_qa answer %s question %s out %s", fa, question.pattern, stdout_err[-50:])
518-
asyncprocess.send_all(proc, fa)
519-
hit = True
520-
break
521-
if not hit:
522-
for question, answers in new_std_qa.items():
498+
cmd_log.close()
499+
raise EasyBuildError("run_cmd_qa init cmd %s failed:%s", cmd, err)
500+
try:
501+
yield proc
502+
finally:
503+
if proc.stdout:
504+
proc.stdout.close()
505+
if proc.stdin:
506+
proc.stdin.close()
507+
if cmd_log:
508+
cmd_log.close()
509+
510+
with get_proc() as proc:
511+
ec = proc.poll()
512+
stdout_err = ''
513+
old_len_out = -1
514+
hit_count = 0
515+
516+
while ec is None:
517+
# need to read from time to time.
518+
# - otherwise the stdout/stderr buffer gets filled and it all stops working
519+
try:
520+
out = get_output_from_process(proc, asynchronous=True)
521+
522+
if cmd_log:
523+
cmd_log.write(out)
524+
stdout_err += out
525+
# recv_some used by get_output_from_process for getting asynchronous output may throw exception
526+
except (IOError, Exception) as err:
527+
_log.debug("run_cmd_qa cmd %s: read failed: %s", cmd, err)
528+
out = None
529+
530+
hit = False
531+
for question, answers in new_qa.items():
523532
res = question.search(stdout_err)
524533
if out and res:
525534
fa = answers[0] % res.groupdict()
@@ -528,51 +537,65 @@ def check_answers_list(answers):
528537
answers.append(last_answer)
529538
_log.debug("List of answers for question %s after cycling: %s", question.pattern, answers)
530539

531-
_log.debug("run_cmd_qa answer %s std question %s out %s", fa, question.pattern, stdout_err[-50:])
540+
_log.debug("run_cmd_qa answer %s question %s out %s", fa, question.pattern, stdout_err[-50:])
532541
asyncprocess.send_all(proc, fa)
533542
hit = True
534543
break
535544
if not hit:
536-
if len(stdout_err) > old_len_out:
537-
old_len_out = len(stdout_err)
545+
for question, answers in new_std_qa.items():
546+
res = question.search(stdout_err)
547+
if out and res:
548+
fa = answers[0] % res.groupdict()
549+
# cycle through list of answers
550+
last_answer = answers.pop(0)
551+
answers.append(last_answer)
552+
_log.debug("List of answers for question %s after cycling: %s", question.pattern, answers)
553+
554+
_log.debug("run_cmd_qa answer %s std question %s out %s",
555+
fa, question.pattern, stdout_err[-50:])
556+
asyncprocess.send_all(proc, fa)
557+
hit = True
558+
break
559+
if not hit:
560+
if len(stdout_err) > old_len_out:
561+
old_len_out = len(stdout_err)
562+
else:
563+
noqa = False
564+
for r in new_no_qa:
565+
if r.search(stdout_err):
566+
_log.debug("runqanda: noQandA found for out %s", stdout_err[-50:])
567+
noqa = True
568+
if not noqa:
569+
hit_count += 1
538570
else:
539-
noqa = False
540-
for r in new_no_qa:
541-
if r.search(stdout_err):
542-
_log.debug("runqanda: noQandA found for out %s", stdout_err[-50:])
543-
noqa = True
544-
if not noqa:
545-
hit_count += 1
571+
hit_count = 0
546572
else:
547573
hit_count = 0
548-
else:
549-
hit_count = 0
550574

551-
if hit_count > maxhits:
552-
# explicitly kill the child process before exiting
553-
try:
554-
os.killpg(proc.pid, signal.SIGKILL)
555-
os.kill(proc.pid, signal.SIGKILL)
556-
except OSError as err:
557-
_log.debug("run_cmd_qa exception caught when killing child process: %s", err)
558-
_log.debug("run_cmd_qa: full stdouterr: %s", stdout_err)
559-
raise EasyBuildError("run_cmd_qa: cmd %s : Max nohits %s reached: end of output %s",
560-
cmd, maxhits, stdout_err[-500:])
561-
562-
# the sleep below is required to avoid exiting on unknown 'questions' too early (see above)
563-
time.sleep(1)
564-
ec = proc.poll()
565-
566-
# Process stopped. Read all remaining data
567-
try:
568-
if proc.stdout:
569-
out = get_output_from_process(proc)
570-
stdout_err += out
571-
if cmd_log:
572-
cmd_log.write(out)
573-
cmd_log.close()
574-
except IOError as err:
575-
_log.debug("runqanda cmd %s: remaining data read failed: %s", cmd, err)
575+
if hit_count > maxhits:
576+
# explicitly kill the child process before exiting
577+
try:
578+
os.killpg(proc.pid, signal.SIGKILL)
579+
os.kill(proc.pid, signal.SIGKILL)
580+
except OSError as err:
581+
_log.debug("run_cmd_qa exception caught when killing child process: %s", err)
582+
_log.debug("run_cmd_qa: full stdouterr: %s", stdout_err)
583+
raise EasyBuildError("run_cmd_qa: cmd %s : Max nohits %s reached: end of output %s",
584+
cmd, maxhits, stdout_err[-500:])
585+
586+
# the sleep below is required to avoid exiting on unknown 'questions' too early (see above)
587+
time.sleep(1)
588+
ec = proc.poll()
589+
590+
# Process stopped. Read all remaining data
591+
try:
592+
if proc.stdout:
593+
out = get_output_from_process(proc)
594+
stdout_err += out
595+
if cmd_log:
596+
cmd_log.write(out)
597+
except IOError as err:
598+
_log.debug("runqanda cmd %s: remaining data read failed: %s", cmd, err)
576599

577600
if trace:
578601
trace_msg("interactive command completed: exit %s, ran in %s" % (ec, time_str_since(start_time)))

easybuild/tools/systemtools.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import termios
4242
from ctypes.util import find_library
4343
from socket import gethostname
44+
from easybuild.tools.py2vs3 import subprocess_popen_text
4445

4546
# pkg_resources is provided by the setuptools Python package,
4647
# which we really want to keep as an *optional* dependency
@@ -1201,7 +1202,7 @@ def det_terminal_size():
12011202
except Exception as err:
12021203
_log.warning("First attempt to determine terminal size failed: %s", err)
12031204
try:
1204-
height, width = [int(x) for x in os.popen("stty size").read().strip().split()]
1205+
height, width = [int(x) for x in subprocess_popen_text("stty size").communicate()[0].strip().split()]
12051206
except Exception as err:
12061207
_log.warning("Second attempt to determine terminal size failed, going to return defaults: %s", err)
12071208
height, width = 25, 80

test/framework/asyncprocess.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import easybuild.tools.asyncprocess as p
3737
from easybuild.tools.asyncprocess import Popen
38+
from easybuild.tools.py2vs3 import subprocess_terminate
3839

3940

4041
class AsyncProcessTest(EnhancedTestCase):
@@ -62,6 +63,7 @@ def runTest(self):
6263

6364
def tearDown(self):
6465
"""cleanup"""
66+
subprocess_terminate(self.shell, timeout=1)
6567
super(AsyncProcessTest, self).tearDown()
6668

6769

0 commit comments

Comments
 (0)