chore: finish package to Blax standard (namespace, README, tests, scaffolding)

- Rename namespace BlaxSoftware\ReactPhpKernel -> Blax\ReactPhpKernel (house
  standard; the BlaxSoftware\LaravelWebSockets form is grandfathered only there)
- README rewritten to the Blax OSS package principles: OSS banner, title+badges,
  emoji feature list, quick start, what's-in-the-box, testing, star history
- Add PHPUnit suite (13 tests / 19 assertions, green) + phpunit.xml.dist
- Add pint.json (laravel preset, applied), composer scripts (test/lint),
  .gitattributes export-ignores, test.sh (nix), CHANGELOG
- composer.json: Blax psr-4, PHP ^8.1-^8.4, dev deps

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:24:36 +02:00
parent 96b3de6a53
commit 3c710b5132
18 changed files with 440 additions and 66 deletions

7
.gitattributes vendored Normal file
View File

@ -0,0 +1,7 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/phpunit.xml.dist export-ignore
/pint.json export-ignore
/test.sh export-ignore
/tests export-ignore
/.github export-ignore

23
CHANGELOG.md Normal file
View File

@ -0,0 +1,23 @@
# Changelog
All notable changes to `blax-software/reactphp-kernel` are documented here. This
project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
Blax Software's backward-compatibility guarantee (every push to `master` is a
potentially-shipped, backward-compatible release).
## [Unreleased]
### Added
- `Kernel` — owns the ReactPHP loop, registers `Contracts\Server`s, installs
signal handlers, `run()` and graceful `stop()`.
- `Contracts\Server` — an attachable protocol server (`boot(LoopInterface)` +
`shutdown()`).
- `Server\SocketServerFactory` — plain-TCP or TLS listening socket, no framework
coupling.
- `Ipc\SocketPairIpc` — event-driven parent/child IPC over a Unix socket pair
(lifted from `blax-software/laravel-websockets`; it was already
framework-agnostic).
- `Process\SignalHandler` — SIGINT/SIGTERM → graceful shutdown.
- `Process\ChildReaper` — reaps exited forked children (SIGCHLD + periodic
backstop); the reusable form of laravel-websockets #982.

136
README.md
View File

