diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d8968..dd73397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,3 +21,9 @@ potentially-shipped, backward-compatible release). - `Process\SignalHandler` — SIGINT/SIGTERM → graceful shutdown. - `Process\ChildReaper` — reaps exited forked children (SIGCHLD + periodic backstop); the reusable form of laravel-websockets #982. +- PSR-3 logging: `Kernel` accepts an optional `LoggerInterface` (defaults to + `NullLogger`), exposes `logger()`, and emits boot/running/shutdown lifecycle + lines. +- `Support\ConnectionRegistry` — a transport-agnostic id→connection registry + (add/remove/get/each/onAdd/onRemove/clear) for presence + broadcast, shared by + protocol servers (WS channels, WebRTC peers). diff --git a/README.md b/README.md index 6e2fb8b..30a299c 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue?style=flat-square)](https://php.net) [![Built on ReactPHP](https://img.shields.io/badge/built%20on-ReactPHP-4b275f?style=flat-square)](https://reactphp.org) -[![Tests](https://img.shields.io/badge/tests-13%20passing-success?style=flat-square)](#testing) -[![Assertions](https://img.shields.io/badge/assertions-19-blue?style=flat-square)](#testing) +[![Tests](https://img.shields.io/badge/tests-19%20passing-success?style=flat-square)](#testing) +[![Assertions](https://img.shields.io/badge/assertions-35-blue?style=flat-square)](#testing) [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE) A tiny, protocol-agnostic ReactPHP backbone — one long-lived process, one event loop, one IPC primitive, and one graceful-shutdown story that many protocol servers attach to. @@ -18,7 +18,9 @@ A tiny, protocol-agnostic ReactPHP backbone — one long-lived process, one even - 🛎️ **Graceful shutdown** — SIGINT/SIGTERM tear every server down cleanly before the loop stops - 🧹 **Child reaping built in** — no zombie processes from forked workers (SIGCHLD + periodic backstop) - 🔐 **Plain or TLS listeners** — one factory, no framework coupling -- 🪶 **Dependency-light** — just `react/event-loop`, `react/socket`, `react/stream` +- 📝 **PSR-3 logging** — pass any logger; the kernel emits lifecycle lines and hands it to your servers +- 📇 **Connection registry** — a shared presence/broadcast map (channel members, WebRTC peers) any protocol can reuse +- 🪶 **Dependency-light** — `psr/log` + `react/event-loop`, `react/socket`, `react/stream` ## Installation @@ -81,6 +83,7 @@ final class EchoServer implements Server | `Ipc\SocketPairIpc` | Event-driven parent/child IPC over a Unix socket pair. | | `Process\SignalHandler` | SIGINT/SIGTERM → graceful shutdown. | | `Process\ChildReaper` | Reaps exited forked children (SIGCHLD + periodic backstop). | +| `Support\ConnectionRegistry` | Shared id→connection registry for presence/broadcast. | ## Why diff --git a/composer.json b/composer.json index c2cbf13..98bb8d2 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ ], "require": { "php": "^8.1|^8.2|^8.3|^8.4", + "psr/log": "^1.1|^2.0|^3.0", "react/event-loop": "^1.5", "react/socket": "^1.15", "react/stream": "^1.3" diff --git a/src/Kernel.php b/src/Kernel.php index 7dc834b..e6ac0f6 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -7,6 +7,8 @@ namespace Blax\ReactPhpKernel; use Blax\ReactPhpKernel\Contracts\Server; use Blax\ReactPhpKernel\Process\ChildReaper; use Blax\ReactPhpKernel\Process\SignalHandler; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; @@ -43,10 +45,13 @@ final class Kernel private ?ChildReaper $reaper = null; - public function __construct(?LoopInterface $loop = null) + private LoggerInterface $logger; + + public function __construct(?LoopInterface $loop = null, ?LoggerInterface $logger = null) { // Default to the global loop so co-located libraries share one reactor. $this->loop = $loop ?? Loop::get(); + $this->logger = $logger ?? new NullLogger; } public function loop(): LoopInterface @@ -54,6 +59,11 @@ final class Kernel return $this->loop; } + public function logger(): LoggerInterface + { + return $this->logger; + } + /** Attach a protocol server (WS, WebRTC, ...) to the shared loop. */ public function register(Server $server): self { @@ -99,6 +109,7 @@ final class Kernel foreach ($this->servers as $server) { $server->boot($this->loop); + $this->logger->info('kernel: server booted', ['server' => $server->name()]); } foreach ($this->onBoot as $callback) { @@ -107,6 +118,8 @@ final class Kernel SignalHandler::install($this->loop, fn () => $this->stop()); + $this->logger->info('kernel: running', ['servers' => array_map(fn (Server $s) => $s->name(), $this->servers)]); + $this->loop->run(); } @@ -117,11 +130,17 @@ final class Kernel return; } + $this->logger->info('kernel: shutting down'); + foreach ($this->servers as $server) { try { $server->shutdown(); - } catch (\Throwable) { + } catch (\Throwable $e) { // A failing teardown must not block the others. + $this->logger->error('kernel: server shutdown failed', [ + 'server' => $server->name(), + 'error' => $e->getMessage(), + ]); } } diff --git a/src/Support/ConnectionRegistry.php b/src/Support/ConnectionRegistry.php new file mode 100644 index 0000000..de092b7 --- /dev/null +++ b/src/Support/ConnectionRegistry.php @@ -0,0 +1,108 @@ + */ + private array $items = []; + + /** @var array */ + private array $onAdd = []; + + /** @var array */ + private array $onRemove = []; + + /** Register (or replace) a connection under $id. Fires onAdd listeners. */ + public function add(string $id, object $connection): void + { + $this->items[$id] = $connection; + + foreach ($this->onAdd as $listener) { + $listener($id, $connection); + } + } + + /** Remove a connection by id (no-op if absent). Fires onRemove listeners. */ + public function remove(string $id): void + { + if (! isset($this->items[$id])) { + return; + } + + $connection = $this->items[$id]; + unset($this->items[$id]); + + foreach ($this->onRemove as $listener) { + $listener($id, $connection); + } + } + + public function has(string $id): bool + { + return isset($this->items[$id]); + } + + public function get(string $id): ?object + { + return $this->items[$id] ?? null; + } + + /** @return array */ + public function all(): array + { + return $this->items; + } + + /** @return array */ + public function ids(): array + { + return array_keys($this->items); + } + + public function count(): int + { + return count($this->items); + } + + /** + * Apply a callback to every connection — the broadcast primitive. + * The callback receives (object $connection, string $id). + */ + public function each(callable $callback): void + { + foreach ($this->items as $id => $connection) { + $callback($connection, $id); + } + } + + /** Listen for additions: function(string $id, object $connection): void. */ + public function onAdd(callable $listener): void + { + $this->onAdd[] = $listener; + } + + /** Listen for removals: function(string $id, object $connection): void. */ + public function onRemove(callable $listener): void + { + $this->onRemove[] = $listener; + } + + /** Remove every connection (firing onRemove for each). */ + public function clear(): void + { + foreach (array_keys($this->items) as $id) { + $this->remove($id); + } + } +} diff --git a/tests/ConnectionRegistryTest.php b/tests/ConnectionRegistryTest.php new file mode 100644 index 0000000..bbb2f10 --- /dev/null +++ b/tests/ConnectionRegistryTest.php @@ -0,0 +1,87 @@ +add('a', $a); + $registry->add('b', $b); + + $this->assertSame($a, $registry->get('a')); + $this->assertTrue($registry->has('b')); + $this->assertFalse($registry->has('c')); + $this->assertNull($registry->get('c')); + $this->assertCount(2, $registry); + $this->assertSame(['a', 'b'], $registry->ids()); + } + + public function test_remove_is_idempotent_and_fires_listeners_once(): void + { + $registry = new ConnectionRegistry; + $removed = []; + $registry->onRemove(function (string $id) use (&$removed) { + $removed[] = $id; + }); + + $registry->add('a', new \stdClass); + $registry->remove('a'); + $registry->remove('a'); // no-op — must not fire again + + $this->assertSame(['a'], $removed); + $this->assertCount(0, $registry); + } + + public function test_on_add_listener_fires(): void + { + $registry = new ConnectionRegistry; + $added = []; + $registry->onAdd(function (string $id) use (&$added) { + $added[] = $id; + }); + + $registry->add('x', new \stdClass); + + $this->assertSame(['x'], $added); + } + + public function test_each_visits_every_connection(): void + { + $registry = new ConnectionRegistry; + $registry->add('a', new \stdClass); + $registry->add('b', new \stdClass); + + $seen = []; + $registry->each(function (object $conn, string $id) use (&$seen) { + $seen[] = $id; + }); + + $this->assertSame(['a', 'b'], $seen); + } + + public function test_clear_removes_all_and_fires_remove_for_each(): void + { + $registry = new ConnectionRegistry; + $removed = []; + $registry->onRemove(function (string $id) use (&$removed) { + $removed[] = $id; + }); + + $registry->add('a', new \stdClass); + $registry->add('b', new \stdClass); + $registry->clear(); + + $this->assertCount(0, $registry); + $this->assertSame(['a', 'b'], $removed); + } +} diff --git a/tests/Fixtures/SpyLogger.php b/tests/Fixtures/SpyLogger.php new file mode 100644 index 0000000..c40382c --- /dev/null +++ b/tests/Fixtures/SpyLogger.php @@ -0,0 +1,38 @@ + */ + public array $records = []; + + public function log($level, \Stringable|string $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => (string) $message, 'context' => $context]; + } + + public function messages(): array + { + return array_map(fn ($r) => $r['message'], $this->records); + } + + public function hasMessageContaining(string $needle): bool + { + foreach ($this->records as $record) { + if (str_contains($record['message'], $needle)) { + return true; + } + } + + return false; + } +} diff --git a/tests/KernelTest.php b/tests/KernelTest.php index 082f742..c08f8f8 100644 --- a/tests/KernelTest.php +++ b/tests/KernelTest.php @@ -7,6 +7,7 @@ namespace Blax\ReactPhpKernel\Tests; use Blax\ReactPhpKernel\Contracts\Server; use Blax\ReactPhpKernel\Kernel; use Blax\ReactPhpKernel\Tests\Fixtures\RecordingServer; +use Blax\ReactPhpKernel\Tests\Fixtures\SpyLogger; use PHPUnit\Framework\TestCase; use React\EventLoop\LoopInterface; use React\EventLoop\StreamSelectLoop; @@ -86,4 +87,21 @@ final class KernelTest extends TestCase $this->assertTrue($good->wasShutDown, 'a throwing teardown must not stop the others'); } + + public function test_emits_lifecycle_logs_to_the_injected_logger(): void + { + $loop = new StreamSelectLoop; + $logger = new SpyLogger; + $kernel = new Kernel($loop, $logger); + + $server = new RecordingServer('lifecycle'); + $server->onBoot = fn (LoopInterface $l) => $l->futureTick(fn () => $kernel->stop()); + + $kernel->register($server)->run(); + + $this->assertSame($logger, $kernel->logger()); + $this->assertTrue($logger->hasMessageContaining('server booted')); + $this->assertTrue($logger->hasMessageContaining('running')); + $this->assertTrue($logger->hasMessageContaining('shutting down')); + } }