summaryrefslogtreecommitdiff
path: root/lib/spack/llnl/util/tty/log.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/spack/llnl/util/tty/log.py')
-rw-r--r--lib/spack/llnl/util/tty/log.py215
1 files changed, 168 insertions, 47 deletions
diff --git a/lib/spack/llnl/util/tty/log.py b/lib/spack/llnl/util/tty/log.py
index 97fba0592c..658de5c596 100644
--- a/lib/spack/llnl/util/tty/log.py
+++ b/lib/spack/llnl/util/tty/log.py
@@ -21,7 +21,6 @@ from six import string_types
from six import StringIO
import llnl.util.tty as tty
-from llnl.util.lang import fork_context
try:
import termios
@@ -237,6 +236,8 @@ class keyboard_input(object):
"""If termios was available, restore old settings."""
if self.old_cfg:
self._restore_default_terminal_settings()
+ if sys.version_info >= (3,):
+ atexit.unregister(self._restore_default_terminal_settings)
# restore SIGSTP and SIGCONT handlers
if self.old_handlers:
@@ -288,6 +289,109 @@ def _file_descriptors_work(*streams):
return False
+class FileWrapper(object):
+ """Represents a file. Can be an open stream, a path to a file (not opened
+ yet), or neither. When unwrapped, it returns an open file (or file-like)
+ object.
+ """
+ def __init__(self, file_like):
+ # This records whether the file-like object returned by "unwrap" is
+ # purely in-memory. In that case a subprocess will need to explicitly
+ # transmit the contents to the parent.
+ self.write_in_parent = False
+
+ self.file_like = file_like
+
+ if isinstance(file_like, string_types):
+ self.open = True
+ elif _file_descriptors_work(file_like):
+ self.open = False
+ else:
+ self.file_like = None
+ self.open = True
+ self.write_in_parent = True
+
+ self.file = None
+
+ def unwrap(self):
+ if self.open:
+ if self.file_like:
+ self.file = open(self.file_like, 'w')
+ else:
+ self.file = StringIO()
+ return self.file
+ else:
+ # We were handed an already-open file object. In this case we also
+ # will not actually close the object when requested to.
+ return self.file_like
+
+ def close(self):
+ if self.file:
+ self.file.close()
+
+
+class MultiProcessFd(object):
+ """Return an object which stores a file descriptor and can be passed as an
+ argument to a function run with ``multiprocessing.Process``, such that
+ the file descriptor is available in the subprocess."""
+ def __init__(self, fd):
+ self._connection = None
+ self._fd = None
+ if sys.version_info >= (3, 8):
+ self._connection = multiprocessing.connection.Connection(fd)
+ else:
+ self._fd = fd
+
+ @property
+ def fd(self):
+ if self._connection:
+ return self._connection._handle
+ else:
+ return self._fd
+
+ def close(self):
+ if self._connection:
+ self._connection.close()
+ else:
+ os.close(self._fd)
+
+
+def close_connection_and_file(multiprocess_fd, file):
+ # MultiprocessFd is intended to transmit a FD
+ # to a child process, this FD is then opened to a Python File object
+ # (using fdopen). In >= 3.8, MultiprocessFd encapsulates a
+ # multiprocessing.connection.Connection; Connection closes the FD
+ # when it is deleted, and prints a warning about duplicate closure if
+ # it is not explicitly closed. In < 3.8, MultiprocessFd encapsulates a
+ # simple FD; closing the FD here appears to conflict with
+ # closure of the File object (in < 3.8 that is). Therefore this needs
+ # to choose whether to close the File or the Connection.
+ if sys.version_info >= (3, 8):
+ multiprocess_fd.close()
+ else:
+ file.close()
+
+
+@contextmanager
+def replace_environment(env):
+ """Replace the current environment (`os.environ`) with `env`.
+
+ If `env` is empty (or None), this unsets all current environment
+ variables.
+ """
+ env = env or {}
+ old_env = os.environ.copy()
+ try:
+ os.environ.clear()
+ for name, val in env.items():
+ os.environ[name] = val
+ yield
+ finally:
+ os.environ.clear()
+ for name, val in old_env.items():
+ os.environ[name] = val
+
+
class log_output(object):
"""Context manager that logs its output to a file.
@@ -324,7 +428,8 @@ class log_output(object):
work within test frameworks like nose and pytest.
"""
- def __init__(self, file_like=None, echo=False, debug=0, buffer=False):
+ def __init__(self, file_like=None, echo=False, debug=0, buffer=False,
+ env=None):
"""Create a new output log context manager.
Args:
@@ -352,6 +457,7 @@ class log_output(object):
self.echo = echo
self.debug = debug
self.buffer = buffer
+ self.env = env # the environment to use for _writer_daemon
self._active = False # used to prevent re-entry
@@ -393,18 +499,7 @@ class log_output(object):
"file argument must be set by either __init__ or __call__")
# set up a stream for the daemon to write to
- self.close_log_in_parent = True
- self.write_log_in_parent = False
- if isinstance(self.file_like, string_types):
- self.log_file = open(self.file_like, 'w')
-
- elif _file_descriptors_work(self.file_like):
- self.log_file = self.file_like
- self.close_log_in_parent = False
-
- else:
- self.log_file = StringIO()
- self.write_log_in_parent = True
+ self.log_file = FileWrapper(self.file_like)
# record parent color settings before redirecting. We do this
# because color output depends on whether the *original* stdout
@@ -419,6 +514,8 @@ class log_output(object):
# OS-level pipe for redirecting output to logger
read_fd, write_fd = os.pipe()
+ read_multiprocess_fd = MultiProcessFd(read_fd)
+
# Multiprocessing pipe for communication back from the daemon
# Currently only used to save echo value between uses
self.parent_pipe, child_pipe = multiprocessing.Pipe()
@@ -427,24 +524,28 @@ class log_output(object):
try:
# need to pass this b/c multiprocessing closes stdin in child.
try:
- input_stream = os.fdopen(os.dup(sys.stdin.fileno()))
+ input_multiprocess_fd = MultiProcessFd(
+ os.dup(sys.stdin.fileno())
+ )
except BaseException:
- input_stream = None # just don't forward input if this fails
-
- self.process = fork_context.Process(
- target=_writer_daemon,
- args=(
- input_stream, read_fd, write_fd, self.echo, self.log_file,
- child_pipe
+ # just don't forward input if this fails
+ input_multiprocess_fd = None
+
+ with replace_environment(self.env):
+ self.process = multiprocessing.Process(
+ target=_writer_daemon,
+ args=(
+ input_multiprocess_fd, read_multiprocess_fd, write_fd,
+ self.echo, self.log_file, child_pipe
+ )
)
- )
- self.process.daemon = True # must set before start()
- self.process.start()
- os.close(read_fd) # close in the parent process
+ self.process.daemon = True # must set before start()
+ self.process.start()
finally:
- if input_stream:
- input_stream.close()
+ if input_multiprocess_fd:
+ input_multiprocess_fd.close()
+ read_multiprocess_fd.close()
# Flush immediately before redirecting so that anything buffered
# goes to the original stream
@@ -515,18 +616,21 @@ class log_output(object):
sys.stderr = self._saved_stderr
# print log contents in parent if needed.
- if self.write_log_in_parent:
+ if self.log_file.write_in_parent:
string = self.parent_pipe.recv()
self.file_like.write(string)
- if self.close_log_in_parent:
- self.log_file.close()
-
# recover and store echo settings from the child before it dies
- self.echo = self.parent_pipe.recv()
-
- # join the daemon process. The daemon will quit automatically
- # when the write pipe is closed; we just wait for it here.
+ try:
+ self.echo = self.parent_pipe.recv()
+ except EOFError:
+ # This may occur if some exception prematurely terminates the
+ # _writer_daemon. An exception will have already been generated.
+ pass
+
+ # now that the write pipe is closed (in this __exit__, when we restore
+ # stdout with dup2), the logger daemon process loop will terminate. We
+ # wait for that here.
self.process.join()
# restore old color and debug settings
@@ -555,7 +659,8 @@ class log_output(object):
sys.stdout.flush()
-def _writer_daemon(stdin, read_fd, write_fd, echo, log_file, control_pipe):
+def _writer_daemon(stdin_multiprocess_fd, read_multiprocess_fd, write_fd, echo,
+ log_file_wrapper, control_pipe):
"""Daemon used by ``log_output`` to write to a log file and to ``stdout``.
The daemon receives output from the parent process and writes it both
@@ -592,26 +697,39 @@ def _writer_daemon(stdin, read_fd, write_fd, echo, log_file, control_pipe):
``StringIO`` in the parent. This is mainly for testing.
Arguments:
- stdin (stream): input from the terminal
- read_fd (int): pipe for reading from parent's redirected stdout
- write_fd (int): parent's end of the pipe will write to (will be
- immediately closed by the writer daemon)
+ stdin_multiprocess_fd (int): input from the terminal
+ read_multiprocess_fd (int): pipe for reading from parent's redirected
+ stdout
echo (bool): initial echo setting -- controlled by user and
preserved across multiple writer daemons
- log_file (file-like): file to log all output
+ log_file_wrapper (FileWrapper): file to log all output
control_pipe (Pipe): multiprocessing pipe on which to send control
information to the parent
"""
+ # If this process was forked, then it will inherit file descriptors from
+ # the parent process. This process depends on closing all instances of
+ # write_fd to terminate the reading loop, so we close the file descriptor
+ # here. Forking is the process spawning method everywhere except Mac OS
+ # for Python >= 3.8 and on Windows
+ if sys.version_info < (3, 8) or sys.platform != 'darwin':
+ os.close(write_fd)
+
# Use line buffering (3rd param = 1) since Python 3 has a bug
# that prevents unbuffered text I/O.
- in_pipe = os.fdopen(read_fd, 'r', 1)
- os.close(write_fd)
+ in_pipe = os.fdopen(read_multiprocess_fd.fd, 'r', 1)
+
+ if stdin_multiprocess_fd:
+ stdin = os.fdopen(stdin_multiprocess_fd.fd)
+ else:
+ stdin = None
# list of streams to select from
istreams = [in_pipe, stdin] if stdin else [in_pipe]
force_echo = False # parent can force echo for certain output
+ log_file = log_file_wrapper.unwrap()
+
try:
with keyboard_input(stdin) as kb:
while True:
@@ -672,10 +790,13 @@ def _writer_daemon(stdin, read_fd, write_fd, echo, log_file, control_pipe):
# send written data back to parent if we used a StringIO
if isinstance(log_file, StringIO):
control_pipe.send(log_file.getvalue())
- log_file.close()
+ log_file_wrapper.close()
+ close_connection_and_file(read_multiprocess_fd, in_pipe)
+ if stdin_multiprocess_fd:
+ close_connection_and_file(stdin_multiprocess_fd, stdin)
- # send echo value back to the parent so it can be preserved.
- control_pipe.send(echo)
+ # send echo value back to the parent so it can be preserved.
+ control_pipe.send(echo)
def _retry(function):