@ -1,63 +1,59 @@
# blax-software/reactphp-kernel
[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software)
A small, protocol-agnostic **ReactPHP backbone**: one long-lived process, one
event loop, one IPC primitive, and one signal/graceful-shutdown story that many
protocol servers attach to.
# ReactPHP Kernel
It is the shared foundation extracted from
[`blax-software/laravel-websockets`](https://git.blax.at/blax-software/laravel-websockets)
so that WebSockets, **WebRTC signaling/media**
([`blax-software/laravel-webrtc`](https://git.blax.at/blax-software/laravel-webrtc)),
and future protocols (SIP, ...) do not each re-implement the plumbing.
[![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)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
```
blax-software/reactphp-kernel <- this package (the backbone)
├─ laravel-websockets <- WS on the kernel
└─ laravel-webrtc <- WebRTC signaling on the kernel
└─ Rust/str0m media core <- via ext-php-rs
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.
## Features
- 🧩 **One loop, many protocols** — WebSockets, WebRTC signaling, SIP and more share a single `Kernel` instead of each spinning up its own runtime
- 🔌 **Tiny `Server` contract** — implement `boot(LoopInterface)` + `shutdown()` and you're on the shared loop
- 🧵 **Socket-pair IPC** — event-driven parent↔child messaging with zero polling
- 🛎️ **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`
## Installation
```bash
composer require blax-software/reactphp-kernel
```
## What's in the box
Requires PHP 8.1+, plus `ext-pcntl` (signals + child reaping) and `ext-sockets` (IPC) for the full feature set.
| Piece | Responsibility |
|---|---|
| `Kernel` | Owns the loop; registers `Server`s; installs signals; `run()` / graceful `stop()`. |
| `Contracts\Server` | An attachable protocol server: `boot(LoopInterface)` (non-blocking) + `shutdown()`. |
| `Server\SocketServerFactory` | Plain-TCP or TLS listening socket (no framework coupling). |
| `Ipc\SocketPairIpc` | Event-driven parent/child IPC over a Unix socket pair (no polling). |
| `Process\SignalHandler` | SIGINT/SIGTERM → graceful shutdown (no-op without ext-pcntl). |
| `Process\ChildReaper` | Reaps exited forked children (SIGCHLD + periodic backstop) — the reusable form of laravel-websockets #982. |
## Quick Start
## Usage
Implement a `Server`, register it, `run()`:
```php
use BlaxSoftware\ReactPhpKernel\Kernel;
(new Kernel())
->reapChildren() // optional: auto-reap forked children
->register($webSocketServer) // any Contracts\Server
->register($webRtcSignaling)
->onBoot(fn (Kernel $k) => /* warm caches, announce ready, ... */ null)
->run(); // boots servers, installs signals, runs the loop (blocks)
```
Implement `Contracts\Server` to plug in a protocol:
```php
use BlaxSoftware\ReactPhpKernel\Contracts\Server;
use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory;
use Blax\ReactPhpKernel\Kernel;
use Blax\ReactPhpKernel\Contracts\Server;
use Blax\ReactPhpKernel\Server\SocketServerFactory;
use React\EventLoop\LoopInterface;
use React\Socket\ConnectionInterface;
final class MyServer implements Server
final class EchoServer implements Server
{
private $socket = null;
public function name(): string { return 'my-server'; }
public function name(): string
{
return 'echo';
}
public function boot(LoopInterface $loop): void
{
$this->socket = SocketServerFactory::create('0.0.0.0:9000', $loop);
$this->socket->on('connection', function ($conn) { /* ... */ });
$this->socket = SocketServerFactory::create('0.0.0.0:9001', $loop);
$this->socket->on('connection', function (ConnectionInterface $conn) {
$conn->on('data', fn ($data) => $conn->write($data)); // echo it back
});
}
public function shutdown(): void
@ -65,15 +61,57 @@ final class MyServer implements Server
$this->socket?->close();
}
}
(new Kernel())
->reapChildren() // auto-reap forked children
->register(new EchoServer())
->onBoot(fn (Kernel $k) => fwrite(STDERR, "ready\n"))
->run(); // boots servers, installs signals, runs the loop (blocks)
```
## Requirements
`Ctrl-C` (SIGINT) or `SIGTERM` triggers a graceful `stop()`: every registered server's `shutdown()` runs, then the loop stops.
- PHP >= 8.1
- `react/event-loop`, `react/socket`, `react/stream`
- `ext-pcntl` (suggested) for signal handling + child reaping
- `ext-sockets` (suggested) for `SocketPairIpc`
## What's in the box
| Class | Responsibility |
|---|---|
| `Kernel` | Owns the loop; `register()` servers; installs signals; `run()` / graceful `stop()`. |
| `Contracts\Server` | An attachable protocol server: `boot(LoopInterface)` (non-blocking) + `shutdown()`. |
| `Server\SocketServerFactory` | Plain-TCP or TLS listening socket. |
| `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). |
## Why
It's the shared foundation extracted from [`blax-software/laravel-websockets`](https://github.com/blax-software/laravel-websockets) so that WebSockets and [`blax-software/laravel-webrtc`](https://github.com/blax-software/laravel-webrtc) (WebRTC signaling + a Rust/str0m media core) don't each re-implement the loop, IPC, signals and supervision. A new protocol gets the plumbing for free.
```
reactphp-kernel this package — the backbone
├─ laravel-websockets WS on the kernel
└─ laravel-webrtc WebRTC signaling on the kernel
└─ Rust/str0m core via ext-php-rs
```
## Testing
```bash
composer install
composer test
```
The suite runs against a real ReactPHP loop, with no external services.
## License
MIT © Blax Software
MIT. See [LICENSE](LICENSE).
## Star History
<a href="https://www.star-history.com/?repos=blax-software%2Freactphp-kernel&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/reactphp-kernel&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/reactphp-kernel&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/reactphp-kernel&type=date&legend=top-left" />
</picture>
</a>

View File

@ -11,13 +11,14 @@
}
],
"require": {
"php": ">=8.1",
"php": "^8.1|^8.2|^8.3|^8.4",
"react/event-loop": "^1.5",
"react/socket": "^1.15",
"react/stream": "^1.3"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^10.5|^11.0",
"laravel/pint": "^1.13"
},
"suggest": {
"ext-pcntl": "Signal handling (graceful shutdown) and reaping of forked children.",
@ -25,14 +26,20 @@
},
"autoload": {
"psr-4": {
"BlaxSoftware\\ReactPhpKernel\\": "src/"
"Blax\\ReactPhpKernel\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BlaxSoftware\\ReactPhpKernel\\Tests\\": "tests/"
"Blax\\ReactPhpKernel\\Tests\\": "tests/"
}
},
"scripts": {
"test": "vendor/bin/phpunit",
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
"lint": "vendor/bin/pint",
"lint-test": "vendor/bin/pint --test"
},
"config": {
"sort-packages": true
},

21
phpunit.xml.dist Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="ReactPHP Kernel Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

4
pint.json Normal file
View File

@ -0,0 +1,4 @@
{
"preset": "laravel",
"rules": {}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Contracts;
namespace Blax\ReactPhpKernel\Contracts;
use React\EventLoop\LoopInterface;

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Ipc;
namespace Blax\ReactPhpKernel\Ipc;
use React\EventLoop\LoopInterface;
@ -38,7 +38,7 @@ final class SocketPairIpc
/** Create a new socket pair for IPC. Must be called BEFORE fork(). */
public static function create(LoopInterface $loop): self
{
$instance = new self();
$instance = new self;
$instance->loop = $loop;
if (! socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets)) {

View File

@ -2,11 +2,11 @@
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel;
namespace Blax\ReactPhpKernel;
use BlaxSoftware\ReactPhpKernel\Contracts\Server;
use BlaxSoftware\ReactPhpKernel\Process\ChildReaper;
use BlaxSoftware\ReactPhpKernel\Process\SignalHandler;
use Blax\ReactPhpKernel\Contracts\Server;
use Blax\ReactPhpKernel\Process\ChildReaper;
use Blax\ReactPhpKernel\Process\SignalHandler;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Process;
namespace Blax\ReactPhpKernel\Process;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Process;
namespace Blax\ReactPhpKernel\Process;
use React\EventLoop\LoopInterface;

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Server;
namespace Blax\ReactPhpKernel\Server;
use React\EventLoop\LoopInterface;
use React\Socket\SocketServer;

11
test.sh Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p php83 php83Extensions.sockets php83Extensions.pcntl php83Extensions.mbstring php83Extensions.xml php83Extensions.xmlwriter php83Extensions.tokenizer
# Test script for NixOS — runs PHPUnit with the extensions the kernel needs
# (ext-sockets for SocketPairIpc, ext-pcntl for signals/child reaping).
echo "Running ReactPHP Kernel tests..."
echo "PHP version: $(php --version | head -n 1)"
echo ""
vendor/bin/phpunit "$@"

47
tests/ChildReaperTest.php Normal file
View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Blax\ReactPhpKernel\Tests;
use Blax\ReactPhpKernel\Process\ChildReaper;
use PHPUnit\Framework\TestCase;
use React\EventLoop\StreamSelectLoop;
final class ChildReaperTest extends TestCase
{
public function test_reap_all_returns_zero_when_there_are_no_children(): void
{
if (! function_exists('pcntl_waitpid')) {
$this->markTestSkipped('ext-pcntl not available');
}
$reaper = new ChildReaper(new StreamSelectLoop);
$this->assertSame(0, $reaper->reapAll());
}
public function test_install_and_uninstall_do_not_throw(): void
{
$loop = new StreamSelectLoop;
$reaper = new ChildReaper($loop);
$reaper->install(0.1);
$reaper->uninstall();
// Reaching here without an exception is the assertion.
$this->assertTrue(true);
}
public function test_reap_all_is_safe_to_call_repeatedly(): void
{
if (! function_exists('pcntl_waitpid')) {
$this->markTestSkipped('ext-pcntl not available');
}
$reaper = new ChildReaper(new StreamSelectLoop);
$this->assertSame(0, $reaper->reapAll());
$this->assertSame(0, $reaper->reapAll());
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Blax\ReactPhpKernel\Tests\Fixtures;
use Blax\ReactPhpKernel\Contracts\Server;
use React\EventLoop\LoopInterface;
/**
* A test-double protocol server that records whether it was booted / shut down
* and lets a test hook onto the boot moment (e.g. to schedule the kernel stop).
*/
final class RecordingServer implements Server
{
public bool $booted = false;
public bool $wasShutDown = false;
/** @var callable|null function(LoopInterface): void */
public $onBoot = null;
public function __construct(private string $id = 'recording') {}
public function name(): string
{
return $this->id;
}
public function boot(LoopInterface $loop): void
{
$this->booted = true;
if ($this->onBoot !== null) {
($this->onBoot)($loop);
}
}
public function shutdown(): void
{
$this->wasShutDown = true;
}
}

89
tests/KernelTest.php Normal file
View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Blax\ReactPhpKernel\Tests;
use Blax\ReactPhpKernel\Contracts\Server;
use Blax\ReactPhpKernel\Kernel;
use Blax\ReactPhpKernel\Tests\Fixtures\RecordingServer;
use PHPUnit\Framework\TestCase;
use React\EventLoop\LoopInterface;
use React\EventLoop\StreamSelectLoop;
final class KernelTest extends TestCase
{
public function test_uses_the_injected_loop(): void
{
$loop = new StreamSelectLoop;
$this->assertSame($loop, (new Kernel($loop))->loop());
}
public function test_boots_and_shuts_down_registered_servers_with_hooks(): void
{
$loop = new StreamSelectLoop;
$kernel = new Kernel($loop);
$server = new RecordingServer;
// Stop the kernel on the next tick so run() returns instead of blocking.
$server->onBoot = fn (LoopInterface $l) => $l->futureTick(fn () => $kernel->stop());
$bootHook = false;
$shutdownHook = false;
$kernel
->register($server)
->onBoot(function () use (&$bootHook) {
$bootHook = true;
})
->onShutdown(function () use (&$shutdownHook) {
$shutdownHook = true;
})
->run();
$this->assertTrue($server->booted, 'server should have been booted');
$this->assertTrue($server->wasShutDown, 'server should have been shut down');
$this->assertTrue($bootHook, 'onBoot hook should have fired');
$this->assertTrue($shutdownHook, 'onShutdown hook should have fired');
$this->assertFalse($kernel->isRunning());
}
public function test_stop_is_idempotent_and_safe_before_run(): void
{
$kernel = new Kernel(new StreamSelectLoop);
$kernel->stop(); // never ran — must be a harmless no-op
$kernel->stop();
$this->assertFalse($kernel->isRunning());
}
public function test_a_failing_server_shutdown_does_not_block_the_others(): void
{
$loop = new StreamSelectLoop;
$kernel = new Kernel($loop);
$throwing = new class implements Server
{
public function name(): string
{
return 'throwing';
}
public function boot(LoopInterface $loop): void {}
public function shutdown(): void
{
throw new \RuntimeException('teardown failed');
}
};
$good = new RecordingServer;
$good->onBoot = fn (LoopInterface $l) => $l->futureTick(fn () => $kernel->stop());
$kernel->register($throwing)->register($good)->run();
$this->assertTrue($good->wasShutDown, 'a throwing teardown must not stop the others');
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Blax\ReactPhpKernel\Tests;
use Blax\ReactPhpKernel\Ipc\SocketPairIpc;
use PHPUnit\Framework\TestCase;
use React\EventLoop\StreamSelectLoop;
final class SocketPairIpcTest extends TestCase
{
protected function setUp(): void
{
if (! SocketPairIpc::isSupported()) {
$this->markTestSkipped('ext-sockets (socket_create_pair / socket_export_stream) not available');
}
}
public function test_reports_support_on_this_platform(): void
{
$this->assertTrue(SocketPairIpc::isSupported());
}
public function test_create_returns_an_unconfigured_instance(): void
{
$ipc = SocketPairIpc::create(new StreamSelectLoop);
$this->assertInstanceOf(SocketPairIpc::class, $ipc);
}
public function test_double_configuration_is_rejected(): void
{
$ipc = SocketPairIpc::create(new StreamSelectLoop);
$ipc->setupChild();
$this->expectException(\LogicException::class);
$ipc->setupChild();
}
public function test_send_from_a_non_child_is_rejected(): void
{
$ipc = SocketPairIpc::create(new StreamSelectLoop);
// Not configured as child -> sendToParent must refuse.
$this->expectException(\LogicException::class);
$ipc->sendToParent('nope');
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Blax\ReactPhpKernel\Tests;
use Blax\ReactPhpKernel\Server\SocketServerFactory;
use PHPUnit\Framework\TestCase;
use React\EventLoop\StreamSelectLoop;
use React\Socket\SocketServer;
final class SocketServerFactoryTest extends TestCase
{
public function test_creates_a_plain_tcp_listener(): void
{
$server = SocketServerFactory::create('127.0.0.1:0', new StreamSelectLoop);
$this->assertInstanceOf(SocketServer::class, $server);
$this->assertStringStartsWith('tcp://127.0.0.1:', (string) $server->getAddress());
$server->close();
}
public function test_binds_an_ephemeral_port_that_can_be_read_back(): void
{
$server = SocketServerFactory::create('127.0.0.1:0', new StreamSelectLoop);
$address = (string) $server->getAddress();
$port = (int) parse_url($address, PHP_URL_PORT);
$this->assertGreaterThan(0, $port, 'an ephemeral port should have been assigned');
$server->close();
}
}