diff --git a/src/Application.php b/src/Application.php index 3cd407cf..3afc51ae 100644 --- a/src/Application.php +++ b/src/Application.php @@ -18,7 +18,7 @@ /** * @internal */ -final class Application implements Processable +final class Application implements Processable, Cancellable, Destroyable { /** @var Processable[] */ private array $processors = []; @@ -115,6 +115,32 @@ public function process(array $senders = []): void } } + public function destroy(): void + { + foreach ([...$this->servers, ...$this->processors] as $instance) { + if ($instance instanceof Destroyable) { + $instance->destroy(); + } + } + + $this->servers = []; + $this->processors = []; + $this->fibers = []; + } + + public function cancel(): void + { + foreach ($this->servers as $server) { + $server->cancel(); + } + + while ($this->fibers !== [] || $this->processors !== []) { + echo '.'; + $this->process(); + Fiber::getCurrent() === null or Fiber::suspend(); + } + } + /** * @param Sender[] $senders */ @@ -131,8 +157,9 @@ private function sendBuffer(array $senders = []): void private function createServer(SocketServer $config, Inspector $inspector): Server { - $clientInflector = static function (Client $client, int $id) use ($inspector): Client { - // Logger::debug('New client connected %d', $id); + $logger = $this->logger; + $clientInflector = static function (Client $client, int $id) use ($inspector, $logger): Client { + $logger->debug('Client %d connected', $id); $inspector->addStream(SocketStream::create($client, $id)); return $client; }; diff --git a/src/Cancellable.php b/src/Cancellable.php new file mode 100644 index 00000000..bfdcbdfb --- /dev/null +++ b/src/Cancellable.php @@ -0,0 +1,15 @@ + + * @psalm-suppress RiskyTruthyFalsyComparison */ private static function registerHandler(): Closure { diff --git a/src/Command/Run.php b/src/Command/Run.php index 59c82539..30ba4c7b 100644 --- a/src/Command/Run.php +++ b/src/Command/Run.php @@ -11,6 +11,7 @@ use Buggregator\Trap\Sender; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -24,8 +25,10 @@ name: 'run', description: 'Run application', )] -final class Run extends Command +final class Run extends Command implements SignalableCommandInterface { + private ?Application $app = null; + public function configure(): void { $this->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to listen', 9912); @@ -54,14 +57,14 @@ protected function execute( $registry = $this->createRegistry($output); - $app = new Application( + $this->app = new Application( [new SocketServer($port)], new Logger($output), senders: $registry->getSenders($senders), withFrontend: $input->getOption('ui') !== false, ); - $app->run(); + $this->app->run(); } catch (\Throwable $e) { if ($output->isVerbose()) { // Write colorful exception (title, message, stacktrace) @@ -93,4 +96,28 @@ public function createRegistry(OutputInterface $output): Sender\SenderRegistry return $registry; } + + public function getSubscribedSignals(): array + { + $result = []; + \defined('SIGINT') and $result[] = \SIGINT; + \defined('SIGTERM') and $result[] = \SIGTERM; + + return $result; + } + + public function handleSignal(int $signal): void + { + if (\defined('SIGINT') && $signal === \SIGINT) { + // todo may be uncommented if it's possible to switch fibers when signal is received + // todo Error: Cannot switch fibers in current execution context + // $this->app?->cancel(); + + $this->app?->destroy(); + } + + if (\defined('SIGTERM') && $signal === \SIGTERM) { + $this->app?->destroy(); + } + } } diff --git a/src/Destroyable.php b/src/Destroyable.php new file mode 100644 index 00000000..7adcd355 --- /dev/null +++ b/src/Destroyable.php @@ -0,0 +1,15 @@ +getFile() . ':' . $e->getLine() . "\033[0m\n"; // Print stack trace using gray $r .= "Stack trace:\n"; - $r .= "\033[90m" . $e->getTraceAsString() . "\033[0m\n"; + // Limit stacktrace to 5 lines + $stack = \explode("\n", $e->getTraceAsString()); + $r .= "\033[90m" . implode("\n", \array_slice($stack, 0, \min(5, \count($stack)))) . "\033[0m\n"; $r .= "\n"; $this->echo($r, !$important); } diff --git a/src/Sender/Console/Renderer/Sentry/Exceptions.php b/src/Sender/Console/Renderer/Sentry/Exceptions.php index 2586f80e..89e46285 100644 --- a/src/Sender/Console/Renderer/Sentry/Exceptions.php +++ b/src/Sender/Console/Renderer/Sentry/Exceptions.php @@ -58,6 +58,8 @@ public static function render(OutputInterface $output, mixed $exceptions): void /** * Renders the trace of the exception. + * + * @psalm-suppress RiskyTruthyFalsyComparison */ private static function renderTrace(OutputInterface $output, array $frames, bool $verbose = false): void { @@ -82,6 +84,7 @@ private static function renderTrace(OutputInterface $output, array $frames, bool $file = $getValue($frame, 'filename'); $line = $getValue($frame, 'lineno', null); $class = $getValue($frame, 'class'); + /** @psalm-suppress RiskyTruthyFalsyComparison */ $class = empty($class) ? '' : $class . '::'; $function = $getValue($frame, 'function'); diff --git a/src/Sender/Console/Renderer/VarDumper.php b/src/Sender/Console/Renderer/VarDumper.php index 983b5b54..dd6b8e06 100644 --- a/src/Sender/Console/Renderer/VarDumper.php +++ b/src/Sender/Console/Renderer/VarDumper.php @@ -57,6 +57,9 @@ public function __construct( ) { } + /** + * @psalm-suppress RiskyTruthyFalsyComparison + */ public function describe(OutputInterface $output, Data $data, array $context, int $clientId): void { Common::renderHeader1($output, 'DUMP'); diff --git a/src/Socket/Client.php b/src/Socket/Client.php index 064c3db6..493170dd 100644 --- a/src/Socket/Client.php +++ b/src/Socket/Client.php @@ -4,7 +4,9 @@ namespace Buggregator\Trap\Socket; -use Buggregator\Trap\Socket\Exception\DisconnectClient; +use Buggregator\Trap\Destroyable; +use Buggregator\Trap\Socket\Exception\ClientDisconnected; +use Buggregator\Trap\Support\Timer; use Fiber; /** @@ -12,7 +14,7 @@ * * @internal */ -final class Client +final class Client implements Destroyable { /** @var string[] */ private array $writeQueue = []; @@ -33,24 +35,24 @@ private function __construct( $this->setOnClose(fn() => null); } - public function __destruct() + public function destroy(): void { + /** @psalm-suppress RedundantPropertyInitializationCheck */ + isset($this->onClose) and ($this->onClose)(); try { \socket_close($this->socket); } catch (\Throwable) { - // Do nothing. - } finally { - /** @psalm-suppress RedundantPropertyInitializationCheck */ - isset($this->onClose) and ($this->onClose)(); + // do nothing } + // Unlink all closures and free resources. + unset($this->onClose, $this->onPayload); + $this->writeQueue = []; + $this->readBuffer = ''; } - public function close(): void + public function __destruct() { - /** @psalm-suppress RedundantPropertyInitializationCheck */ - isset($this->onClose) and ($this->onClose)(); - // Unlink all closures and free resources. - unset($this->onClose, $this->onPayload, $this->readBuffer); + $this->destroy(); } public function disconnect(): void @@ -96,7 +98,9 @@ public function process(): void } if ($this->toDisconnect && $this->writeQueue === []) { - throw new DisconnectClient(); + // Wait for the socket buffer to be flushed. + (new Timer(0.1))->wait(); + throw new ClientDisconnected(); } Fiber::suspend(); } while (true); @@ -126,6 +130,10 @@ public function setOnClose(callable $callable): void public function send(string $payload): void { + if ($this->toDisconnect) { + return; + } + $this->writeQueue[] = $payload; } @@ -147,7 +155,7 @@ private function writeQueue(): void $this->writeQueue = []; - $this->toDisconnect and throw new DisconnectClient(); + $this->toDisconnect and throw new ClientDisconnected(); } private function readMessage(): void diff --git a/src/Socket/Exception/ClientDisconnected.php b/src/Socket/Exception/ClientDisconnected.php new file mode 100644 index 00000000..7690d944 --- /dev/null +++ b/src/Socket/Exception/ClientDisconnected.php @@ -0,0 +1,12 @@ + */ private array $fibers = []; + private bool $cancelled = false; + /** * @param null|Closure(Client, int $id): void $clientInflector * @param positive-int $payloadSize Max payload size. @@ -48,16 +53,27 @@ private function __construct( $logger->status('Application', 'Server started on 127.0.0.1:%s', $port); } - public function __destruct() + public function destroy(): void { + /** @psalm-suppress all */ + foreach ($this->clients ?? [] as $client) { + $client->destroy(); + } + try { - \socket_close($this->socket); - } finally { - foreach ($this->clients as $client) { - $client->close(); + /** @psalm-suppress all */ + if (isset($this->socket)) { + \socket_close($this->socket); } - unset($this->socket, $this->clients, $this->fibers); + } catch (\Throwable) { + // do nothing } + unset($this->socket, $this->clients, $this->fibers); + } + + public function __destruct() + { + $this->destroy(); } /** @@ -76,16 +92,22 @@ public static function init( public function process(): void { - while (false !== ($socket = \socket_accept($this->socket))) { + while (!$this->cancelled and false !== ($socket = \socket_accept($this->socket))) { $client = null; try { + /** @psalm-suppress MixedArgument */ $client = Client::init($socket, $this->payloadSize); $key = (int)\array_key_last($this->clients) + 1; $this->clients[$key] = $client; $this->clientInflector !== null and ($this->clientInflector)($client, $key); $this->fibers[$key] = new Fiber($client->process(...)); + /** + * The {@see self::$cancelled} may be changed because of fibers + * @psalm-suppress all + */ + $this->cancelled and $client->disconnect(); } catch (\Throwable) { - $client?->close(); + $client?->destroy(); unset($client); if (isset($key)) { unset($this->clients[$key], $this->fibers[$key]); @@ -98,16 +120,30 @@ public function process(): void $fiber->isStarted() ? $fiber->resume() : $fiber->start(); if ($fiber->isTerminated()) { - throw new RuntimeException('Client terminated.'); + throw new RuntimeException("Client $key terminated."); } } catch (\Throwable $e) { - if ($e instanceof DisconnectClient) { - $this->logger->info('Custom disconnect.'); + if ($e instanceof ClientDisconnected) { + $this->logger->debug('Client %s disconnected', $key); + } else { + $this->logger->exception($e, "Client $key fiber."); } - $this->clients[$key]->close(); - // Logger::exception($e, 'Client fiber.'); + + $this->clients[$key]->destroy(); unset($this->clients[$key], $this->fibers[$key]); } } + + if ($this->cancelled && $this->fibers === []) { + throw new ServerStopped(); + } + } + + public function cancel(): void + { + $this->cancelled = true; + foreach ($this->clients as $client) { + $client->disconnect(); + } } }