Repository: pantsbuild/pants

Issue:
piping pantsd output to `head` leads to unhandled IOError
```
[illuminati source (master)]$ ./pants list 3rdparty/python: | head -10
...
Exception caught: (<type 'exceptions.IOError'>)
  File ".bootstrap/_pex/pex.py", line 326, in execute
    self._wrap_coverage(self._wrap_profiling, self._execute)
  File ".bootstrap/_pex/pex.py", line 258, in _wrap_coverage
    runner(*args)
  File ".bootstrap/_pex/pex.py", line 290, in _wrap_profiling
    runner(*args)
  File ".bootstrap/_pex/pex.py", line 369, in _execute
    return self.execute_entry(self._pex_info.entry_point)
  File ".bootstrap/_pex/pex.py", line 427, in execute_entry
    return runner(entry_point)
  File ".bootstrap/_pex/pex.py", line 445, in execute_pkg_resources
    return runner()
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/bin/pants_exe.py", line 44, in main
    PantsRunner(exiter).run()
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/bin/pants_runner.py", line 57, in run
    options_bootstrapper=options_bootstrapper)
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/bin/pants_runner.py", line 35, in _run
    return RemotePantsRunner(exiter, args, env, process_metadata_dir).run()
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/bin/remote_pants_runner.py", line 79, in run
    result = client.execute(self.PANTS_COMMAND, *self._args, **modified_env)
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/java/nailgun_client.py", line 147, in execute
    return self._session.execute(cwd, main_class, *args, **environment)
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/java/nailgun_client.py", line 70, in execute
    return self._process_session()
  File "/Users/kwilson/dev/source/.pex/install/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl.dfcf1da242f8807637b07735eb90bff845cbed67/pantsbuild.pants-1.3.0.dev3+3342022031-py2-none-any.whl/pants/java/nailgun_client.py", line 46, in _process_session
    self._stdout.flush()

Exception message: [Errno 32] Broken pipe
```


Discussion:

Resolving patch:
diff --git a/src/python/pants/bin/remote_pants_runner.py b/src/python/pants/bin/remote_pants_runner.py
--- a/src/python/pants/bin/remote_pants_runner.py
+++ b/src/python/pants/bin/remote_pants_runner.py
@@ -72,7 +72,11 @@ def run(self, args=None):
     modified_env = self._combine_dicts(self._env, ng_env)
 
     # Instantiate a NailgunClient.
-    client = NailgunClient(port=self._port, ins=self._stdin, out=self._stdout, err=self._stderr)
+    client = NailgunClient(port=self._port,
+                           ins=self._stdin,
+                           out=self._stdout,
+                           err=self._stderr,
+                           exit_on_broken_pipe=True)
 
     with self._trapped_control_c(client):
       # Execute the command on the pailgun.
diff --git a/src/python/pants/java/nailgun_client.py b/src/python/pants/java/nailgun_client.py
--- a/src/python/pants/java/nailgun_client.py
+++ b/src/python/pants/java/nailgun_client.py
@@ -5,6 +5,7 @@
 from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
                         unicode_literals, with_statement)
 
+import errno
 import logging
 import os
 import signal
@@ -15,18 +16,18 @@
 from pants.java.nailgun_protocol import ChunkType, NailgunProtocol
 from pants.util.socket import RecvBufferedSocket
 
-
 logger = logging.getLogger(__name__)
 
 
 class NailgunClientSession(NailgunProtocol):
   """Handles a single nailgun client session."""
 
-  def __init__(self, sock, in_fd, out_fd, err_fd):
+  def __init__(self, sock, in_fd, out_fd, err_fd, exit_on_broken_pipe=False):
     self._sock = sock
     self._input_reader = NailgunStreamReader(in_fd, self._sock) if in_fd else None
     self._stdout = out_fd
     self._stderr = err_fd
+    self._exit_on_broken_pipe = exit_on_broken_pipe
     self.remote_pid = None
 
   def _maybe_start_input_reader(self):
@@ -37,19 +38,30 @@ def _maybe_stop_input_reader(self):
     if self._input_reader:
       self._input_reader.stop()
 
