Skip to content

Commit 9195c88

Browse files
authored
ConsoleReader: Use proc_open()'s socket support to send commands back to the main server process (#5273)
Support for this was introduced in PHP 8.0, though not mentioned in any changelog: php/php-src#5777 This simplifies the subprocess handling considerably. However, there is a potential for problems if PHP generates any E_* errors, since these get written to STDOUT as well. To avoid error messages being treated as a command, a hash is attached to each IPC message, seeded with an incrementing counter. This prevents error messages causing command replays or unintended commands. Unfortunately, PHP doesn't support binding pipes other than stdin/stdout/stderr on Windows for the child process, so we have to use stdout for this. In the future, if it becomes possible, a dedicated pipe for the purpose should be introduced. We'd need something like php://fd/<number> to work on Windows.
1 parent ae19d05 commit 9195c88

File tree

4 files changed

+209
-38
lines changed

4 files changed

+209
-38
lines changed

src/console/ConsoleReaderChildProcess.php

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,21 @@
2929
use function cli_set_process_title;
3030
use function count;
3131
use function dirname;
32-
use function feof;
3332
use function fwrite;
34-
use function stream_socket_client;
33+
use function is_numeric;
34+
use const PHP_EOL;
35+
use const STDOUT;
3536

36-
require dirname(__DIR__, 2) . '/vendor/autoload.php';
37-
38-
if(count($argv) !== 2){
39-
die("Please provide a server to connect to");
37+
if(count($argv) !== 2 || !is_numeric($argv[1])){
38+
echo "Usage: " . $argv[0] . " <command token seed>" . PHP_EOL;
39+
exit(1);
4040
}
4141

42+
$commandTokenSeed = (int) $argv[1];
43+
44+
require dirname(__DIR__, 2) . '/vendor/autoload.php';
45+
4246
@cli_set_process_title('PocketMine-MP Console Reader');
43-
$errCode = null;
44-
$errMessage = null;
45-
$socket = stream_socket_client($argv[1], $errCode, $errMessage, 15.0);
46-
if($socket === false){
47-
throw new \RuntimeException("Failed to connect to server process ($errCode): $errMessage");
48-
}
4947

5048
/** @phpstan-var ThreadSafeArray<int, string> $channel */
5149
$channel = new ThreadSafeArray();
@@ -75,15 +73,15 @@ public function run() : void{
7573
};
7674

7775
$thread->start(NativeThread::INHERIT_NONE);
78-
while(!feof($socket)){
76+
while(true){
7977
$line = $channel->synchronized(function() use ($channel) : ?string{
8078
if(count($channel) === 0){
8179
$channel->wait(1_000_000);
8280
}
83-
$line = $channel->shift();
84-
return $line;
81+
return $channel->shift();
8582
});
86-
if(@fwrite($socket, ($line ?? "") . "\n") === false){
83+
$message = $line !== null ? ConsoleReaderChildProcessUtils::createMessage($line, $commandTokenSeed) : "";
84+
if(@fwrite(STDOUT, $message . "\n") === false){
8785
//Always send even if there's no line, to check if the parent is alive
8886
//If the parent process was terminated forcibly, it won't close the connection properly, so feof() will return
8987
//false even though the connection is actually broken. However, fwrite() will fail.

src/console/ConsoleReaderChildProcessDaemon.php

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,16 @@
2929
use function base64_encode;
3030
use function fgets;
3131
use function fopen;
32+
use function mt_rand;
3233
use function preg_replace;
3334
use function proc_close;
3435
use function proc_open;
3536
use function proc_terminate;
37+
use function rtrim;
3638
use function sprintf;
3739
use function stream_select;
38-
use function stream_socket_accept;
39-
use function stream_socket_get_name;
40-
use function stream_socket_server;
41-
use function stream_socket_shutdown;
4240
use function trim;
4341
use const PHP_BINARY;
44-
use const STREAM_SHUT_RDWR;
4542

4643
/**
4744
* This pile of shit exists because PHP on Windows is broken, and can't handle stream_select() on stdin or pipes
@@ -58,44 +55,44 @@
5855
* communication.
5956
*/
6057
final class ConsoleReaderChildProcessDaemon{
58+
public const TOKEN_DELIMITER = ":";
59+
public const TOKEN_HASH_ALGO = "xxh3";
60+
6161
private \PrefixedLogger $logger;
6262
/** @var resource */
6363
private $subprocess;
6464
/** @var resource */
6565
private $socket;
66+
private int $commandTokenSeed;
6667

6768
public function __construct(
6869
\Logger $logger
6970
){
7071
$this->logger = new \PrefixedLogger($logger, "Console Reader Daemon");
72+
$this->commandTokenSeed = mt_rand();
7173
$this->prepareSubprocess();
7274
}
7375

7476
private function prepareSubprocess() : void{
75-
$server = stream_socket_server("tcp://127.0.0.1:0");
76-
if($server === false){
77-
throw new \RuntimeException("Failed to open console reader socket server");
78-
}
79-
$address = Utils::assumeNotFalse(stream_socket_get_name($server, false), "stream_socket_get_name() shouldn't return false here");
80-
8177
//Windows sucks, and likes to corrupt UTF-8 file paths when they travel to the subprocess, so we base64 encode
8278
//the path to avoid the problem. This is an abysmally shitty hack, but here we are :(
8379
$sub = Utils::assumeNotFalse(proc_open(
84-
[PHP_BINARY, '-dopcache.enable_cli=0', '-r', sprintf('require base64_decode("%s", true);', base64_encode(Path::join(__DIR__, 'ConsoleReaderChildProcess.php'))), $address],
8580
[
81+
PHP_BINARY,
82+
'-dopcache.enable_cli=0',
83+
'-r',
84+
sprintf('require base64_decode("%s", true);', base64_encode(Path::join(__DIR__, 'ConsoleReaderChildProcess.php'))),
85+
(string) $this->commandTokenSeed
86+
],
87+
[
88+
1 => ['socket'],
8689
2 => fopen("php://stderr", "w"),
8790
],
8891
$pipes
8992
), "Something has gone horribly wrong");
9093

91-
$client = stream_socket_accept($server, 15);
92-
if($client === false){
93-
throw new AssumptionFailedError("stream_socket_accept() returned false");
94-
}
95-
stream_socket_shutdown($server, STREAM_SHUT_RDWR);
96-
9794
$this->subprocess = $sub;
98-
$this->socket = $client;
95+
$this->socket = $pipes[1];
9996
}
10097

10198
private function shutdownSubprocess() : void{
@@ -104,21 +101,34 @@ private function shutdownSubprocess() : void{
104101
//the first place).
105102
proc_terminate($this->subprocess);
106103
proc_close($this->subprocess);
107-
stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR);
108104
}
109105

110106
public function readLine() : ?string{
111107
$r = [$this->socket];
112108
$w = null;
113109
$e = null;
114110
if(stream_select($r, $w, $e, 0, 0) === 1){
115-
$command = fgets($this->socket);
116-
if($command === false){
111+
$line = fgets($this->socket);
112+
if($line === false){
117113
$this->logger->debug("Lost connection to subprocess, restarting (maybe the child process was killed from outside?)");
118114
$this->shutdownSubprocess();
119115
$this->prepareSubprocess();
120116
return null;
121117
}
118+
$line = rtrim($line, "\n");
119+
120+
if($line === ""){
121+
//keepalive
122+
return null;
123+
}
124+
125+
$command = ConsoleReaderChildProcessUtils::parseMessage($line, $this->commandTokenSeed);
126+
if($command === null){
127+
//this is not a command - it may be some kind of error output from the subprocess
128+
//write it directly to the console
129+
$this->logger->warning("Unexpected output from child process: $line");
130+
return null;
131+
}
122132

123133
$command = preg_replace("#\\x1b\\x5b([^\\x1b]*\\x7e|[\\x40-\\x50])#", "", trim($command)) ?? throw new AssumptionFailedError("This regex is assumed to be valid");
124134
$command = preg_replace('/[[:cntrl:]]/', '', $command) ?? throw new AssumptionFailedError("This regex is assumed to be valid");
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
*
5+
* ____ _ _ __ __ _ __ __ ____
6+
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7+
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8+
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9+
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10+
*
11+
* This program is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU Lesser General Public License as published by
13+
* the Free Software Foundation, either version 3 of the License, or
14+
* (at your option) any later version.
15+
*
16+
* @author PocketMine Team
17+
* @link http://www.pocketmine.net/
18+
*
19+
*
20+
*/
21+
22+
declare(strict_types=1);
23+
24+
namespace pocketmine\console;
25+
26+
use function hash;
27+
use function strlen;
28+
use function strrpos;
29+
use function substr;
30+
31+
final class ConsoleReaderChildProcessUtils{
32+
public const TOKEN_DELIMITER = ":";
33+
public const TOKEN_HASH_ALGO = "xxh3";
34+
35+
private function __construct(){
36+
37+
}
38+
39+
/**
40+
* Creates an IPC message to transmit a user's input command to the parent process.
41+
*
42+
* Unfortunately we can't currently provide IPC pipes other than stdout/stderr to subprocesses on Windows, so this
43+
* adds a hash of the user input (with a counter as salt) to prevent unintended process output (like error messages)
44+
* from being treated as user input.
45+
*/
46+
public static function createMessage(string $line, int &$counter) : string{
47+
$token = hash(self::TOKEN_HASH_ALGO, $line, options: ['seed' => $counter]);
48+
$counter++;
49+
return $line . self::TOKEN_DELIMITER . $token;
50+
}
51+
52+
/**
53+
* Extracts a command from an IPC message from the console reader subprocess.
54+
* Returns the user's input command, or null if this isn't a user input.
55+
*/
56+
public static function parseMessage(string $message, int &$counter) : ?string{
57+
$delimiterPos = strrpos($message, self::TOKEN_DELIMITER);
58+
if($delimiterPos !== false){
59+
$left = substr($message, 0, $delimiterPos);
60+
$right = substr($message, $delimiterPos + strlen(self::TOKEN_DELIMITER));
61+
$expectedToken = hash(self::TOKEN_HASH_ALGO, $left, options: ['seed' => $counter]);
62+
63+
if($expectedToken === $right){
64+
$counter++;
65+
return $left;
66+
}
67+
}
68+
69+
return null;
70+
}
71+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
*
5+
* ____ _ _ __ __ _ __ __ ____
6+
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7+
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8+
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9+
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10+
*
11+
* This program is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU Lesser General Public License as published by
13+
* the Free Software Foundation, either version 3 of the License, or
14+
* (at your option) any later version.
15+
*
16+
* @author PocketMine Team
17+
* @link http://www.pocketmine.net/
18+
*
19+
*
20+
*/
21+
22+
declare(strict_types=1);
23+
24+
namespace pocketmine\console;
25+
26+
use PHPUnit\Framework\TestCase;
27+
use function mt_rand;
28+
use function str_repeat;
29+
30+
final class ConsoleReaderChildProcessUtilsTest extends TestCase{
31+
32+
/**
33+
* @phpstan-return \Generator<int, array{string}, void, void>
34+
*/
35+
public static function commandStringProvider() : \Generator{
36+
yield ["stop"];
37+
yield ["pocketmine:status"];
38+
yield [str_repeat("s", 1000)];
39+
yield ["time set day"];
40+
yield ["give \"Steve\" golden_apple"];
41+
}
42+
43+
/**
44+
* @dataProvider commandStringProvider
45+
*/
46+
public function testCreateParseSymmetry(string $input) : void{
47+
$counterCreate = $counterParse = mt_rand();
48+
$message = ConsoleReaderChildProcessUtils::createMessage($input, $counterCreate);
49+
$parsedInput = ConsoleReaderChildProcessUtils::parseMessage($message, $counterParse);
50+
51+
self::assertSame($input, $parsedInput);
52+
}
53+
54+
public function testCreateMessage() : void{
55+
$counter = 0;
56+
57+
ConsoleReaderChildProcessUtils::createMessage("", $counter);
58+
self::assertSame(1, $counter, "createMessage should always bump the counter");
59+
}
60+
61+
/**
62+
* @phpstan-return \Generator<int, array{string, bool}, void, void>
63+
*/
64+
public static function parseMessageProvider() : \Generator{
65+
$counter = 0;
66+
yield [ConsoleReaderChildProcessUtils::createMessage("", $counter), true];
67+
68+
yield ["", false]; //keepalive message, doesn't bump counter
69+
70+
$counter = 1;
71+
yield [ConsoleReaderChildProcessUtils::createMessage("", $counter), false]; //mismatched counter
72+
73+
$counter = 0;
74+
yield ["a" . ConsoleReaderChildProcessUtils::TOKEN_DELIMITER . "b", false]; //message with delimiter but not a valid IPC message
75+
}
76+
77+
/**
78+
* @dataProvider parseMessageProvider
79+
*/
80+
public static function testParseMessage(string $message, bool $valid) : void{
81+
$counter = $oldCounter = 0;
82+
83+
$input = ConsoleReaderChildProcessUtils::parseMessage($message, $counter);
84+
if(!$valid){
85+
self::assertNull($input, "Result should be null on invalid message");
86+
self::assertSame($oldCounter, $counter, "Counter shouldn't be bumped on invalid message");
87+
}else{
88+
self::assertNotNull($input, "This was a valid message, expected a result");
89+
self::assertSame($oldCounter + 1, $counter, "Counter should be bumped on valid message parse");
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)