diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b8d1cdd
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..88d8968
--- /dev/null
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 937d2e2..6e2fb8b 100644
--- a/README.md
+++ b/README.md
@@ -1,63 +1,59 @@
-# blax-software/reactphp-kernel
+[](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.
+[](https://php.net)
+[](https://reactphp.org)
+[](#testing)
+[](#testing)
+[](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
+
+
+
+
+
+
+
+
diff --git a/composer.json b/composer.json
index 4199004..c2cbf13 100644
--- a/composer.json
+++ b/composer.json
@@ -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
},
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..c558858
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,21 @@
+
+
+
+
+ tests
+
+
+
+
+ ./src
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..71e1065
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,4 @@
+{
+ "preset": "laravel",
+ "rules": {}
+}
diff --git a/src/Contracts/Server.php b/src/Contracts/Server.php
index 145adfd..f493fdf 100644
--- a/src/Contracts/Server.php
+++ b/src/Contracts/Server.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace BlaxSoftware\ReactPhpKernel\Contracts;
+namespace Blax\ReactPhpKernel\Contracts;
use React\EventLoop\LoopInterface;
diff --git a/src/Ipc/SocketPairIpc.php b/src/Ipc/SocketPairIpc.php
index 5cb8a65..6c1c328 100644
--- a/src/Ipc/SocketPairIpc.php
+++ b/src/Ipc/SocketPairIpc.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace BlaxSoftware\ReactPhpKernel\Ipc;
+namespace Blax\ReactPhpKernel\Ipc;
use React\EventLoop\LoopInterface;
@@ -38,11 +38,11 @@ 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)) {
- throw new \RuntimeException('Failed to create socket pair: ' . socket_strerror(socket_last_error()));
+ throw new \RuntimeException('Failed to create socket pair: '.socket_strerror(socket_last_error()));
}
$instance->sockets = $sockets;
@@ -53,7 +53,7 @@ final class SocketPairIpc
/**
* 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 $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
@@ -125,7 +125,7 @@ final class SocketPairIpc
throw new \LogicException('sendToParent can only be called from child');
}
- $message = $data . "\n";
+ $message = $data."\n";
$written = socket_write($this->sockets[1], $message, strlen($message));
return $written === strlen($message);
diff --git a/src/Kernel.php b/src/Kernel.php
index 3973098..7dc834b 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -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;
diff --git a/src/Process/ChildReaper.php b/src/Process/ChildReaper.php
index 0648ef3..1f677ab 100644
--- a/src/Process/ChildReaper.php
+++ b/src/Process/ChildReaper.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace BlaxSoftware\ReactPhpKernel\Process;
+namespace Blax\ReactPhpKernel\Process;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
diff --git a/src/Process/SignalHandler.php b/src/Process/SignalHandler.php
index e7b653c..9fc414a 100644
--- a/src/Process/SignalHandler.php
+++ b/src/Process/SignalHandler.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace BlaxSoftware\ReactPhpKernel\Process;
+namespace Blax\ReactPhpKernel\Process;
use React\EventLoop\LoopInterface;
diff --git a/src/Server/SocketServerFactory.php b/src/Server/SocketServerFactory.php
index 69b62f3..df877c3 100644
--- a/src/Server/SocketServerFactory.php
+++ b/src/Server/SocketServerFactory.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace BlaxSoftware\ReactPhpKernel\Server;
+namespace Blax\ReactPhpKernel\Server;
use React\EventLoop\LoopInterface;
use React\Socket\SocketServer;
diff --git a/test.sh b/test.sh
new file mode 100644
index 0000000..d4d1abc
--- /dev/null
+++ b/test.sh
@@ -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 "$@"
diff --git a/tests/ChildReaperTest.php b/tests/ChildReaperTest.php
new file mode 100644
index 0000000..c7d819b
--- /dev/null
+++ b/tests/ChildReaperTest.php
@@ -0,0 +1,47 @@
+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());
+ }
+}
diff --git a/tests/Fixtures/RecordingServer.php b/tests/Fixtures/RecordingServer.php
new file mode 100644
index 0000000..5b55af8
--- /dev/null
+++ b/tests/Fixtures/RecordingServer.php
@@ -0,0 +1,43 @@
+id;
+ }
+
+ public function boot(LoopInterface $loop): void
+ {
+ $this->booted = true;
+
+ if ($this->onBoot !== null) {
+ ($this->onBoot)($loop);
+ }
+ }
+
+ public function shutdown(): void
+ {
+ $this->wasShutDown = true;
+ }
+}
diff --git a/tests/KernelTest.php b/tests/KernelTest.php
new file mode 100644
index 0000000..082f742
--- /dev/null
+++ b/tests/KernelTest.php
@@ -0,0 +1,89 @@
+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');
+ }
+}
diff --git a/tests/SocketPairIpcTest.php b/tests/SocketPairIpcTest.php
new file mode 100644
index 0000000..2100599
--- /dev/null
+++ b/tests/SocketPairIpcTest.php
@@ -0,0 +1,49 @@
+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');
+ }
+}
diff --git a/tests/SocketServerFactoryTest.php b/tests/SocketServerFactoryTest.php
new file mode 100644
index 0000000..b801052
--- /dev/null
+++ b/tests/SocketServerFactoryTest.php
@@ -0,0 +1,35 @@
+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();
+ }
+}