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:
commit
96b3de6a53
|
|
@ -0,0 +1,8 @@
|
||||||
|
/vendor/
|
||||||
|
composer.lock
|
||||||
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue