diff --git a/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php b/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php index 6aae6156bb7fd..c6ed3abf58975 100644 --- a/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php +++ b/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php @@ -20,7 +20,6 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\LogicException; -use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -34,7 +33,6 @@ class ServerLogCommand extends Command { private const BG_COLOR = ['black', 'blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow']; - private ExpressionLanguage $el; private HandlerInterface $handler; public function isEnabled(): bool @@ -78,12 +76,13 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { + $el = null; $filter = $input->getOption('filter'); if ($filter) { if (!class_exists(ExpressionLanguage::class)) { throw new LogicException('Package "symfony/expression-language" is required to use the "filter" option. Try running "composer require symfony/expression-language".'); } - $this->el = new ExpressionLanguage(); + $el = new ExpressionLanguage(); } $this->handler = new ConsoleHandler($output, true, [ @@ -101,49 +100,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int $host = 'tcp://'.$host; } - if (!$socket = stream_socket_server($host, $errno, $errstr)) { - throw new RuntimeException(sprintf('Server start failed on "%s": ', $host).$errstr.' '.$errno); - } - - foreach ($this->getLogs($socket) as $clientId => $message) { - $record = unserialize(base64_decode($message)); + $streamHelper = $this->getHelper('stream'); + $streamHelper->listen( + $input, + $output, + $host, + function (int $clientId, string $message) use ($el, $filter, $output) { + $record = unserialize(base64_decode($message)); + + // Impossible to decode the message, give up. + if (false === $record) { + return; + } - // Impossible to decode the message, give up. - if (false === $record) { - continue; - } + if ($filter && !$el->evaluate($filter, $record)) { + return; + } - if ($filter && !$this->el->evaluate($filter, $record)) { - continue; + $this->displayLog($output, $clientId, $record); } + ); - $this->displayLog($output, $clientId, $record); - } - - return 0; - } - - private function getLogs($socket): iterable - { - $sockets = [(int) $socket => $socket]; - $write = []; - - while (true) { - $read = $sockets; - stream_select($read, $write, $write, null); - - foreach ($read as $stream) { - if ($socket === $stream) { - $stream = stream_socket_accept($socket); - $sockets[(int) $stream] = $stream; - } elseif (feof($stream)) { - unset($sockets[(int) $stream]); - fclose($stream); - } else { - yield (int) $stream => fgets($stream); - } - } - } + return Command::SUCCESS; } private function displayLog(OutputInterface $output, int $clientId, array $record): void diff --git a/src/Symfony/Bridge/Monolog/Tests/Command/ServerLogCommandTest.php b/src/Symfony/Bridge/Monolog/Tests/Command/ServerLogCommandTest.php new file mode 100644 index 0000000000000..f92ea37ae9a48 --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Tests/Command/ServerLogCommandTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Tests\Command; + +use Monolog\Level; +use Monolog\LogRecord; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Monolog\Command\ServerLogCommand; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\StreamHelper; +use Symfony\Component\Console\Tester\CommandTester; + +class ServerLogCommandTest extends TestCase +{ + public function testServerLogSuccess() + { + $command = $this->createCommand(); + $commandTester = new CommandTester($command); + + $record = new LogRecord( + new \DateTimeImmutable('2024-01-02 18:05'), + 'console', + Level::Info, + 'test log command', + ); + $recordFormatted = $record->toArray(); + $input = base64_encode(serialize($recordFormatted))."\n"; + + $commandTester->setInputs([$input]); + + $commandTester->execute([]); + + $commandTester->assertCommandIsSuccessful(); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('18:05:00 INFO [console] test log command', $output); + } + + private function createCommand(): ServerLogCommand + { + $command = new ServerLogCommand(); + $command->setHelperSet(new HelperSet([new StreamHelper()])); + + return $command; + } +} diff --git a/src/Symfony/Bridge/Monolog/composer.json b/src/Symfony/Bridge/Monolog/composer.json index 50a23a5876931..0bead72b8f066 100644 --- a/src/Symfony/Bridge/Monolog/composer.json +++ b/src/Symfony/Bridge/Monolog/composer.json @@ -22,7 +22,7 @@ "symfony/http-kernel": "^6.4|^7.0" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^7.1", "symfony/http-client": "^6.4|^7.0", "symfony/security-core": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -31,7 +31,7 @@ "symfony/messenger": "^6.4|^7.0" }, "conflict": { - "symfony/console": "<6.4", + "symfony/console": "<7.1", "symfony/http-foundation": "<6.4", "symfony/security-core": "<6.4" }, diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 00acef1dd1df0..67aa01927dded 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -39,6 +39,7 @@ use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Helper\StreamHelper; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; @@ -1112,6 +1113,7 @@ protected function getDefaultHelperSet(): HelperSet new DebugFormatterHelper(), new ProcessHelper(), new QuestionHelper(), + new StreamHelper(), ]); } diff --git a/src/Symfony/Component/Console/Helper/StreamHelper.php b/src/Symfony/Component/Console/Helper/StreamHelper.php new file mode 100644 index 0000000000000..3a23267a30d73 --- /dev/null +++ b/src/Symfony/Component/Console/Helper/StreamHelper.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\StreamableInputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * This class provides helpers to interact with the socket stream server. + * + * @author Louis-Marie Gaborit + */ +class StreamHelper extends Helper +{ + /** + * @var resource|null + */ + private $inputStream; + + /** + * @var resource|null + */ + private $socket; + + private static bool $stty = true; + private static bool $stdinIsInteractive; + + private function start(string $host): void + { + if (!$this->socket = stream_socket_server($host, $errno, $errstr)) { + throw new RuntimeException(sprintf('Server start failed on "%s": ', $host).$errstr.' '.$errno); + } + } + + public function listen(InputInterface $input, OutputInterface $output, string $host, callable $callback): void + { + if ($input instanceof StreamableInputInterface && $stream = $input->getStream()) { + $this->inputStream = $stream; + } elseif (null === $this->socket) { + if (!str_contains($host, '://')) { + $host = 'tcp://'.$host; + } + + $this->start($host); + } + + $io = new SymfonyStyle($input, $output); + $errorIo = $io->getErrorStyle(); + + $errorIo->success(sprintf('Server listening on %s', $host)); + $errorIo->comment('Quit the server with CONTROL-C.'); + + foreach ($this->getMessages() as $clientId => $message) { + $callback($clientId, $message); + } + } + + public function getName(): string + { + return 'stream'; + } + + private function getMessages(): iterable + { + if (null !== $inputStream = $this->inputStream) { + while (!feof($inputStream)) { + $stream = fgets($inputStream); + yield (int) $stream => $stream; + } + } else { + $sockets = [(int) $this->socket => $this->socket]; + $write = []; + + while (true) { + $read = $sockets; + stream_select($read, $write, $write, null); + + foreach ($read as $stream) { + if ($this->socket === $stream) { + $stream = stream_socket_accept($this->socket); + $sockets[(int) $stream] = $stream; + } elseif (feof($stream)) { + unset($sockets[(int) $stream]); + fclose($stream); + } else { + yield (int) $stream => fgets($stream); + } + } + } + } + } +} diff --git a/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php b/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php index b64a884b99bf3..78f312ee48524 100644 --- a/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php +++ b/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Cloner\Stub; use Symfony\Component\VarDumper\Command\Descriptor\CliDescriptor; use Symfony\Component\VarDumper\Command\Descriptor\DumpDescriptorInterface; use Symfony\Component\VarDumper\Command\Descriptor\HtmlDescriptor; @@ -86,16 +87,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int $errorIo = $io->getErrorStyle(); $errorIo->title('Symfony Var Dumper Server'); - $this->server->start(); + $streamHelper = $this->getHelper('stream'); - $errorIo->success(sprintf('Server listening on %s', $this->server->getHost())); - $errorIo->comment('Quit the server with CONTROL-C.'); + $streamHelper->listen( + $input, + $output, + $this->server->getHost(), + function (int $clientId, string $message) use ($descriptor, $io) { + $payload = @unserialize(base64_decode($message), ['allowed_classes' => [Data::class, Stub::class]]); - $this->server->listen(function (Data $data, array $context, int $clientId) use ($descriptor, $io) { - $descriptor->describe($io, $data, $context, $clientId); - }); + // Impossible to decode the message, give up. + if (false === $payload) { + return; + } - return 0; + if (!\is_array($payload) || \count($payload) < 2 || !$payload[0] instanceof Data || !\is_array($payload[1])) { + return; + } + [$data, $context] = $payload; + + $descriptor->describe($io, $data, $context, $clientId); + } + ); + + return Command::SUCCESS; } public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void diff --git a/src/Symfony/Component/VarDumper/Tests/Command/ServerDumpCommandTest.php b/src/Symfony/Component/VarDumper/Tests/Command/ServerDumpCommandTest.php index 47c45fd1b7e9e..64e1eab3266b8 100644 --- a/src/Symfony/Component/VarDumper/Tests/Command/ServerDumpCommandTest.php +++ b/src/Symfony/Component/VarDumper/Tests/Command/ServerDumpCommandTest.php @@ -12,7 +12,11 @@ namespace Symfony\Component\VarDumper\Tests\Command; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\StreamHelper; use Symfony\Component\Console\Tester\CommandCompletionTester; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\VarDumper\Cloner\Data; use Symfony\Component\VarDumper\Command\ServerDumpCommand; use Symfony\Component\VarDumper\Server\DumpServer; @@ -28,6 +32,24 @@ public function testComplete(array $input, array $expectedSuggestions) $this->assertSame($expectedSuggestions, $tester->complete($input)); } + public function testServerDumpSuccess() + { + $command = $this->createCommand(); + $commandTester = new CommandTester($command); + + $data = new Data([['my dump']]); + $input = base64_encode(serialize([$data, ['timestamp' => time(), 'source' => ['name' => 'sourceName', 'line' => 222, 'file' => 'myFile']]]))."\n"; + + $commandTester->setInputs([$input]); + + $commandTester->execute(['--format' => 'html']); + + $commandTester->assertCommandIsSuccessful(); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('my dump', $output); + } + public static function provideCompletionSuggestions() { yield 'option --format' => [ @@ -38,6 +60,9 @@ public static function provideCompletionSuggestions() private function createCommand(): ServerDumpCommand { - return new ServerDumpCommand($this->createMock(DumpServer::class)); + $command = new ServerDumpCommand($this->createMock(DumpServer::class)); + $command->setHelperSet(new HelperSet([new StreamHelper()])); + + return $command; } } diff --git a/src/Symfony/Component/VarDumper/composer.json b/src/Symfony/Component/VarDumper/composer.json index eba8c966e19cb..3896c214c466f 100644 --- a/src/Symfony/Component/VarDumper/composer.json +++ b/src/Symfony/Component/VarDumper/composer.json @@ -21,14 +21,14 @@ }, "require-dev": { "ext-iconv": "*", - "symfony/console": "^6.4|^7.0", + "symfony/console": "^7.1", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", "symfony/uid": "^6.4|^7.0", "twig/twig": "^3.0.4|^4.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.1" }, "autoload": { "files": [ "Resources/functions/dump.php" ],