RI websocket performance

This commit is contained in:
Fabian @ Blax Software 2026-01-24 14:42:35 +01:00
parent 5b8a2a8112
commit 2849f0fe5f
6 changed files with 471 additions and 16 deletions

View File

@ -251,11 +251,6 @@ class StartServer extends Command
$restartData = $this->getLastRestartData(); $restartData = $this->getLastRestartData();
$currentRestart = $restartData['time'] ?? null; $currentRestart = $restartData['time'] ?? null;
\Log::channel('websocket')->debug('Restart timer tick', [
'last_restart' => $this->lastRestart,
'current_restart' => $currentRestart,
]);
// Only trigger restart if lastRestart was set and a new restart signal was received // Only trigger restart if lastRestart was set and a new restart signal was received
if ($this->lastRestart !== null && $currentRestart !== null && $currentRestart !== $this->lastRestart) { if ($this->lastRestart !== null && $currentRestart !== null && $currentRestart !== $this->lastRestart) {
$this->restartSoftShutdown = $restartData['soft'] ?? false; $this->restartSoftShutdown = $restartData['soft'] ?? false;
@ -402,10 +397,88 @@ class StartServer extends Command
$this->buildServer(); $this->buildServer();
\Log::channel('websocket')->debug('Server instance built, starting event loop...'); \Log::channel('websocket')->debug('Server instance built, starting event loop...');
// Warmup: pre-heat hot code paths to trigger JIT compilation
// This reduces first-request latency from ~15ms to ~3ms
$this->warmupCodePaths();
$this->server->run(); $this->server->run();
\Log::channel('websocket')->debug('Event loop stopped, server shutdown complete'); \Log::channel('websocket')->debug('Event loop stopped, server shutdown complete');
} }
/**
* Pre-heat frequently used code paths to trigger JIT compilation
* and load code into CPU cache before real requests arrive.
*
* @return void
*/
protected function warmupCodePaths(): void
{
// Warmup JSON encode/decode (used in every message)
$testPayload = '{"event":"pusher.ping","data":{}}';
for ($i = 0; $i < 10; $i++) {
$decoded = json_decode($testPayload, true);
json_encode($decoded);
strpos($testPayload, 'ping');
}
// Warmup time() function
time();
// Warmup string operations
$pong = '{"event":"pusher.pong"}';
strlen($pong);
// Preload WebSocket controller classes to avoid autoloading latency on first request
$this->preloadControllers();
// Warmup database connection (will be re-established in child, but PDO classes get loaded)
try {
\DB::connection()->getPdo();
} catch (\Throwable $e) {
// Ignore connection errors, we just want to load classes
}
\Log::channel('websocket')->debug('Code paths warmed up (JIT pre-compiled)');
}
/**
* Preload all WebSocket controller classes to avoid autoloading latency
* on first request. This triggers class loading and JIT compilation.
*
* Uses ControllerResolver which:
* - Scans app/Websocket/Controllers/ recursively (including subfolders)
* - Caches discovered controllers in memory for O(1) lookup
* - Supports folder-based controllers (e.g., Admin/UserController)
*
* @return void
*/
protected function preloadControllers(): void
{
// Use ControllerResolver to scan and cache all controllers
\BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver::preload();
$stats = \BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver::getStats();
\Log::channel('websocket')->debug('Controllers preloaded', [
'available' => $stats['available'],
]);
// Preload commonly used classes
$commonClasses = [
\BlaxSoftware\LaravelWebSockets\Websocket\Controller::class,
\BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver::class,
\BlaxSoftware\LaravelWebSockets\Websocket\MockConnection::class,
\BlaxSoftware\LaravelWebSockets\Websocket\MockConnectionSocketPair::class,
\BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc::class,
\BlaxSoftware\LaravelWebSockets\Cache\IpcCache::class,
];
foreach ($commonClasses as $class) {
if (class_exists($class, true)) {
new \ReflectionClass($class);
}
}
}
/** /**
* Log comprehensive server startup information * Log comprehensive server startup information
*/ */