+  def _write_flush(self, fd, payload=None):
+    """Write a payload to a given fd (if provided) and flush the fd."""
+    try:
+      if payload:
+        fd.write(payload)
+      fd.flush()
+    except (IOError, OSError) as e:
+      # If a `Broken Pipe` is encountered during a stdio fd write, we're headless - bail.
+      if e.errno == errno.EPIPE and self._exit_on_broken_pipe:
+        sys.exit()
+      # Otherwise, re-raise.
+      raise
+
   def _process_session(self):
     """Process the outputs of the nailgun session."""
     try:
       for chunk_type, payload in self.iter_chunks(self._sock, return_bytes=True):
         if chunk_type == ChunkType.STDOUT:
-          self._stdout.write(payload)
-          self._stdout.flush()
+          self._write_flush(self._stdout, payload)
         elif chunk_type == ChunkType.STDERR:
-          self._stderr.write(payload)
-          self._stderr.flush()
+          self._write_flush(self._stderr, payload)
         elif chunk_type == ChunkType.EXIT:
-          self._stdout.flush()
-          self._stderr.flush()
+          self._write_flush(self._stdout)
+          self._write_flush(self._stderr)
           return int(payload)
         elif chunk_type == ChunkType.PID:
           self.remote_pid = int(payload)
@@ -85,7 +97,7 @@ class NailgunConnectionError(NailgunError):
   DEFAULT_NG_PORT = 2113
 
   def __init__(self, host=DEFAULT_NG_HOST, port=DEFAULT_NG_PORT, ins=sys.stdin, out=None, err=None,
-               workdir=None):
+               workdir=None, exit_on_broken_pipe=False):
     """Creates a nailgun client that can be used to issue zero or more nailgun commands.
 
     :param string host: the nailgun server to contact (defaults to '127.0.0.1')
@@ -96,6 +108,7 @@ def __init__(self, host=DEFAULT_NG_HOST, port=DEFAULT_NG_PORT, ins=sys.stdin, ou
     :param file out: a stream to write command standard output to (defaults to stdout)
     :param file err: a stream to write command standard error to (defaults to stderr)
     :param string workdir: the default working directory for all nailgun commands (defaults to CWD)
+    :param bool exit_on_broken_pipe: whether or not to exit when `Broken Pipe` errors are encountered.
     """
     self._host = host
     self._port = port
