feat: PSR-3 logging + ConnectionRegistry backbone primitives
- Kernel now accepts an optional PSR-3 LoggerInterface (default NullLogger), exposes logger(), emits boot/running/shutdown lifecycle lines - Add Support\ConnectionRegistry: transport-agnostic id->connection registry (add/remove/get/each/onAdd/onRemove/clear) for presence + broadcast, reused by WS channels + WebRTC peers - Tests: 19 total / 35 assertions (added ConnectionRegistry + logging coverage) - require psr/log rel learn-atc #1055 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c710b5132
commit
9173a8897b
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
|
||||
[](https://php.net)
|
||||
[](https://reactphp.org)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\ReactPhpKernel\Support;
|
||||
|
||||
/**
|
||||
* A generic, in-memory registry of active connections/peers keyed by string id.
|
||||
*
|
||||
* Shared plumbing for presence + broadcast in protocol servers — WS channel
|
||||
* members, WebRTC peers in a room, etc. It is transport-agnostic: values are
|
||||
* arbitrary objects (a socket connection, a peer wrapper, ...), so the same
|
||||
* registry serves every protocol on the kernel.
|
||||
*/
|
||||
final class ConnectionRegistry implements \Countable
|
||||
{
|
||||
/** @var array<string,object> */
|
||||
private array $items = [];
|
||||
|
||||
/** @var array<callable> */
|
||||
private array $onAdd = [];
|
||||
|
||||
/** @var array<callable> */
|
||||
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<string,object> */
|
||||
public function all(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\ReactPhpKernel\Tests;
|
||||
|
||||
use Blax\ReactPhpKernel\Support\ConnectionRegistry;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConnectionRegistryTest extends TestCase
|
||||
{
|
||||
public function test_add_get_has_and_count(): void
|
||||
{
|
||||
$registry = new ConnectionRegistry;
|
||||
$a = new \stdClass;
|
||||
$b = new \stdClass;
|
||||
|
||||
$registry->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\ReactPhpKernel\Tests\Fixtures;
|
||||
|
||||
use Psr\Log\AbstractLogger;
|
||||
|
||||
/**
|
||||
* A PSR-3 logger that records every log() call so tests can assert the kernel
|
||||
* emitted the lifecycle lines it should.
|
||||
*/
|
||||
final class SpyLogger extends AbstractLogger
|
||||
{
|
||||
/** @var array<int,array{level:mixed,message:string,context:array}> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue