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:
Blax Software 2026-07-07 13:49:43 +02:00
parent 3c710b5132
commit 9173a8897b
8 changed files with 285 additions and 5 deletions

View File

@ -21,3 +21,9 @@ potentially-shipped, backward-compatible release).
- `Process\SignalHandler` — SIGINT/SIGTERM → graceful shutdown. - `Process\SignalHandler` — SIGINT/SIGTERM → graceful shutdown.
- `Process\ChildReaper` — reaps exited forked children (SIGCHLD + periodic - `Process\ChildReaper` — reaps exited forked children (SIGCHLD + periodic
backstop); the reusable form of laravel-websockets #982. 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).

View File

@ -4,8 +4,8 @@
[![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue?style=flat-square)](https://php.net) [![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) [![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) [![Tests](https://img.shields.io/badge/tests-19%20passing-success?style=flat-square)](#testing)
[![Assertions](https://img.shields.io/badge/assertions-19-blue?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) [![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. 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 - 🛎️ **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) - 🧹 **Child reaping built in** — no zombie processes from forked workers (SIGCHLD + periodic backstop)
- 🔐 **Plain or TLS listeners** — one factory, no framework coupling - 🔐 **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 ## Installation
@ -81,6 +83,7 @@ final class EchoServer implements Server
| `Ipc\SocketPairIpc` | Event-driven parent/child IPC over a Unix socket pair. | | `Ipc\SocketPairIpc` | Event-driven parent/child IPC over a Unix socket pair. |
| `Process\SignalHandler` | SIGINT/SIGTERM → graceful shutdown. | | `Process\SignalHandler` | SIGINT/SIGTERM → graceful shutdown. |
| `Process\ChildReaper` | Reaps exited forked children (SIGCHLD + periodic backstop). | | `Process\ChildReaper` | Reaps exited forked children (SIGCHLD + periodic backstop). |
| `Support\ConnectionRegistry` | Shared id→connection registry for presence/broadcast. |
## Why ## Why

View File

@ -12,6 +12,7 @@
], ],
"require": { "require": {
"php": "^8.1|^8.2|^8.3|^8.4", "php": "^8.1|^8.2|^8.3|^8.4",
"psr/log": "^1.1|^2.0|^3.0",
"react/event-loop": "^1.5", "react/event-loop": "^1.5",
"react/socket": "^1.15", "react/socket": "^1.15",
"react/stream": "^1.3" "react/stream": "^1.3"

View File

@ -7,6 +7,8 @@ namespace Blax\ReactPhpKernel;
use Blax\ReactPhpKernel\Contracts\Server; use Blax\ReactPhpKernel\Contracts\Server;
use Blax\ReactPhpKernel\Process\ChildReaper; use Blax\ReactPhpKernel\Process\ChildReaper;
use Blax\ReactPhpKernel\Process\SignalHandler; use Blax\ReactPhpKernel\Process\SignalHandler;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use React\EventLoop\Loop; use React\EventLoop\Loop;
use React\EventLoop\LoopInterface; use React\EventLoop\LoopInterface;
@ -43,10 +45,13 @@ final class Kernel
private ?ChildReaper $reaper = null; 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. // Default to the global loop so co-located libraries share one reactor.
$this->loop = $loop ?? Loop::get(); $this->loop = $loop ?? Loop::get();
$this->logger = $logger ?? new NullLogger;
} }
public function loop(): LoopInterface public function loop(): LoopInterface
@ -54,6 +59,11 @@ final class Kernel
return $this->loop; return $this->loop;
} }
public function logger(): LoggerInterface
{
return $this->logger;
}
/** Attach a protocol server (WS, WebRTC, ...) to the shared loop. */ /** Attach a protocol server (WS, WebRTC, ...) to the shared loop. */
public function register(Server $server): self public function register(Server $server): self
{ {
@ -99,6 +109,7 @@ final class Kernel
foreach ($this->servers as $server) { foreach ($this->servers as $server) {
$server->boot($this->loop); $server->boot($this->loop);
$this->logger->info('kernel: server booted', ['server' => $server->name()]);
} }
foreach ($this->onBoot as $callback) { foreach ($this->onBoot as $callback) {
@ -107,6 +118,8 @@ final class Kernel
SignalHandler::install($this->loop, fn () => $this->stop()); 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(); $this->loop->run();
} }
@ -117,11 +130,17 @@ final class Kernel
return; return;
} }
$this->logger->info('kernel: shutting down');
foreach ($this->servers as $server) { foreach ($this->servers as $server) {
try { try {
$server->shutdown(); $server->shutdown();
} catch (\Throwable) { } catch (\Throwable $e) {
// A failing teardown must not block the others. // A failing teardown must not block the others.
$this->logger->error('kernel: server shutdown failed', [
'server' => $server->name(),
'error' => $e->getMessage(),
]);
} }
} }

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -7,6 +7,7 @@ namespace Blax\ReactPhpKernel\Tests;
use Blax\ReactPhpKernel\Contracts\Server; use Blax\ReactPhpKernel\Contracts\Server;
use Blax\ReactPhpKernel\Kernel; use Blax\ReactPhpKernel\Kernel;
use Blax\ReactPhpKernel\Tests\Fixtures\RecordingServer; use Blax\ReactPhpKernel\Tests\Fixtures\RecordingServer;
use Blax\ReactPhpKernel\Tests\Fixtures\SpyLogger;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use React\EventLoop\LoopInterface; use React\EventLoop\LoopInterface;
use React\EventLoop\StreamSelectLoop; 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'); $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'));
}
} }