feat: initial reactphp-kernel — shared ReactPHP backbone

Protocol-agnostic backbone extracted from blax-software/laravel-websockets so
WebSockets, WebRTC signaling and future protocols attach to one long-lived
process + loop + IPC + supervision:

- Kernel: loop lifecycle, server registration, signals, graceful stop
- Contracts\Server: attachable protocol server (boot/shutdown)
- Server\SocketServerFactory: plain/TLS listening socket (no framework coupling)
- Ipc\SocketPairIpc: event-driven parent/child IPC (lifted verbatim)
- Process\SignalHandler + ChildReaper (reusable form of laravel-websockets #982)

rel learn-atc #1055 #1051

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-07 13:07:47 +02:00
commit 96b3de6a53
10 changed files with 629 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/vendor/
composer.lock
.phpunit.result.cache
.phpunit.cache/
*.log
.DS_Store
.idea/
.vscode/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Blax Software
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

79
README.md Normal file
View File

@ -0,0 +1,79 @@
# blax-software/reactphp-kernel
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.
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.
```
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
```
## What's in the box
| 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. |
## Usage
```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 React\EventLoop\LoopInterface;
final class MyServer implements Server
{
private $socket = null;
public function name(): string { return 'my-server'; }
public function boot(LoopInterface $loop): void
{
$this->socket = SocketServerFactory::create('0.0.0.0:9000', $loop);
$this->socket->on('connection', function ($conn) { /* ... */ });
}
public function shutdown(): void
{
$this->socket?->close();
}
}
```
## Requirements
- PHP >= 8.1
- `react/event-loop`, `react/socket`, `react/stream`
- `ext-pcntl` (suggested) for signal handling + child reaping
- `ext-sockets` (suggested) for `SocketPairIpc`
## License
MIT © Blax Software

41
composer.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "blax-software/reactphp-kernel",
"description": "Protocol-agnostic ReactPHP backbone: event-loop lifecycle, socket accept, IPC, signal handling and graceful shutdown, shared by blax realtime servers (WebSockets, WebRTC, ...).",
"type": "library",
"license": "MIT",
"keywords": ["reactphp", "event-loop", "kernel", "realtime", "websocket", "webrtc", "ipc", "daemon"],
"authors": [
{
"name": "Blax Software",
"email": "office@blax.at"
}
],
"require": {
"php": ">=8.1",
"react/event-loop": "^1.5",
"react/socket": "^1.15",
"react/stream": "^1.3"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"suggest": {
"ext-pcntl": "Signal handling (graceful shutdown) and reaping of forked children.",
"ext-sockets": "Required for the SocketPairIpc parent<->child channel."
},
"autoload": {
"psr-4": {
"BlaxSoftware\\ReactPhpKernel\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BlaxSoftware\\ReactPhpKernel\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}

34
src/Contracts/Server.php Normal file
View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Contracts;
use React\EventLoop\LoopInterface;
/**
* An attachable server. The Kernel owns ONE long-lived process + event loop;
* every protocol (WebSockets, WebRTC signaling, SIP, ...) implements this so it
* can bind its sockets/streams/timers onto that shared loop instead of spinning
* up its own runtime.
*
* Contract: boot() must NOT block or call $loop->run() itself it only
* registers listeners on the loop the Kernel passes in. The Kernel calls run().
*/
interface Server
{
/** Human-readable identifier, used in logs / diagnostics. */
public function name(): string;
/**
* Attach to the shared loop: bind listening sockets, register read streams,
* periodic timers, etc. Returns immediately (non-blocking).
*/
public function boot(LoopInterface $loop): void;
/**
* Gracefully tear down: stop accepting, close listeners, drain/close open
* connections. Called by the Kernel on SIGINT/SIGTERM before the loop stops.
*/
public function shutdown(): void;
}

154
src/Ipc/SocketPairIpc.php Normal file
View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Ipc;
use React\EventLoop\LoopInterface;
/**
* Event-driven IPC using Unix socket pairs.
*
* Provides instant notification when a child process sends data, eliminating
* polling. Lifted verbatim (bar the namespace) from blax-software/laravel-websockets
* because it is already framework-agnostic it belongs to the shared backbone.
*
* Usage:
* 1. Before fork: $ipc = SocketPairIpc::create($loop);
* 2. After fork in parent: $ipc->setupParent($onData, $onClose);
* 3. After fork in child: $ipc->setupChild(); $ipc->sendToParent($data);
*/
final class SocketPairIpc
{
/**
* Socket pair: [0] = parent side, [1] = child side.
*
* @var resource[]|null
*/
private ?array $sockets = null;
private ?LoopInterface $loop = null;
/** 'parent' | 'child' | null (unconfigured). */
private ?string $role = null;
/** Exported stream resource for ReactPHP. */
private $stream = null;
/** Create a new socket pair for IPC. Must be called BEFORE fork(). */
public static function create(LoopInterface $loop): self
{
$instance = new self();
$instance->loop = $loop;
if (! socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets)) {
throw new \RuntimeException('Failed to create socket pair: ' . socket_strerror(socket_last_error()));
}
$instance->sockets = $sockets;
return $instance;
}
/**
* Setup the parent side after fork: close the child socket and read async.
*
* @param callable $onData function(string $message): void newline-delimited frames
* @param callable|null $onClose function(): void child closed the channel
*/
public function setupParent(callable $onData, ?callable $onClose = null): void
{
if ($this->role !== null) {
throw new \LogicException('IPC already configured');
}
$this->role = 'parent';
socket_close($this->sockets[1]);
socket_set_nonblock($this->sockets[0]);
$this->stream = socket_export_stream($this->sockets[0]);
if ($this->stream === false) {
throw new \RuntimeException('Failed to export socket as stream');
}
$buffer = '';
$this->loop->addReadStream($this->stream, function ($stream) use ($onData, $onClose, &$buffer) {
$data = @fread($stream, 65536);
if ($data === false || $data === '') {
if ($buffer !== '') {
$onData($buffer);
}
$this->loop->removeReadStream($stream);
fclose($stream);
if ($onClose) {
$onClose();
}
return;
}
$buffer .= $data;
while (($pos = strpos($buffer, "\n")) !== false) {
$message = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 1);
if ($message !== '') {
$onData($message);
}
}
});
}
/** Setup the child side after fork: close the parent socket. */
public function setupChild(): void
{
if ($this->role !== null) {
throw new \LogicException('IPC already configured');
}
$this->role = 'child';
socket_close($this->sockets[0]);
}
/**
* Send data from child to parent (newline-delimited; do not embed newlines).
* Call only from the child after setupChild().
*/
public function sendToParent(string $data): bool
{
if ($this->role !== 'child') {
throw new \LogicException('sendToParent can only be called from child');
}
$message = $data . "\n";
$written = socket_write($this->sockets[1], $message, strlen($message));
return $written === strlen($message);
}
/** Close the child socket (call at the end of the child process). */
public function closeChild(): void
{
if ($this->role === 'child' && $this->sockets[1]) {
socket_close($this->sockets[1]);
}
}
/** @return resource */
public function getChildSocket()
{
return $this->sockets[1];
}
/** Whether this system supports socket-pair IPC. */
public static function isSupported(): bool
{
return function_exists('socket_create_pair')
&& function_exists('socket_export_stream');
}
}

147
src/Kernel.php Normal file
View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel;
use BlaxSoftware\ReactPhpKernel\Contracts\Server;
use BlaxSoftware\ReactPhpKernel\Process\ChildReaper;
use BlaxSoftware\ReactPhpKernel\Process\SignalHandler;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
/**
* The shared ReactPHP backbone.
*
* Extracted from blax-software/laravel-websockets so that WebSockets, WebRTC
* signaling, and future protocols all attach to ONE long-lived process, one
* event loop, one IPC primitive and one supervision/signal story instead of each
* re-implementing the plumbing.
*
* Usage:
* (new Kernel())
* ->reapChildren() // optional: auto-reap forked children
* ->register($webSocketServer) // any Contracts\Server
* ->register($webRtcSignaling)
* ->onBoot(fn (Kernel $k) => ...)
* ->run(); // boots servers, installs signals, runs the loop
*/
final class Kernel
{
private LoopInterface $loop;
/** @var Server[] */
private array $servers = [];
/** @var array<callable> */
private array $onBoot = [];
/** @var array<callable> */
private array $onShutdown = [];
private bool $running = false;
private ?ChildReaper $reaper = null;
public function __construct(?LoopInterface $loop = null)
{
// Default to the global loop so co-located libraries share one reactor.
$this->loop = $loop ?? Loop::get();
}
public function loop(): LoopInterface
{
return $this->loop;
}
/** Attach a protocol server (WS, WebRTC, ...) to the shared loop. */
public function register(Server $server): self
{
$this->servers[] = $server;
return $this;
}
public function onBoot(callable $callback): self
{
$this->onBoot[] = $callback;
return $this;
}
public function onShutdown(callable $callback): self
{
$this->onShutdown[] = $callback;
return $this;
}
/**
* Automatically reap exited forked children so they never accumulate as
* zombies (the concern behind laravel-websockets #982). Opt-in because not
* every deployment forks.
*/
public function reapChildren(float $interval = 1.0): self
{
$this->reaper = new ChildReaper($this->loop);
$this->reaper->install($interval);
return $this;
}
/** Boot every registered server, install signal handlers, run the loop (blocks). */
public function run(): void
{
if ($this->running) {
return;
}
$this->running = true;
foreach ($this->servers as $server) {
$server->boot($this->loop);
}
foreach ($this->onBoot as $callback) {
$callback($this);
}
SignalHandler::install($this->loop, fn () => $this->stop());
$this->loop->run();
}
/** Graceful shutdown: tear down servers, run shutdown hooks, stop the loop. */
public function stop(): void
{
if (! $this->running) {
return;
}
foreach ($this->servers as $server) {
try {
$server->shutdown();
} catch (\Throwable) {
// A failing teardown must not block the others.
}
}
foreach ($this->onShutdown as $callback) {
try {
$callback($this);
} catch (\Throwable) {
// ignore
}
}
$this->reaper?->reapAll();
$this->reaper?->uninstall();
$this->running = false;
$this->loop->stop();
}
public function isRunning(): bool
{
return $this->running;
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Process;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
/**
* Reaps exited forked children so they don't pile up as zombies.
*
* This is the reusable form of the fix behind laravel-websockets #982: reap on
* SIGCHLD for immediacy, plus a periodic backstop because SIGCHLD can coalesce
* (several children exiting near-simultaneously deliver a single signal).
*/
final class ChildReaper
{
private LoopInterface $loop;
private ?TimerInterface $timer = null;
/** @var callable|null */
private $signalHandler = null;
public function __construct(LoopInterface $loop)
{
$this->loop = $loop;
}
public function install(float $interval = 1.0): void
{
if (! \function_exists('pcntl_waitpid')) {
return; // No pcntl — nothing to reap (this build cannot fork).
}
if (\defined('SIGCHLD')) {
$this->signalHandler = fn () => $this->reapAll();
try {
$this->loop->addSignal(SIGCHLD, $this->signalHandler);
} catch (\BadMethodCallException) {
$this->signalHandler = null; // loop has no signal support
}
}
$this->timer = $this->loop->addPeriodicTimer($interval, fn () => $this->reapAll());
}
/** Reap every child that has already exited. Returns how many were reaped. */
public function reapAll(): int
{
if (! \function_exists('pcntl_waitpid')) {
return 0;
}
$reaped = 0;
$status = 0;
while (($pid = \pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
$reaped++;
}
return $reaped;
}
public function uninstall(): void
{
if ($this->timer !== null) {
$this->loop->cancelTimer($this->timer);
$this->timer = null;
}
if ($this->signalHandler !== null && \defined('SIGCHLD')) {
try {
$this->loop->removeSignal(SIGCHLD, $this->signalHandler);
} catch (\Throwable) {
// ignore
}
$this->signalHandler = null;
}
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Process;
use React\EventLoop\LoopInterface;
/**
* Installs SIGINT/SIGTERM handlers that trigger a graceful shutdown callback.
* No-op when the runtime has no signal support (SIGINT undefined without
* ext-pcntl), so the kernel still runs (just without Ctrl-C graceful shutdown).
*/
final class SignalHandler
{
public static function install(LoopInterface $loop, callable $onShutdown): void
{
if (! \defined('SIGINT') || ! \defined('SIGTERM')) {
return; // ext-pcntl (or ext-ev) not available — skip.
}
$handler = static function () use ($onShutdown): void {
$onShutdown();
};
try {
$loop->addSignal(SIGINT, $handler);
$loop->addSignal(SIGTERM, $handler);
} catch (\BadMethodCallException) {
// The active loop provides no signal support; ignore.
}
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\ReactPhpKernel\Server;
use React\EventLoop\LoopInterface;
use React\Socket\SocketServer;
/**
* Framework-agnostic listening-socket factory (plain TCP or TLS).
*
* Replaces laravel-websockets' ServerFactory::createServer, minus the Laravel
* config() coupling and the Ratchet HTTP wiring a protocol server layers its
* own connection handling on top of the returned SocketServer.
*/
final class SocketServerFactory
{
/**
* @param string $address host:port, e.g. "0.0.0.0:8080"
* @param array<string,mixed> $tls ReactPHP TLS context (e.g. ['local_cert' => ..., 'local_pk' => ...]); [] = plain TCP
*/
public static function create(string $address, LoopInterface $loop, array $tls = []): SocketServer
{
if ($tls !== []) {
return new SocketServer("tls://{$address}", ['tls' => $tls], $loop);
}
return new SocketServer($address, [], $loop);
}
}