@@ -103,6 +116,7 @@ def __init__(self, host=DEFAULT_NG_HOST, port=DEFAULT_NG_PORT, ins=sys.stdin, ou
     self._stdout = out or sys.stdout
     self._stderr = err or sys.stderr
     self._workdir = workdir or os.path.abspath(os.path.curdir)
+    self._exit_on_broken_pipe = exit_on_broken_pipe
     self._session = None
 
   def try_connect(self):
@@ -142,7 +156,11 @@ def execute(self, main_class, cwd=None, *args, **environment):
     # N.B. This can throw NailgunConnectionError (catchable via NailgunError).
     sock = self.try_connect()
 
-    self._session = NailgunClientSession(sock, self._stdin, self._stdout, self._stderr)
+    self._session = NailgunClientSession(sock,
+                                         self._stdin,
+                                         self._stdout,
+                                         self._stderr,
+                                         self._exit_on_broken_pipe)
     try:
       return self._session.execute(cwd, main_class, *args, **environment)
     except socket.error as e:


Test patch:
diff --git a/tests/python/pants_test/pants_run_integration_test.py b/tests/python/pants_test/pants_run_integration_test.py
--- a/tests/python/pants_test/pants_run_integration_test.py
+++ b/tests/python/pants_test/pants_run_integration_test.py
@@ -173,7 +173,13 @@ def run_pants_with_workdir(self, command, workdir, config=None, stdin_data=None,
       args.append('--config-override=' + ini_file_name)
 
     pants_script = os.path.join(build_root or get_buildroot(), self.PANTS_SCRIPT_NAME)
-    pants_command = [pants_script] + args + command
+
+    # Permit usage of shell=True and string-based commands to allow e.g. `./pants | head`.
+    if kwargs.get('shell') is True:
+      assert not isinstance(command, list), 'must pass command as a string when using shell=True'
+      pants_command = ' '.join([pants_script, ' '.join(args), command])
+    else:
+      pants_command = [pants_script] + args + command
 
     # Only whitelisted entries will be included in the environment if hermetic=True.
     if self.hermetic():
diff --git a/tests/python/pants_test/pantsd/test_pantsd_integration.py b/tests/python/pants_test/pantsd/test_pantsd_integration.py
--- a/tests/python/pants_test/pantsd/test_pantsd_integration.py
+++ b/tests/python/pants_test/pantsd/test_pantsd_integration.py
@@ -5,6 +5,7 @@
 from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
                         unicode_literals, with_statement)
 
+from contextlib import contextmanager
 import os
 
 from pants.pantsd.process_manager import ProcessManager
@@ -34,15 +35,20 @@ def read_pantsd_log(workdir):
 
 
 class TestPantsDaemonIntegration(PantsRunIntegrationTest):
-  def test_pantsd_run(self):
+  @contextmanager
+  def pantsd_test_context(self, config=None):
     with self.temporary_workdir() as workdir_base:
       pid_dir = os.path.join(workdir_base, '.pids')
       workdir = os.path.join(workdir_base, '.workdir.pants.d')
-      pantsd_config = {'GLOBAL': {'enable_pantsd': True,
-                                  'level': 'debug',
-                                  'pants_subprocessdir': pid_dir}}
+      pantsd_config = config or {}
+      pantsd_config.setdefault('GLOBAL', {'enable_pantsd': True,
+                                          'level': 'debug',
+                                          'pants_subprocessdir': pid_dir})
       checker = PantsDaemonMonitor(pid_dir)
+      yield workdir, pantsd_config, checker
 
+  def test_pantsd_run(self):
+    with self.pantsd_test_context() as (workdir, pantsd_config, checker):
       # Explicitly kill any running pantsd instances for the current buildroot.
       self.assert_success(self.run_pants_with_workdir(['kill-pantsd'], workdir, pantsd_config))
       try:
@@ -72,18 +78,12 @@ def test_pantsd_run(self):
         print(line)
 
   def test_pantsd_run_with_watchman(self):
-    with self.temporary_workdir() as workdir_base:
-      pid_dir = os.path.join(workdir_base, '.pids')
-      workdir = os.path.join(workdir_base, '.workdir.pants.d')
-      pantsd_config = {'GLOBAL': {'enable_pantsd': True,
-                                  'level': 'debug',
-                                  'pants_subprocessdir': pid_dir},
-                       'pantsd': {'fs_event_detection': True},
-                       # The absolute paths in CI can exceed the UNIX socket path limitation
-                       # (>104-108 characters), so we override that here with a shorter path.
-                       'watchman': {'socket_path': '/tmp/watchman.{}.sock'.format(os.getpid())}}
-      checker = PantsDaemonMonitor(pid_dir)
+    config = {'pantsd': {'fs_event_detection': True},
+              # The absolute paths in CI can exceed the UNIX socket path limitation
+              # (>104-108 characters), so we override that here with a shorter path.
+              'watchman': {'socket_path': '/tmp/watchman.{}.sock'.format(os.getpid())}}
 
+    with self.pantsd_test_context(config) as (workdir, pantsd_config, checker):
       print('log: {}/pantsd/pantsd.log'.format(workdir))
       # Explicitly kill any running pantsd instances for the current buildroot.
       print('\nkill-pantsd')
@@ -127,3 +127,21 @@ def test_pantsd_run_with_watchman(self):
           continue
 
         self.assertNotRegexpMatches(line, r'^[WE].*')
+
+  def test_pantsd_broken_pipe(self):
+    with self.pantsd_test_context() as (workdir, pantsd_config, checker):
+      # Explicitly kill any running pantsd instances for the current buildroot.
+      self.assert_success(self.run_pants_with_workdir(['kill-pantsd'], workdir, pantsd_config))
+      try:
+        # Start pantsd implicitly via a throwaway invocation.
+        self.assert_success(self.run_pants_with_workdir(['help'], workdir, pantsd_config))
+        checker.await_pantsd()
+
+        run = self.run_pants_with_workdir('help | head -1', workdir, pantsd_config, shell=True)
+        self.assertNotIn('broken pipe', run.stderr_data.lower())
+        checker.assert_running()
+      finally:
+        # Explicitly kill pantsd (from a pantsd-launched runner).
+        self.assert_success(self.run_pants_with_workdir(['kill-pantsd'], workdir, pantsd_config))
+
+      checker.assert_stopped()