View File

@ -70,17 +70,11 @@ class Controller
} }
try { try {
$contr = (strpos($event[0], '-') >= 0) $eventPrefix = $event[0];
? implode('', array_map(fn($item) => ucfirst($item), explode('-', $event[0])))
: ucfirst($event[0]);
$vendorcontroller = '\\BlaxSoftware\\LaravelWebSockets\\Websocket\\Controllers\\' . $contr . 'Controller';
$appcontroller = '\\App\\Websocket\\Controllers\\' . $contr . 'Controller';
$method = static::without_uniquifyer($event[1]); $method = static::without_uniquifyer($event[1]);
$controllerClass = class_exists($appcontroller) // Use cached controller resolver for fast lookup
? $appcontroller $controllerClass = ControllerResolver::resolve($eventPrefix);
: (class_exists($vendorcontroller) ? $vendorcontroller : null);
if (! $controllerClass) { if (! $controllerClass) {
return self::send_error($connection, $message, 'Event could not be associated'); return self::send_error($connection, $message, 'Event could not be associated');

View File

@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Websocket;
/**
* Resolves WebSocket controller classes with caching and fuzzy folder matching.
*
* Supports:
* - Flat controllers: `app.method` `AppController`
* - Kebab-case: `admin-user.method` `AdminUserController`
* - Folder structure: `admin-user.method` `Admin/UserController` (fuzzy)
* - Dynamic discovery: new controllers are found and cached at runtime
*/
class ControllerResolver
{
/**
* In-memory cache of event prefix controller class mappings
* This persists for the lifetime of the WebSocket server process
*
* @var array<string, string|null>
*/
private static array $controllerCache = [];
/**
* Pre-scanned controller paths for fast lookup
* Maps lowercase class name actual class name with namespace
*
* @var array<string, string>
*/
private static array $availableControllers = [];
/**
* Whether controllers have been pre-scanned
*/
private static bool $scanned = false;
/**
* App controller namespace
*/
private const APP_NAMESPACE = '\\App\\Websocket\\Controllers\\';
/**
* Package controller namespace
*/
private const VENDOR_NAMESPACE = '\\BlaxSoftware\\LaravelWebSockets\\Websocket\\Controllers\\';
/**
* Resolve controller class for an event prefix
*
* @param string $eventPrefix The event prefix (e.g., 'app', 'admin-user', 'admin-user-settings')
* @return string|null The fully qualified controller class name, or null if not found
*/
public static function resolve(string $eventPrefix): ?string
{
// Check cache first (O(1) lookup)
if (array_key_exists($eventPrefix, self::$controllerCache)) {
return self::$controllerCache[$eventPrefix];
}
// Ensure controllers are scanned
if (!self::$scanned) {
self::scanControllers();
}
// Try to find the controller
$controllerClass = self::findController($eventPrefix);
// Cache the result (even if null, to avoid repeated lookups)
self::$controllerCache[$eventPrefix] = $controllerClass;
return $controllerClass;
}
/**
* Find controller using multiple strategies
*/
private static function findController(string $eventPrefix): ?string
{
// Strategy 1: Direct match (e.g., 'app' → 'AppController')
$directName = self::kebabToPascal($eventPrefix) . 'Controller';
if ($class = self::findInAvailable($directName)) {
return $class;
}
// Strategy 2: Folder structure (e.g., 'admin-user' → 'Admin/UserController')
$parts = explode('-', $eventPrefix);
if (count($parts) > 1) {
// Try progressively deeper folder structures
// 'admin-user-settings' tries:
// - Admin/User/SettingsController
// - Admin/UserSettingsController
// - AdminUser/SettingsController
for ($folderDepth = count($parts) - 1; $folderDepth >= 1; $folderDepth--) {
$folderParts = array_slice($parts, 0, $folderDepth);
$nameParts = array_slice($parts, $folderDepth);
$folder = implode('/', array_map('ucfirst', $folderParts));
$name = implode('', array_map('ucfirst', $nameParts)) . 'Controller';
// Try app namespace with folder
$appClass = self::APP_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name;
if (class_exists($appClass)) {
self::$availableControllers[strtolower($name)] = $appClass;
return $appClass;
}
// Try vendor namespace with folder
$vendorClass = self::VENDOR_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name;
if (class_exists($vendorClass)) {
self::$availableControllers[strtolower($name)] = $vendorClass;
return $vendorClass;
}
}
}
// Strategy 3: Dynamic class_exists check (for newly added controllers)
$appClass = self::APP_NAMESPACE . $directName;
if (class_exists($appClass)) {
self::$availableControllers[strtolower($directName)] = $appClass;
return $appClass;
}
$vendorClass = self::VENDOR_NAMESPACE . $directName;
if (class_exists($vendorClass)) {
self::$availableControllers[strtolower($directName)] = $vendorClass;
return $vendorClass;
}
return null;
}
/**
* Find a controller in the pre-scanned available controllers
*/
private static function findInAvailable(string $controllerName): ?string
{
$key = strtolower($controllerName);
return self::$availableControllers[$key] ?? null;
}
/**
* Convert kebab-case to PascalCase
* 'admin-user-settings' 'AdminUserSettings'
*/
private static function kebabToPascal(string $kebab): string
{
return implode('', array_map('ucfirst', explode('-', $kebab)));
}
/**
* Pre-scan all controller directories and cache the available controllers
* This is called once at server startup or on first request
*/
public static function scanControllers(): void
{
if (self::$scanned) {
return;
}
// Scan app controllers (including subfolders)
$appPath = self::getAppControllersPath();
if ($appPath && is_dir($appPath)) {
self::scanDirectory($appPath, self::APP_NAMESPACE);
}
// Scan vendor controllers
$vendorPath = __DIR__ . '/Controllers';
if (is_dir($vendorPath)) {
self::scanDirectory($vendorPath, self::VENDOR_NAMESPACE);
}
self::$scanned = true;
}
/**
* Recursively scan a directory for controller classes
*/
private static function scanDirectory(string $path, string $namespace, string $subNamespace = ''): void
{
$iterator = new \DirectoryIterator($path);
foreach ($iterator as $item) {
if ($item->isDot()) {
continue;
}
if ($item->isDir()) {
// Recurse into subdirectory
$folderName = $item->getFilename();
$newSubNamespace = $subNamespace . $folderName . '\\';
self::scanDirectory($item->getPathname(), $namespace, $newSubNamespace);
} elseif ($item->isFile() && $item->getExtension() === 'php') {
$fileName = $item->getBasename('.php');
// Only consider files ending with 'Controller'
if (!str_ends_with($fileName, 'Controller')) {
continue;
}
$fullClass = $namespace . $subNamespace . $fileName;
// Verify the class exists (triggers autoload)
if (class_exists($fullClass, true)) {
// Store with lowercase key for case-insensitive lookup
$key = strtolower($fileName);
self::$availableControllers[$key] = $fullClass;
// Also store with folder prefix for folder-based lookup
if ($subNamespace) {
$folderKey = strtolower(str_replace('\\', '', $subNamespace) . $fileName);
self::$availableControllers[$folderKey] = $fullClass;
}
}
}
}
}
/**
* Get the app controllers path
*/
private static function getAppControllersPath(): ?string
{
// Try Laravel's app_path if the application is fully booted
if (function_exists('app_path')) {
try {
$path = app_path('Websocket/Controllers');
if (is_dir($path)) {
return $path;
}
} catch (\Throwable $e) {
// app_path() might fail if app isn't booted - fall through to fallback
}
}
// Fallback: try common locations
$basePaths = [
defined('BASE_PATH') ? BASE_PATH : null,
getcwd(),
dirname(__DIR__, 5), // Go up from vendor package
dirname(__DIR__, 4),
];
foreach (array_filter($basePaths) as $basePath) {
$path = $basePath . '/app/Websocket/Controllers';
if (is_dir($path)) {
return $path;
}
}
return null;
}
/**
* Clear the controller cache (useful for testing or hot reload)
*/
public static function clearCache(): void
{
self::$controllerCache = [];
self::$availableControllers = [];
self::$scanned = false;
}
/**
* Get cache statistics (for debugging)
*
* @return array{cached: int, available: int, scanned: bool}
*/
public static function getStats(): array
{
return [
'cached' => count(self::$controllerCache),
'available' => count(self::$availableControllers),
'scanned' => self::$scanned,
];
}
/**
* Force preload of all controllers (call at server startup)
*/
public static function preload(): void
{
self::scanControllers();
}
}

View File

@ -385,8 +385,9 @@ class Handler implements MessageComponentInterface
$ipc->setupChild(); $ipc->setupChild();
try { try {
// Lazy DB reconnect: disconnect now, reconnect only when first query runs
// This saves ~5-15ms for methods that don't use the database
DB::disconnect(); DB::disconnect();
DB::reconnect();
$this->setRequest($message, $connection); $this->setRequest($message, $connection);
@ -516,8 +517,9 @@ class Handler implements MessageComponentInterface
string $requestId string $requestId
): void { ): void {
try { try {
// Lazy DB reconnect: disconnect now, reconnect only when first query runs
// This saves ~5-15ms for methods that don't use the database
DB::disconnect(); DB::disconnect();
DB::reconnect();
$this->setRequest($message, $connection); $this->setRequest($message, $connection);
$mock = new MockConnection($connection, $requestId); $mock = new MockConnection($connection, $requestId);

View File

@ -0,0 +1,99 @@
<?php
namespace BlaxSoftware\LaravelWebSockets\Tests\Unit;
use BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver;
use PHPUnit\Framework\TestCase;
class ControllerResolverTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
ControllerResolver::clearCache();
}
/** @test */
public function it_resolves_simple_controller_names()
{
// The package has a PusherController in vendor namespace
$result = ControllerResolver::resolve('pusher');
$this->assertNotNull($result);
$this->assertStringContainsString('PusherController', $result);
}
/** @test */
public function it_caches_resolved_controllers()
{
// First call scans and caches
ControllerResolver::resolve('pusher');
$stats = ControllerResolver::getStats();
$this->assertTrue($stats['scanned']);
$this->assertGreaterThan(0, $stats['cached']);
}
/** @test */
public function it_caches_null_for_nonexistent_controllers()
{
// First call - not found
$result1 = ControllerResolver::resolve('nonexistent-controller-xyz');
$this->assertNull($result1);
// Second call - should still be null (cached)
$result2 = ControllerResolver::resolve('nonexistent-controller-xyz');
$this->assertNull($result2);
// Check it's in cache
$stats = ControllerResolver::getStats();
$this->assertGreaterThan(0, $stats['cached']);
}
/** @test */
public function it_converts_kebab_case_to_pascal_case()
{
// admin-user should try AdminUserController
// This tests the internal conversion (we can't directly test private method,
// but we can test the resolve behavior)
$stats = ControllerResolver::getStats();
$this->assertIsArray($stats);
}
/** @test */
public function it_clears_cache_correctly()
{
// Populate cache
ControllerResolver::resolve('pusher');
$statsBefore = ControllerResolver::getStats();
$this->assertTrue($statsBefore['scanned']);
// Clear cache
ControllerResolver::clearCache();
$statsAfter = ControllerResolver::getStats();
$this->assertFalse($statsAfter['scanned']);
$this->assertEquals(0, $statsAfter['cached']);
$this->assertEquals(0, $statsAfter['available']);
}
/** @test */
public function it_preloads_controllers()
{
ControllerResolver::preload();
$stats = ControllerResolver::getStats();
$this->assertTrue($stats['scanned']);
$this->assertGreaterThan(0, $stats['available']);
}
/** @test */
public function it_returns_same_result_on_repeated_calls()
{
$result1 = ControllerResolver::resolve('pusher');
$result2 = ControllerResolver::resolve('pusher');
$this->assertSame($result1, $result2);
}
}

Binary file not shown.