From 3c710b513223c515db57dcdcb774052a2b44c29a Mon Sep 17 00:00:00 2001 From: Blax Software Date: Tue, 7 Jul 2026 13:24:36 +0200 Subject: [PATCH] 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 --- .gitattributes | 7 ++ CHANGELOG.md | 23 +++++ README.md | 136 ++++++++++++++++++----------- composer.json | 15 +++- phpunit.xml.dist | 21 +++++ pint.json | 4 + src/Contracts/Server.php | 2 +- src/Ipc/SocketPairIpc.php | 10 +-- src/Kernel.php | 8 +- src/Process/ChildReaper.php | 2 +- src/Process/SignalHandler.php | 2 +- src/Server/SocketServerFactory.php | 2 +- test.sh | 11 +++ tests/ChildReaperTest.php | 47 ++++++++++ tests/Fixtures/RecordingServer.php | 43 +++++++++ tests/KernelTest.php | 89 +++++++++++++++++++ tests/SocketPairIpcTest.php | 49 +++++++++++ tests/SocketServerFactoryTest.php | 35 ++++++++ 18 files changed, 440 insertions(+), 66 deletions(-) create mode 100644 .gitattributes create mode 100644 CHANGELOG.md create mode 100644 phpunit.xml.dist create mode 100644 pint.json create mode 100644 test.sh create mode 100644 tests/ChildReaperTest.php create mode 100644 tests/Fixtures/RecordingServer.php create mode 100644 tests/KernelTest.php create mode 100644 tests/SocketPairIpcTest.php create mode 100644 tests/SocketServerFactoryTest.php 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 +[![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 + + + + + + Star History Chart + + 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(); + } +}