Index: src/__phutil_library_map__.php
===================================================================
--- src/__phutil_library_map__.php
+++ src/__phutil_library_map__.php
@@ -373,6 +373,7 @@
     'phutil_safe_html' => 'markup/render.php',
     'phutil_split_lines' => 'utils/utils.php',
     'phutil_tag' => 'markup/render.php',
+    'phutil_tag_div' => 'markup/render.php',
     'phutil_unescape_uri_path_component' => 'markup/render.php',
     'phutil_utf8_console_strlen' => 'utils/utf8.php',
     'phutil_utf8_convert' => 'utils/utf8.php',
@@ -400,6 +401,7 @@
     'vqsprintf' => 'xsprintf/qsprintf.php',
     'vqueryfx' => 'xsprintf/queryfx.php',
     'vqueryfx_all' => 'xsprintf/queryfx.php',
+    'vurisprintf' => 'xsprintf/urisprintf.php',
     'xhp_parser_node_constants' => 'parser/xhpast/parser_nodes.php',
     'xhpast_get_binary_path' => 'parser/xhpast/bin/xhpast_parse.php',
     'xhpast_get_build_instructions' => 'parser/xhpast/bin/xhpast_parse.php',
Index: src/channel/PhutilChannel.php
===================================================================
--- src/channel/PhutilChannel.php
+++ src/channel/PhutilChannel.php
@@ -231,6 +231,14 @@
 
 
   /**
+   * Close the channel for writing.
+   *
+   * @return void
+   * @task impl
+   */
+  abstract public function closeWriteChannel();
+
+  /**
    * Test if the channel is open for reading.
    *
    * @return bool True if the channel is open for reading.
Index: src/channel/PhutilChannelChannel.php
===================================================================
--- src/channel/PhutilChannelChannel.php
+++ src/channel/PhutilChannelChannel.php
@@ -40,6 +40,10 @@
     return $this->channel->isOpen();
   }
 
+  public function closeWriteChannel() {
+    return $this->channel->closeWriteChannel();
+  }
+
   public function isOpenForReading() {
     return $this->channel->isOpenForReading();
   }
Index: src/channel/PhutilExecChannel.php
===================================================================
--- src/channel/PhutilExecChannel.php
+++ src/channel/PhutilExecChannel.php
@@ -108,6 +108,10 @@
     $this->future->write($bytes, $keep_pipe = true);
   }
 
+  public function closeWriteChannel() {
+    $this->future->write('', $keep_pipe = false);
+  }
+
   protected function writeBytes($bytes) {
     throw new Exception("ExecFuture can not write bytes directly!");
   }
Index: src/channel/PhutilProtocolChannel.php
===================================================================
--- src/channel/PhutilProtocolChannel.php
+++ src/channel/PhutilProtocolChannel.php
@@ -121,11 +121,17 @@
    * @task wait
    */
   public function waitForMessage() {
-    while ($this->update()) {
+    while (true) {
+      $is_open = $this->update();
       $message = $this->read();
       if ($message !== null) {
         return $message;
       }
+
+      if (!$is_open) {
+        break;
+      }
+
       self::waitForAny(array($this));
     }
 
Index: src/channel/PhutilSocketChannel.php
===================================================================
--- src/channel/PhutilSocketChannel.php
+++ src/channel/PhutilSocketChannel.php
@@ -173,6 +173,10 @@
     }
   }
 
+  public function closeWriteChannel() {
+    $this->closeWriteSocket();
+  }
+
   private function closeOneSocket($socket) {
     if (!$socket) {
       return;
Index: src/channel/__tests__/PhutilPHPObjectProtocolChannelTestCase.php
===================================================================
--- src/channel/__tests__/PhutilPHPObjectProtocolChannelTestCase.php
+++ src/channel/__tests__/PhutilPHPObjectProtocolChannelTestCase.php
@@ -26,4 +26,41 @@
       "Objects are not the same.");
   }
 
+  public function testCloseSocketWriteChannel() {
+    list($x, $y) = PhutilSocketChannel::newChannelPair();
+    $xp = new PhutilPHPObjectProtocolChannel($x);
+    $yp = new PhutilPHPObjectProtocolChannel($y);
+
+    $yp->closeWriteChannel();
+    $yp->update();
+
+    // NOTE: This test is more broad than the implementation needs to be. A
+    // better test would be to verify that this throws an exception:
+    //
+    //   $xp->waitForMessage();
+    //
+    // However, if the test breaks, that method will hang forever instead of
+    // returning, which would be hard to diagnose. Since the current
+    // implementation shuts down the entire channel, just test for that.
+
+    $this->assertEqual(false, $xp->update(), 'Expected channel to close.');
+  }
+
+  public function testCloseExecWriteChannel() {
+    $future = new ExecFuture('cat');
+
+    // If this test breaks, we want to explode, not hang forever.
+    $future->setTimeout(5);
+
+    $exec_channel = new PhutilExecChannel($future);
+    $exec_channel->write("quack");
+    $exec_channel->closeWriteChannel();
+
+    // If `closeWriteChannel()` did what it is supposed to, this will just
+    // echo "quack" and exit with no error code. If the channel did not close,
+    // this will time out after 5 seconds and throw.
+    $future->resolvex();
+  }
+
+
 }