@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Enable Mercurial reads and writes over SSH

Summary:
Ref T2230. This is substantially more complicated than Git, but mostly because Mercurial's protocol is a like 50 ad-hoc extensions cobbled together. Because we must decode protocol frames in order to determine if a request is read or write, 90% of this is implementing a stream parser for the protocol.

Mercurial's own parser is simpler, but relies on blocking reads. Since we don't even have methods for blocking reads right now and keeping the whole thing non-blocking is conceptually better, I made the parser nonblocking. It ends up being a lot of stuff. I made an effort to cover it reasonably well with unit tests, and to make sure we fail closed (i.e., reject requests) if there are any parts of the protocol I got wrong.

A lot of the complexity is sharable with the HTTP stuff, so it ends up being not-so-bad, just very hard to verify by inspection as clearly correct.

Test Plan:
- Ran `hg clone` over SSH.
- Ran `hg fetch` over SSH.
- Ran `hg push` over SSH, to a read-only repo (error) and a read-write repo (success).

Reviewers: btrahan, asherkin

Reviewed By: btrahan

CC: aran

Maniphest Tasks: T2230

Differential Revision: https://secure.phabricator.com/D7553

+544 -42
+1 -1
scripts/ssh/ssh-exec.php
··· 61 61 62 62 $workflows = array( 63 63 new ConduitSSHWorkflow(), 64 - 64 + new DiffusionSSHMercurialServeWorkflow(), 65 65 new DiffusionSSHGitUploadPackWorkflow(), 66 66 new DiffusionSSHGitReceivePackWorkflow(), 67 67 );
+8
src/__phutil_library_map__.php
··· 543 543 'DiffusionSSHGitReceivePackWorkflow' => 'applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php', 544 544 'DiffusionSSHGitUploadPackWorkflow' => 'applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php', 545 545 'DiffusionSSHGitWorkflow' => 'applications/diffusion/ssh/DiffusionSSHGitWorkflow.php', 546 + 'DiffusionSSHMercurialServeWorkflow' => 'applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php', 547 + 'DiffusionSSHMercurialWireClientProtocolChannel' => 'applications/diffusion/ssh/DiffusionSSHMercurialWireClientProtocolChannel.php', 548 + 'DiffusionSSHMercurialWireTestCase' => 'applications/diffusion/ssh/__tests__/DiffusionSSHMercurialWireTestCase.php', 549 + 'DiffusionSSHMercurialWorkflow' => 'applications/diffusion/ssh/DiffusionSSHMercurialWorkflow.php', 546 550 'DiffusionSSHWorkflow' => 'applications/diffusion/ssh/DiffusionSSHWorkflow.php', 547 551 'DiffusionServeController' => 'applications/diffusion/controller/DiffusionServeController.php', 548 552 'DiffusionSetPasswordPanel' => 'applications/diffusion/panel/DiffusionSetPasswordPanel.php', ··· 2808 2812 'DiffusionSSHGitReceivePackWorkflow' => 'DiffusionSSHGitWorkflow', 2809 2813 'DiffusionSSHGitUploadPackWorkflow' => 'DiffusionSSHGitWorkflow', 2810 2814 'DiffusionSSHGitWorkflow' => 'DiffusionSSHWorkflow', 2815 + 'DiffusionSSHMercurialServeWorkflow' => 'DiffusionSSHMercurialWorkflow', 2816 + 'DiffusionSSHMercurialWireClientProtocolChannel' => 'PhutilProtocolChannel', 2817 + 'DiffusionSSHMercurialWireTestCase' => 'PhabricatorTestCase', 2818 + 'DiffusionSSHMercurialWorkflow' => 'DiffusionSSHWorkflow', 2811 2819 'DiffusionSSHWorkflow' => 'PhabricatorSSHWorkflow', 2812 2820 'DiffusionServeController' => 'DiffusionController', 2813 2821 'DiffusionSetPasswordPanel' => 'PhabricatorSettingsPanel',
+2 -34
src/applications/diffusion/controller/DiffusionServeController.php
··· 228 228 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 229 229 $cmd = $request->getStr('cmd'); 230 230 if ($cmd == 'batch') { 231 - // For "batch" we get a "cmds" argument like 232 - // 233 - // heads ;known nodes= 234 - // 235 - // We need to examine the commands (here, "heads" and "known") to 236 - // make sure they're all read-only. 237 - 238 - $args = $this->getMercurialArguments(); 239 - $cmds = idx($args, 'cmds'); 240 - if ($cmds) { 241 - 242 - // NOTE: Mercurial has some code to escape semicolons, but it does 243 - // not actually function for command separation. For example, these 244 - // two batch commands will produce completely different results (the 245 - // former will run the lookup; the latter will fail with a parser 246 - // error): 247 - // 248 - // lookup key=a:xb;lookup key=z* 0 249 - // lookup key=a:;b;lookup key=z* 0 250 - // ^ 251 - // | 252 - // +-- Note semicolon. 253 - // 254 - // So just split unconditionally. 255 - 256 - $cmds = explode(';', $cmds); 257 - foreach ($cmds as $sub_cmd) { 258 - $name = head(explode(' ', $sub_cmd, 2)); 259 - if (!DiffusionMercurialWireProtocol::isReadOnlyCommand($name)) { 260 - return false; 261 - } 262 - } 263 - return true; 264 - } 231 + $cmds = idx($this->getMercurialArguments(), 'cmds'); 232 + return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); 265 233 } 266 234 return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); 267 235 case PhabricatorRepositoryType::REPOSITORY_TYPE_SUBVERSION:
+40
src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php
··· 59 59 return isset($read_only[$command]); 60 60 } 61 61 62 + public static function isReadOnlyBatchCommand($cmds) { 63 + if (!strlen($cmds)) { 64 + // We expect a "batch" command to always have a "cmds" string, so err 65 + // on the side of caution and throw if we don't get any data here. This 66 + // either indicates a mangled command from the client or a programming 67 + // error in our code. 68 + throw new Exception("Expected nonempty 'cmds' specification!"); 69 + } 70 + 71 + // For "batch" we get a "cmds" argument like: 72 + // 73 + // heads ;known nodes= 74 + // 75 + // We need to examine the commands (here, "heads" and "known") to make sure 76 + // they're all read-only. 77 + 78 + // NOTE: Mercurial has some code to escape semicolons, but it does not 79 + // actually function for command separation. For example, these two batch 80 + // commands will produce completely different results (the former will run 81 + // the lookup; the latter will fail with a parser error): 82 + // 83 + // lookup key=a:xb;lookup key=z* 0 84 + // lookup key=a:;b;lookup key=z* 0 85 + // ^ 86 + // | 87 + // +-- Note semicolon. 88 + // 89 + // So just split unconditionally. 90 + 91 + $cmds = explode(';', $cmds); 92 + foreach ($cmds as $sub_cmd) { 93 + $name = head(explode(' ', $sub_cmd, 2)); 94 + if (!self::isReadOnlyCommand($name)) { 95 + return false; 96 + } 97 + } 98 + 99 + return true; 100 + } 101 + 62 102 }
+106
src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php
··· 1 + <?php 2 + 3 + final class DiffusionSSHMercurialServeWorkflow 4 + extends DiffusionSSHMercurialWorkflow { 5 + 6 + protected $didSeeWrite; 7 + 8 + public function didConstruct() { 9 + $this->setName('hg'); 10 + $this->setArguments( 11 + array( 12 + array( 13 + 'name' => 'repository', 14 + 'short' => 'R', 15 + 'param' => 'repo', 16 + ), 17 + array( 18 + 'name' => 'stdio', 19 + ), 20 + array( 21 + 'name' => 'command', 22 + 'wildcard' => true, 23 + ), 24 + )); 25 + } 26 + 27 + public function getRequestPath() { 28 + return $this->getArgs()->getArg('repository'); 29 + } 30 + 31 + protected function executeRepositoryOperations( 32 + PhabricatorRepository $repository) { 33 + 34 + $args = $this->getArgs(); 35 + 36 + if (!$args->getArg('stdio')) { 37 + throw new Exception("Expected `hg ... --stdio`!"); 38 + } 39 + 40 + if ($args->getArg('command') !== array('serve')) { 41 + throw new Exception("Expected `hg ... serve`!"); 42 + } 43 + 44 + $future = new ExecFuture( 45 + 'hg -R %s serve --stdio', 46 + $repository->getLocalPath()); 47 + 48 + $io_channel = $this->getIOChannel(); 49 + 50 + $protocol_channel = new DiffusionSSHMercurialWireClientProtocolChannel( 51 + $io_channel); 52 + 53 + $err = id($this->newPassthruCommand()) 54 + ->setIOChannel($protocol_channel) 55 + ->setCommandChannelFromExecFuture($future) 56 + ->setWillWriteCallback(array($this, 'willWriteMessageCallback')) 57 + ->execute(); 58 + 59 + // TODO: It's apparently technically possible to communicate errors to 60 + // Mercurial over SSH by writing a special "\n<error>\n-\n" string. However, 61 + // my attempt to implement that resulted in Mercurial closing the socket and 62 + // then hanging, without showing the error. This might be an issue on our 63 + // side (we need to close our half of the socket?), or maybe the code 64 + // for this in Mercurial doesn't actually work, or maybe something else 65 + // is afoot. At some point, we should look into doing this more cleanly. 66 + // For now, when we, e.g., reject writes for policy reasons, the user will 67 + // see "abort: unexpected response: empty string" after the diagnostically 68 + // useful, e.g., "remote: This repository is read-only over SSH." message. 69 + 70 + if (!$err && $this->didSeeWrite) { 71 + $repository->writeStatusMessage( 72 + PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, 73 + PhabricatorRepositoryStatusMessage::CODE_OKAY); 74 + } 75 + 76 + return $err; 77 + } 78 + 79 + public function willWriteMessageCallback( 80 + PhabricatorSSHPassthruCommand $command, 81 + $message) { 82 + 83 + $command = $message['command']; 84 + 85 + // Check if this is a readonly command. 86 + 87 + $is_readonly = false; 88 + if ($command == 'batch') { 89 + $cmds = idx($message['arguments'], 'cmds'); 90 + if (DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds)) { 91 + $is_readonly = true; 92 + } 93 + } else if (DiffusionMercurialWireProtocol::isReadOnlyCommand($command)) { 94 + $is_readonly = true; 95 + } 96 + 97 + if (!$is_readonly) { 98 + $this->requireWriteAccess(); 99 + $this->didSeeWrite = true; 100 + } 101 + 102 + // If we're good, return the raw message data. 103 + return $message['raw']; 104 + } 105 + 106 + }
+217
src/applications/diffusion/ssh/DiffusionSSHMercurialWireClientProtocolChannel.php
··· 1 + <?php 2 + 3 + final class DiffusionSSHMercurialWireClientProtocolChannel 4 + extends PhutilProtocolChannel { 5 + 6 + private $buffer = ''; 7 + private $state = 'command'; 8 + private $expectArgumentCount; 9 + private $argumentName; 10 + private $expectBytes; 11 + private $command; 12 + private $arguments; 13 + private $raw; 14 + 15 + protected function encodeMessage($message) { 16 + return $message; 17 + } 18 + 19 + private function initializeState($last_command = null) { 20 + if ($last_command == 'unbundle') { 21 + $this->command = '<raw-data>'; 22 + $this->state = 'data-length'; 23 + } else { 24 + $this->state = 'command'; 25 + } 26 + $this->expectArgumentCount = null; 27 + $this->expectBytes = null; 28 + $this->command = null; 29 + $this->argumentName = null; 30 + $this->arguments = array(); 31 + $this->raw = ''; 32 + } 33 + 34 + private function readProtocolLine() { 35 + $pos = strpos($this->buffer, "\n"); 36 + 37 + if ($pos === false) { 38 + return null; 39 + } 40 + 41 + $line = substr($this->buffer, 0, $pos); 42 + 43 + $this->raw .= $line."\n"; 44 + $this->buffer = substr($this->buffer, $pos + 1); 45 + 46 + return $line; 47 + } 48 + 49 + private function readProtocolBytes() { 50 + if (strlen($this->buffer) < $this->expectBytes) { 51 + return null; 52 + } 53 + 54 + $bytes = substr($this->buffer, 0, $this->expectBytes); 55 + $this->raw .= $bytes; 56 + $this->buffer = substr($this->buffer, $this->expectBytes); 57 + 58 + return $bytes; 59 + } 60 + 61 + private function newMessageAndResetState() { 62 + $message = array( 63 + 'command' => $this->command, 64 + 'arguments' => $this->arguments, 65 + 'raw' => $this->raw, 66 + ); 67 + $this->initializeState($this->command); 68 + return $message; 69 + } 70 + 71 + private function newDataMessage($bytes) { 72 + $message = array( 73 + 'command' => '<raw-data>', 74 + 'raw' => strlen($bytes)."\n".$bytes, 75 + ); 76 + return $message; 77 + } 78 + 79 + protected function decodeStream($data) { 80 + $this->buffer .= $data; 81 + 82 + $out = array(); 83 + $messages = array(); 84 + 85 + while (true) { 86 + if ($this->state == 'command') { 87 + $this->initializeState(); 88 + 89 + // We're reading a command. It looks like: 90 + // 91 + // <command> 92 + 93 + $line = $this->readProtocolLine(); 94 + if ($line === null) { 95 + break; 96 + } 97 + 98 + $this->command = $line; 99 + $this->state = 'arguments'; 100 + } else if ($this->state == 'arguments') { 101 + 102 + // Check if we're still waiting for arguments. 103 + $args = DiffusionMercurialWireProtocol::getCommandArgs($this->command); 104 + $have = array_select_keys($this->arguments, $args); 105 + if (count($have) == count($args)) { 106 + // We have all the arguments. Emit a message and read the next 107 + // command. 108 + $messages[] = $this->newMessageAndResetState(); 109 + } else { 110 + // We're still reading arguments. They can either look like: 111 + // 112 + // <name> <length(value)> 113 + // <value> 114 + // ... 115 + // 116 + // ...or like this: 117 + // 118 + // * <count> 119 + // <name1> <length(value1)> 120 + // <value1> 121 + // ... 122 + 123 + $line = $this->readProtocolLine(); 124 + if ($line === null) { 125 + break; 126 + } 127 + 128 + list($arg, $size) = explode(' ', $line, 2); 129 + $size = (int)$size; 130 + 131 + if ($arg != '*') { 132 + $this->expectBytes = $size; 133 + $this->argumentName = $arg; 134 + $this->state = 'value'; 135 + } else { 136 + $this->arguments['*'] = array(); 137 + $this->expectArgumentCount = $size; 138 + $this->state = 'argv'; 139 + } 140 + } 141 + } else if ($this->state == 'value' || $this->state == 'argv-value') { 142 + 143 + // We're reading the value of an argument. We just need to wait for 144 + // the right number of bytes to show up. 145 + 146 + $bytes = $this->readProtocolBytes(); 147 + if ($bytes === null) { 148 + break; 149 + } 150 + 151 + if ($this->state == 'argv-value') { 152 + $this->arguments['*'][$this->argumentName] = $bytes; 153 + $this->state = 'argv'; 154 + } else { 155 + $this->arguments[$this->argumentName] = $bytes; 156 + $this->state = 'arguments'; 157 + } 158 + 159 + 160 + } else if ($this->state == 'argv') { 161 + 162 + // We're reading a variable number of arguments. We need to wait for 163 + // the arguments to arrive. 164 + 165 + if ($this->expectArgumentCount) { 166 + $line = $this->readProtocolLine(); 167 + if ($line === null) { 168 + break; 169 + } 170 + 171 + list($arg, $size) = explode(' ', $line, 2); 172 + $size = (int)$size; 173 + 174 + $this->expectBytes = $size; 175 + $this->argumentName = $arg; 176 + $this->state = 'argv-value'; 177 + 178 + $this->expectArgumentCount--; 179 + } else { 180 + $this->state = 'arguments'; 181 + } 182 + } else if ($this->state == 'data-length') { 183 + $line = $this->readProtocolLine(); 184 + if ($line === null) { 185 + break; 186 + } 187 + $this->expectBytes = (int)$line; 188 + if (!$this->expectBytes) { 189 + $messages[] = $this->newDataMessage(''); 190 + $this->initializeState(); 191 + } else { 192 + $this->state = 'data-bytes'; 193 + } 194 + } else if ($this->state == 'data-bytes') { 195 + $bytes = substr($this->buffer, 0, $this->expectBytes); 196 + $this->buffer = substr($this->buffer, strlen($bytes)); 197 + $this->expectBytes -= strlen($bytes); 198 + 199 + $messages[] = $this->newDataMessage($bytes); 200 + 201 + if (!$this->expectBytes) { 202 + // We've finished reading this chunk, so go read the next chunk. 203 + $this->state = 'data-length'; 204 + } else { 205 + // We're waiting for more data, and have read everything available 206 + // to us so far. 207 + break; 208 + } 209 + } else { 210 + throw new Exception("Bad parser state '{$this->state}'!"); 211 + } 212 + } 213 + 214 + return $messages; 215 + } 216 + 217 + }
+5
src/applications/diffusion/ssh/DiffusionSSHMercurialWorkflow.php
··· 1 + <?php 2 + 3 + abstract class DiffusionSSHMercurialWorkflow extends DiffusionSSHWorkflow { 4 + 5 + }
+58
src/applications/diffusion/ssh/__tests__/DiffusionSSHMercurialWireTestCase.php
··· 1 + <?php 2 + 3 + final class DiffusionSSHMercurialWireTestCase 4 + extends PhabricatorTestCase { 5 + 6 + public function testMercurialClientWireProtocolParser() { 7 + $data = dirname(__FILE__).'/hgwiredata/'; 8 + $dir = Filesystem::listDirectory($data, $include_hidden = false); 9 + foreach ($dir as $file) { 10 + $raw = Filesystem::readFile($data.$file); 11 + $raw = explode("\n~~~~~~~~~~\n", $raw, 2); 12 + $this->assertEqual(2, count($raw)); 13 + $expect = json_decode($raw[1], true); 14 + $this->assertEqual(true, is_array($expect), $file); 15 + 16 + $this->assertParserResult($expect, $raw[0], $file); 17 + } 18 + } 19 + 20 + private function assertParserResult(array $expect, $input, $file) { 21 + list($x, $y) = PhutilSocketChannel::newChannelPair(); 22 + $xp = new DiffusionSSHMercurialWireClientProtocolChannel($x); 23 + 24 + $y->write($input); 25 + $y->flush(); 26 + $y->closeWriteChannel(); 27 + 28 + $messages = array(); 29 + for ($ii = 0; $ii < count($expect); $ii++) { 30 + try { 31 + $messages[] = $xp->waitForMessage(); 32 + } catch (Exception $ex) { 33 + // This is probably the parser not producing as many messages as 34 + // we expect. Log the exception, but continue to the assertion below 35 + // since that will often be easier to diagnose. 36 + phlog($ex); 37 + break; 38 + } 39 + } 40 + 41 + $this->assertEqual($expect, $messages, $file); 42 + 43 + // Now, make sure the channel doesn't have *more* messages than we expect. 44 + // Specifically, it should throw when we try to read another message. 45 + $caught = null; 46 + try { 47 + $xp->waitForMessage(); 48 + } catch (Exception $ex) { 49 + $caught = $ex; 50 + } 51 + 52 + $this->assertEqual( 53 + true, 54 + ($caught instanceof Exception), 55 + "No extra messages for '{$file}'."); 56 + } 57 + 58 + }
+16
src/applications/diffusion/ssh/__tests__/hgwiredata/batch.txt
··· 1 + batch 2 + * 0 3 + cmds 19 4 + heads ;known nodes= 5 + ~~~~~~~~~~ 6 + [ 7 + { 8 + "command" : "batch", 9 + "arguments" : { 10 + "*" : { 11 + }, 12 + "cmds" : "heads ;known nodes=" 13 + }, 14 + "raw" : "batch\n* 0\ncmds 19\nheads ;known nodes=" 15 + } 16 + ]
+10
src/applications/diffusion/ssh/__tests__/hgwiredata/capabilities.txt
··· 1 + capabilities 2 + 3 + ~~~~~~~~~~ 4 + [ 5 + { 6 + "command" : "capabilities", 7 + "arguments" : [], 8 + "raw" : "capabilities\n" 9 + } 10 + ]
+16
src/applications/diffusion/ssh/__tests__/hgwiredata/capabilities2.txt
··· 1 + capabilities 2 + capabilities 3 + 4 + ~~~~~~~~~~ 5 + [ 6 + { 7 + "command" : "capabilities", 8 + "arguments" : [], 9 + "raw" : "capabilities\n" 10 + }, 11 + { 12 + "command" : "capabilities", 13 + "arguments" : [], 14 + "raw" : "capabilities\n" 15 + } 16 + ]
+18
src/applications/diffusion/ssh/__tests__/hgwiredata/getbundle.txt
··· 1 + getbundle 2 + * 2 3 + common 40 4 + 0000000000000000000000000000000000000000heads 122 5 + 7cb27ad591d60500c020283b81c6467540218eda 1036b72db89a0451fa82fcd5462d903f591f0a3c 0b9d8290c4e067a0b91b43062ee9de392e8fae88 6 + ~~~~~~~~~~ 7 + [ 8 + { 9 + "command" : "getbundle", 10 + "arguments" : { 11 + "*" : { 12 + "common" : "0000000000000000000000000000000000000000", 13 + "heads" : "7cb27ad591d60500c020283b81c6467540218eda 1036b72db89a0451fa82fcd5462d903f591f0a3c 0b9d8290c4e067a0b91b43062ee9de392e8fae88" 14 + } 15 + }, 16 + "raw" : "getbundle\n* 2\ncommon 40\n0000000000000000000000000000000000000000heads 122\n7cb27ad591d60500c020283b81c6467540218eda 1036b72db89a0451fa82fcd5462d903f591f0a3c 0b9d8290c4e067a0b91b43062ee9de392e8fae88" 17 + } 18 + ]
+28
src/applications/diffusion/ssh/__tests__/hgwiredata/unbundle.txt
··· 1 + unbundle 2 + heads 53 3 + 686173686564 8022e00be6886fcf1be8f57f96c78aa924967f8320 4 + aaaaaaaaaaaaaaaaaaaa20 5 + bbbbbbbbbbbbbbbbbbbb0 6 + 7 + ~~~~~~~~~~ 8 + [ 9 + { 10 + "command" : "unbundle", 11 + "arguments" : { 12 + "heads" : "686173686564 8022e00be6886fcf1be8f57f96c78aa924967f83" 13 + }, 14 + "raw" : "unbundle\nheads 53\n686173686564 8022e00be6886fcf1be8f57f96c78aa924967f83" 15 + }, 16 + { 17 + "command" : "<raw-data>", 18 + "raw" : "20\naaaaaaaaaaaaaaaaaaaa" 19 + }, 20 + { 21 + "command" : "<raw-data>", 22 + "raw" : "20\nbbbbbbbbbbbbbbbbbbbb" 23 + }, 24 + { 25 + "command" : "<raw-data>", 26 + "raw" : "0\n" 27 + } 28 + ]
+19 -7
src/infrastructure/ssh/PhabricatorSSHPassthruCommand.php
··· 76 76 77 77 public function writeErrorIOCallback(PhutilChannel $channel, $data) { 78 78 $this->errorChannel->write($data); 79 + 80 + // TODO: Because of the way `waitForAny()` works, we degrade to a busy 81 + // wait if we hand it a writable, write-only channel. We should handle this 82 + // case better in `waitForAny()`. For now, just flush the error channel 83 + // explicity after writing data over it. 84 + 85 + $this->errorChannel->flush(); 79 86 } 80 87 81 88 public function execute() { ··· 98 105 $channels = array($command_channel, $io_channel, $error_channel); 99 106 100 107 while (true) { 101 - PhutilChannel::waitForAny($channels); 108 + // TODO: See note in writeErrorIOCallback! 109 + $wait = array($command_channel, $io_channel); 110 + PhutilChannel::waitForAny($wait); 102 111 103 112 $io_channel->update(); 104 113 $command_channel->update(); ··· 107 116 $done = !$command_channel->isOpen(); 108 117 109 118 $in_message = $io_channel->read(); 110 - $in_message = $this->willWriteData($in_message); 111 119 if ($in_message !== null) { 112 - $command_channel->write($in_message); 120 + $in_message = $this->willWriteData($in_message); 121 + if ($in_message !== null) { 122 + $command_channel->write($in_message); 123 + } 113 124 } 114 125 115 126 $out_message = $command_channel->read(); 116 - $out_message = $this->willReadData($out_message); 117 127 if ($out_message !== null) { 118 - $io_channel->write($out_message); 128 + $out_message = $this->willReadData($out_message); 129 + if ($out_message !== null) { 130 + $io_channel->write($out_message); 131 + } 119 132 } 120 133 121 134 // If we have nothing left on stdin, close stdin on the subprocess. 122 135 if (!$io_channel->isOpenForReading()) { 123 - // TODO: This should probably be part of PhutilExecChannel? 124 - $this->execFuture->write(''); 136 + $command_channel->closeWriteChannel(); 125 137 } 126 138 127 139 if ($done) {