diff --git a/composer-require-checker.json b/composer-require-checker.json index 5571a66f..b2a2e153 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -15,6 +15,7 @@ "Yiisoft\\Cache\\Dependency\\Dependency", "Yiisoft\\ErrorHandler\\Event\\ApplicationError", "PHPUnit\\Framework\\TestCase", + "Yiisoft\\VarDumper\\HandlerInterface", "opcache_invalidate" ] } diff --git a/config/bootstrap.php b/config/bootstrap.php index a78e5b21..c5b4e96f 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -2,16 +2,19 @@ declare(strict_types=1); +use Psr\Container\ContainerInterface; +use Yiisoft\VarDumper\Handler\CompositeHandler; use Yiisoft\VarDumper\VarDumper; use Yiisoft\Yii\Debug\Collector\VarDumperCollector; use Yiisoft\Yii\Debug\Collector\VarDumperHandlerInterfaceProxy; +use Yiisoft\Yii\Debug\DebugServer\VarDumperHandler; /** * @var $params array */ return [ - static function ($container) use ($params) { + static function (ContainerInterface $container) use ($params) { if (!($params['yiisoft/yii-debug']['enabled'] ?? false)) { return; } @@ -19,9 +22,15 @@ static function ($container) use ($params) { return; } + $decorated = VarDumper::getDefaultHandler(); + + if ($params['yiisoft/yii-debug']['devServer']['enabled'] ?? false) { + $decorated = new CompositeHandler([$decorated, new VarDumperHandler()]); + } + VarDumper::setDefaultHandler( new VarDumperHandlerInterfaceProxy( - VarDumper::getDefaultHandler(), + $decorated, $container->get(VarDumperCollector::class), ), ); diff --git a/config/di.php b/config/di.php index 7d34518b..3a6500a3 100644 --- a/config/di.php +++ b/config/di.php @@ -5,14 +5,18 @@ use Composer\Autoload\ClassLoader; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; use Yiisoft\Aliases\Aliases; use Yiisoft\VarDumper\ClosureExporter; use Yiisoft\VarDumper\UseStatementParser; use Yiisoft\Yii\Debug\Collector\ContainerInterfaceProxy; use Yiisoft\Yii\Debug\Collector\ContainerProxyConfig; +use Yiisoft\Yii\Debug\Collector\LogCollector; +use Yiisoft\Yii\Debug\Collector\LoggerInterfaceProxy; use Yiisoft\Yii\Debug\Collector\ServiceCollector; use Yiisoft\Yii\Debug\Collector\Stream\FilesystemStreamCollector; use Yiisoft\Yii\Debug\DebuggerIdGenerator; +use Yiisoft\Yii\Debug\DebugServer\LoggerDecorator; use Yiisoft\Yii\Debug\Storage\FileStorage; use Yiisoft\Yii\Debug\Storage\StorageInterface; @@ -44,13 +48,23 @@ $params = $params['yiisoft/yii-debug']; $collector = $container->get(ServiceCollector::class); $dispatcher = $container->get(EventDispatcherInterface::class); - $debuggerEnabled = (bool) ($params['enabled'] ?? false); + $isDebuggerEnabled = (bool) ($params['enabled'] ?? false); + $isDevServerEnabled = (bool) ($params['devServer']['enabled'] ?? false); + $trackedServices = (array) ($params['trackedServices'] ?? []); + + if ($isDevServerEnabled) { + $trackedServices[LoggerInterface::class] = static fn ( + ContainerInterface $container, + LoggerInterface $logger, + ) => new LoggerInterfaceProxy(new LoggerDecorator($logger), $container->get(LogCollector::class)); + } + $path = $container->get(Aliases::class)->get('@runtime/cache/container-proxy'); $logLevel = $params['logLevel'] ?? ContainerInterfaceProxy::LOG_NOTHING; return new ContainerProxyConfig( - $debuggerEnabled, + $isDebuggerEnabled, $trackedServices, $dispatcher, $collector, diff --git a/config/params.php b/config/params.php index 40801791..1fde7437 100644 --- a/config/params.php +++ b/config/params.php @@ -25,6 +25,8 @@ use Yiisoft\Yii\Debug\Collector\Web\RequestCollector; use Yiisoft\Yii\Debug\Collector\Web\WebAppInfoCollector; use Yiisoft\Yii\Debug\Command\DebugResetCommand; +use Yiisoft\Yii\Debug\Command\DebugServerBroadcastCommand; +use Yiisoft\Yii\Debug\Command\DebugServerCommand; /** * @var $params array @@ -33,6 +35,9 @@ return [ 'yiisoft/yii-debug' => [ 'enabled' => true, + 'devServer' => [ + 'enabled' => true, + ], 'collectors' => [ LogCollector::class, EventCollector::class, @@ -88,6 +93,8 @@ 'yiisoft/yii-console' => [ 'commands' => [ 'debug:reset' => DebugResetCommand::class, + DebugServerCommand::COMMAND_NAME => DebugServerCommand::class, + DebugServerBroadcastCommand::COMMAND_NAME => DebugServerBroadcastCommand::class, ], ], ]; diff --git a/src/Collector/ContainerInterfaceProxy.php b/src/Collector/ContainerInterfaceProxy.php index 2c53f06b..6c2cb47c 100644 --- a/src/Collector/ContainerInterfaceProxy.php +++ b/src/Collector/ContainerInterfaceProxy.php @@ -28,8 +28,6 @@ final class ContainerInterfaceProxy implements ContainerInterface private ProxyManager $proxyManager; - private array $decoratedServices = []; - private array $serviceProxy = []; public function __construct( @@ -60,13 +58,7 @@ public function get($id): mixed $this->logProxy(ContainerInterface::class, $this->decorated, 'get', [$id], $instance, $timeStart); } - if ( - is_object($instance) - && ( - ($proxy = $this->getServiceProxyCache($id)) || - ($proxy = $this->getServiceProxy($id, $instance)) - ) - ) { + if (is_object($instance) && ($proxy = $this->getServiceProxy($id, $instance))) { $this->setServiceProxyCache($id, $proxy); return $proxy; } @@ -96,13 +88,12 @@ public function isActive(): bool return $this->config->getIsActive() && $this->config->getDecoratedServices() !== []; } - private function getServiceProxyCache(string $service): ?object - { - return $this->serviceProxy[$service] ?? null; - } - private function getServiceProxy(string $service, object $instance): ?object { + if (isset($this->serviceProxy[$service])) { + return $this->serviceProxy[$service]; + } + if (!$this->isDecorated($service)) { return null; } diff --git a/src/Command/DebugServerBroadcastCommand.php b/src/Command/DebugServerBroadcastCommand.php new file mode 100644 index 00000000..0d55de74 --- /dev/null +++ b/src/Command/DebugServerBroadcastCommand.php @@ -0,0 +1,61 @@ +setHelp( + 'Broadcasts a message to all connected clients.' + ) + ->addOption('message', 'm', InputOption::VALUE_OPTIONAL, 'A text to broadcast', 'Test message') + ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Yii3 Debug Server'); + $io->writeln('https://yiiframework.com' . "\n"); + + $env = $input->getOption('env'); + if ($env === 'test') { + return ExitCode::OK; + } + + $socket = Connection::create(); + if (\function_exists('pcntl_signal')) { + $io->success('Quit the server with CTRL-C or COMMAND-C.'); + + \pcntl_signal(\SIGINT, static function () use ($socket): void { + $socket->close(); + exit(1); + }); + } + + $data = $input->getOption('message'); + $socket->broadcast(Connection::MESSAGE_TYPE_LOGGER, $data); + $socket->broadcast(Connection::MESSAGE_TYPE_VAR_DUMPER, VarDumper::create(['$data' => $data])->asJson(false)); + + return ExitCode::OK; + } +} diff --git a/src/Command/DebugServerCommand.php b/src/Command/DebugServerCommand.php new file mode 100644 index 00000000..d09e8c56 --- /dev/null +++ b/src/Command/DebugServerCommand.php @@ -0,0 +1,89 @@ +setHelp( + 'In order to access server from remote machines use 0.0.0.0:8000. That is especially useful when running server in a virtual machine.' + ) + ->addOption('address', 'a', InputOption::VALUE_OPTIONAL, 'Host to serve at', $this->address) + ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to serve at', $this->port) + ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Yii3 Debug Server'); + $io->writeln('https://yiiframework.com' . "\n"); + + $env = $input->getOption('env'); + if ($env === 'test') { + return ExitCode::OK; + } + + $socket = Connection::create(); + $socket->bind(); + + $io->success( + sprintf( + 'Listening on "%s".', + $socket->getUri(), + ) + ); + + if (\function_exists('pcntl_signal')) { + $io->success('Quit the server with CTRL-C or COMMAND-C.'); + + \pcntl_signal(\SIGINT, static function () use ($socket): void { + $socket->close(); + exit(1); + }); + } + + foreach ($socket->read() as $message) { + if ($message[0] === Connection::TYPE_ERROR) { + $io->error('Connection closed with error: ' . $message[1]); + break; + } + + $data = \json_decode($message[1], null, 512, JSON_THROW_ON_ERROR); + $type = match ($data[0]) { + Connection::MESSAGE_TYPE_VAR_DUMPER => 'VarDumper', + Connection::MESSAGE_TYPE_LOGGER => 'Logger', + default => 'Plain text', + }; + + $io->block($data[1], $type); + } + + return ExitCode::OK; + } +} diff --git a/src/DebugServer/Connection.php b/src/DebugServer/Connection.php new file mode 100644 index 00000000..503a9d66 --- /dev/null +++ b/src/DebugServer/Connection.php @@ -0,0 +1,206 @@ +uri = $file; + if (!socket_bind($this->socket, $file)) { + $socket_last_error = socket_last_error($this->socket); + + throw new RuntimeException( + sprintf( + 'An error occurred while reading the socket. "socket_last_error" returned %d: "%s".', + $socket_last_error, + socket_strerror($socket_last_error), + ), + ); + } + } + + /** + * @return Generator + */ + public function read(): Generator + { + $sndbuf = socket_get_option($this->socket, SOL_SOCKET, SO_SNDBUF); + $rcvbuf = socket_get_option($this->socket, SOL_SOCKET, SO_RCVBUF); + + socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 2, 'usec' => 0]); + socket_set_option($this->socket, SOL_SOCKET, SO_RCVBUF, 1024 * 10); + socket_set_option($this->socket, SOL_SOCKET, SO_SNDBUF, 1024 * 10); + + $newFrameAwaitRepeat = 0; + $maxFrameAwaitRepeats = 10; + $maxRepeats = 10; + + while (true) { + if (!socket_recv($this->socket, $header, 8, MSG_WAITALL)) { + $socket_last_error = socket_last_error($this->socket); + $newFrameAwaitRepeat++; + if ($newFrameAwaitRepeat === $maxFrameAwaitRepeats) { + $newFrameAwaitRepeat = 0; + yield [self::TYPE_RELEASE, $socket_last_error, socket_strerror($socket_last_error)]; + } + if ($socket_last_error === 35) { + usleep(self::DEFAULT_TIMEOUT); + continue; + } + $this->close(); + yield [self::TYPE_ERROR, $socket_last_error, socket_strerror($socket_last_error)]; + continue; + } + + $length = unpack('P', (string) $header); + $localBuffer = ''; + $bytesToRead = $length[1]; + $bytesRead = 0; + //$value = 2 ** ((int) ($bytesToRead / 2)); + //socket_set_option($this->socket, SOL_SOCKET, SO_RCVBUF, $value); + $repeat = 0; + while ($bytesRead < $bytesToRead) { + //$buffer = socket_read($this->socket, $bytesToRead - $bytesRead); + //$bufferLength = strlen($buffer); + $bufferLength = socket_recv($this->socket, $buffer, min($bytesToRead - $bytesRead, self::DEFAULT_BUFFER_SIZE), MSG_DONTWAIT); + if ($bufferLength === false) { + if ($repeat === $maxRepeats) { + break; + } + //if ($bufferLength === false) { + $socket_last_error = socket_last_error($this->socket); + if ($socket_last_error === 35) { + $repeat++; + usleep(self::DEFAULT_TIMEOUT * 5); + continue; + } + $this->close(); + break; + } + + $localBuffer .= $buffer; + $bytesRead += $bufferLength; + } + yield [self::TYPE_RESULT, base64_decode($localBuffer)]; + } + } + + public function broadcast(int $type, string $data): array + { + $files = glob(sys_get_temp_dir() . '/yii-dev-server-*.sock', GLOB_NOSORT); + //echo 'Files: ' . implode(', ', $files) . "\n"; + $uniqueErrors = []; + $payload = json_encode([$type, $data], JSON_THROW_ON_ERROR); + foreach ($files as $file) { + $socket = @fsockopen('udg://' . $file, -1, $errno, $errstr); + if ($errno === 61) { + @unlink($file); + continue; + } + if ($errno !== 0) { + $uniqueErrors[$errno] = $errstr; + continue; + } + try { + if (!$this->fwriteStream($socket, $payload)) { + $uniqueErrors[] = error_get_last(); + /** + * Connection is closed. + */ + continue; + } + } catch (Throwable $e) { + //@unlink($file); + throw $e; + } finally { + //fflush($socket); + fclose($socket); + } + } + return $uniqueErrors; + } + + public function getUri(): string + { + return $this->uri; + } + + public function close(): void + { + @socket_getsockname($this->socket, $path); + @socket_close($this->socket); + @unlink($path); + } + + /** + * @param resource $fp + */ + private function fwriteStream($fp, string $data): int|false + { + $data = base64_encode($data); + $strlen = strlen($data); + fwrite($fp, pack('P', $strlen)); + for ($written = 0; $written < $strlen; $written += $fwrite) { + $fwrite = fwrite($fp, substr($data, $written), self::DEFAULT_BUFFER_SIZE); + //\fflush($fp); + usleep(self::DEFAULT_TIMEOUT * 5); + if ($fwrite === false) { + return $written; + } + } + return $written; + } +} diff --git a/src/DebugServer/LoggerDecorator.php b/src/DebugServer/LoggerDecorator.php new file mode 100644 index 00000000..5a6f1f2e --- /dev/null +++ b/src/DebugServer/LoggerDecorator.php @@ -0,0 +1,31 @@ +connection = Connection::create(); + } + + public function log($level, Stringable|string $message, array $context = []): void + { + $this->connection->broadcast( + Connection::MESSAGE_TYPE_LOGGER, + VarDumper::create(['message' => $message, 'context' => $context])->asJson(false, 1) + ); + $this->decorated->log($level, $message, $context); + } +} diff --git a/src/DebugServer/VarDumperHandler.php b/src/DebugServer/VarDumperHandler.php new file mode 100644 index 00000000..49618699 --- /dev/null +++ b/src/DebugServer/VarDumperHandler.php @@ -0,0 +1,23 @@ +connection = Connection::create(); + } + + public function handle(mixed $variable, int $depth, bool $highlight = false): void + { + $this->connection->broadcast(Connection::MESSAGE_TYPE_VAR_DUMPER, VarDumper::create($variable)->asJson(false)); + } +}