From bdac23b6f2b17e301ccd174849f903c58bf2bd4b Mon Sep 17 00:00:00 2001 From: Gregory Magarshak Date: Tue, 21 Jul 2026 18:12:11 -0400 Subject: [PATCH] Added support for hot reload, start, stop, restart, and virtual hosts --- README.md | 67 +++++++++++++- bin/qbixserver.phar | Bin 360609 -> 366016 bytes qbixserver.php | 82 ++++++++++++++++- service/README.md | 52 +++++++++++ service/com.qbix.server.plist | 32 +++++++ service/qbixserver.service | 25 +++++ src/Q/HotReload.php | 169 ++++++++++++++++++++++++++++++++++ src/Q/WebServer.php | 49 ++++++++-- src/Q/WebServer/Cache.php | 3 +- 9 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 service/README.md create mode 100644 service/com.qbix.server.plist create mode 100644 service/qbixserver.service create mode 100644 src/Q/HotReload.php diff --git a/README.md b/README.md index a829e31..0b175c6 100644 --- a/README.md +++ b/README.md @@ -2106,9 +2106,72 @@ Create `config/server.json` next to your `web/` directory, or pass `--config=pat | `socket.js` | `"/Q/socket.js"` | Path to serve the minimal bare-WebSocket client (3KB). `false` to disable. | | `app` | `""` | App name — prefixes handler function names (e.g. `"Chess"` → `Chess_chat_message()`) | | `webserver.fallback` | null | Catch-all: `"index.html"`, `{"handler":"app/notfound"}`, or `{"file":"404.html"}` | +| `webserver.hotReload` | `false` | Watch `classes/`, `handlers/`, `config/` for changes. Auto-restarts on class/config changes. | | `webserver.cgi.patterns` | [] | Regex patterns for scripts that use php-cgi (legacy compatibility) | | `webserver.cgi.binary` | auto | Path to php-cgi binary (auto-detected if not set) | +### Virtual hosts + +Serve multiple domains from one server. Each host can have its own document root: + +```json +{ + "Q": { + "webserver": { + "hosts": { + "example.com": { + "root": "/var/www/example/web" + }, + "api.example.com": { + "root": "/var/www/api/web" + }, + "staging.example.com": { + "root": "/var/www/staging/web" + } + } + } + } +} +``` + +The `Host` header selects the root. Requests for unconfigured hosts use the +default `--root` directory. WebSocket, rooms, handlers, and static files all +respect the per-host root. + +### Hot reload + +Watch `classes/`, `handlers/`, and `config/` for file changes: + +```bash +php qbixserver.php --root=./web --port=8080 --hotreload +``` + +Or via config: + +```json +{ + "Q": { + "webserver": { + "hotReload": true + } + } +} +``` + +Handler changes take effect immediately — handlers are lazy-loaded, so the +next request or connection picks up the new code. Class or config changes +trigger a graceful restart (the server re-execs itself with the same arguments). + +Changes are logged to stderr: + +``` +14:32:07 hot-reload: ~ handlers/chat/message.php +14:32:09 hot-reload: + classes/MyApp/NewFeature.php +14:32:09 hot-reload: restarting server... +``` + +Polls every 2 seconds. Use in development, not production. + ### Scheduler Run tasks on intervals or at specific times. Handlers are forked like HTTP @@ -2599,8 +2662,8 @@ the full 10x performance advantage, use Linux or macOS (or WSL). **Coming next:** -- **Virtual hosts** — `Q.web.hosts.$hostname` config overrides for multi-domain serving -- **Hot reload** — watch `classes/`, `handlers/`, `config/` for changes, auto-restart workers +- **Clustering** — multi-process worker pool with shared socket for horizontal scaling +- **HTTP long-polling** — Socket.IO polling transport for environments that block WebSocket --- diff --git a/bin/qbixserver.phar b/bin/qbixserver.phar index 693da0530bde52c58ddd2da3dfde0b8eab3e5af5..3f7e2f6f105ca588056bd4dcd12d92ad92429a2d 100755 GIT binary patch delta 4589 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 H`8ojr $arg) { if ($i === 0) continue; if ($arg === '--help' || $arg === '-h') { echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n\n"; - echo "Usage: php qbixserver.php [options]\n\n"; + echo "Usage: ./qbixserver.php [options]\n\n"; echo "Options:\n"; echo " --root=DIR Document root (default: ./web)\n"; echo " --app=DIR Qbix app directory (uses full Q framework)\n"; @@ -47,7 +47,11 @@ foreach ($argv as $i => $arg) { echo " --workers=N Pre-fork workers (default: 0 = in-process)\n"; echo " --config=FILE JSON config file\n"; echo " --pid=PATH PID file path\n"; + echo " --hotreload Watch files, auto-restart on changes\n"; echo " --debug Verbose logging\n"; + echo " -t Test config and exit\n"; + echo " --stop Graceful shutdown (via PID file)\n"; + echo " --reload Re-exec server (via PID file)\n"; echo " --version Print version\n"; exit(0); } @@ -55,10 +59,26 @@ foreach ($argv as $i => $arg) { echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n"; exit(0); } + if ($arg === '-t') { + $opts['test'] = true; + continue; + } + if ($arg === '--stop') { + $opts['signal'] = 'stop'; + continue; + } + if ($arg === '--reload') { + $opts['signal'] = 'reload'; + continue; + } if ($arg === '--debug') { $opts['debug'] = true; continue; } + if ($arg === '--hotreload') { + $opts['hotreload'] = true; + continue; + } if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) { $opts[$m[1]] = $m[2]; } @@ -208,6 +228,66 @@ if (file_exists($appConfig)) { // Preload handlers if configured (Q.handlers.preload: true) Q::preload(); +// CLI flag overrides +if (!empty($opts['hotreload'])) { + Q_Config::set('Q', 'webserver', 'hotReload', true); +} + +// ── Signal commands (--stop, --reload) ────────────── + +if (!empty($opts['signal'])) { + $pidFile = $opts['pid'] ?: dirname($webDir) . '/qbixserver.pid'; + if (!file_exists($pidFile)) { + fwrite(STDERR, "PID file not found: $pidFile\n"); + fwrite(STDERR, "Use --pid=PATH to specify, or start the server with --pid first.\n"); + exit(1); + } + $pid = (int) trim(file_get_contents($pidFile)); + if ($pid <= 0 || !posix_kill($pid, 0)) { + fwrite(STDERR, "No running server found (PID $pid)\n"); + @unlink($pidFile); + exit(1); + } + if ($opts['signal'] === 'stop') { + posix_kill($pid, SIGTERM); + echo "Sent SIGTERM to PID $pid\n"; + } elseif ($opts['signal'] === 'reload') { + posix_kill($pid, SIGHUP); + echo "Sent SIGHUP to PID $pid\n"; + } + exit(0); +} + +// ── Config test (-t) ──────────────────────────────── + +if (!empty($opts['test'])) { + echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n"; + echo "Config: OK\n"; + echo " Root: $webDir\n"; + echo " Host: {$opts['host']}\n"; + echo " Port: {$opts['port']}\n"; + $app = Q::app(); + if ($app) echo " App: $app\n"; + $ioPath = Q_Config::get('Q', 'socket', 'io', '/socket.io'); + echo " Socket.IO: " . ($ioPath !== false ? $ioPath : 'disabled') . "\n"; + $jsPath = Q_Config::get('Q', 'socket', 'js', '/Q/socket.js'); + echo " Socket.js: " . ($jsPath !== false ? $jsPath : 'disabled') . "\n"; + $hosts = Q_Config::get('Q', 'webserver', 'hosts', array()); + if (!empty($hosts)) { + echo " Vhosts: " . implode(', ', array_keys($hosts)) . "\n"; + } + $schedule = Q_Config::get('Q', 'scheduler', array()); + if (!empty($schedule)) { + echo " Scheduled: " . implode(', ', array_keys($schedule)) . "\n"; + } + $timeout = Q_Config::get('Q', 'webserver', 'requestTimeout', 30); + echo " Timeout: {$timeout}s\n"; + $preloaded = Q::$preloadedHandlers; + echo " Classes: " . count(get_declared_classes()) . " preloaded\n"; + echo " Handlers: " . ($preloaded > 0 ? "$preloaded preloaded" : "lazy") . "\n"; + exit(0); +} + // ── PID file ──────────────────────────────────────── if ($opts['pid']) { diff --git a/service/README.md b/service/README.md new file mode 100644 index 0000000..120697a --- /dev/null +++ b/service/README.md @@ -0,0 +1,52 @@ +# Running Qbix Server as a Service + +## Linux (systemd) + +```bash +# Edit paths in the service file +sudo cp service/qbixserver.service /etc/systemd/system/ +sudo systemctl daemon-reload + +# Start, stop, reload +sudo systemctl start qbixserver +sudo systemctl stop qbixserver +sudo systemctl reload qbixserver # re-reads config, restarts workers + +# Auto-start on boot +sudo systemctl enable qbixserver + +# View logs +journalctl -u qbixserver -f +``` + +## macOS (launchd) + +```bash +# Edit paths in the plist file +cp service/com.qbix.server.plist ~/Library/LaunchAgents/ + +# Start, stop +launchctl load ~/Library/LaunchAgents/com.qbix.server.plist +launchctl unload ~/Library/LaunchAgents/com.qbix.server.plist + +# View logs +tail -f /var/log/qbixserver.log +``` + +For system-wide (not per-user), use `/Library/LaunchDaemons/` instead. + +## Manual (any platform) + +```bash +# Start in background +./qbixserver.php --root=./web --port=8080 --pid=./qbixserver.pid & + +# Stop +./qbixserver.php --stop --pid=./qbixserver.pid + +# Reload (graceful restart) +./qbixserver.php --reload --pid=./qbixserver.pid + +# Test config +./qbixserver.php -t +``` diff --git a/service/com.qbix.server.plist b/service/com.qbix.server.plist new file mode 100644 index 0000000..61fd632 --- /dev/null +++ b/service/com.qbix.server.plist @@ -0,0 +1,32 @@ + + + + + Label + com.qbix.server + + ProgramArguments + + /usr/local/bin/php + /var/www/myapp/qbixserver.php + --root=./web + --port=8080 + --pid=/tmp/qbixserver.pid + + + WorkingDirectory + /var/www/myapp + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /var/log/qbixserver.log + + StandardErrorPath + /var/log/qbixserver.error.log + + diff --git a/service/qbixserver.service b/service/qbixserver.service new file mode 100644 index 0000000..cd2e8b3 --- /dev/null +++ b/service/qbixserver.service @@ -0,0 +1,25 @@ +[Unit] +Description=Qbix Server +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/myapp +ExecStart=/usr/bin/php /var/www/myapp/qbixserver.php --root=./web --port=8080 --pid=/var/run/qbixserver.pid +ExecStop=/usr/bin/php /var/www/myapp/qbixserver.php --stop --pid=/var/run/qbixserver.pid +ExecReload=/usr/bin/php /var/www/myapp/qbixserver.php --reload --pid=/var/run/qbixserver.pid +PIDFile=/var/run/qbixserver.pid +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/www/myapp /var/run + +[Install] +WantedBy=multi-user.target diff --git a/src/Q/HotReload.php b/src/Q/HotReload.php new file mode 100644 index 0000000..4b8a99a --- /dev/null +++ b/src/Q/HotReload.php @@ -0,0 +1,169 @@ + mtime snapshot */ + static $snapshot = array(); + /** @var float Last full scan time */ + static $lastScan = 0; + /** @var array Directories to watch */ + static $watchDirs = array(); + /** @var boolean Whether a restart is pending */ + static $restarting = false; + + /** + * Initialize: snapshot all watched files. + */ + static function init() + { + // Watch standard directories relative to each registered path + foreach (Q::$paths as $base) { + foreach (array('classes', 'handlers', 'config') as $dir) { + $full = $base . DS . $dir; + if (is_dir($full)) { + self::$watchDirs[] = $full; + } + } + } + + if (empty(self::$watchDirs)) return; + + self::$snapshot = self::scan(); + self::$lastScan = microtime(true); + } + + /** + * Check for changes. Called every 2 seconds by the event loop. + */ + static function check() + { + if (self::$restarting || empty(self::$watchDirs)) return; + + $current = self::scan(); + $changes = self::diff($current); + + if (empty($changes)) { + self::$snapshot = $current; + return; + } + + // Categorize changes + $needsRestart = false; + foreach ($changes as $file => $type) { + $rel = self::relativePath($file); + if (strpos($rel, 'classes' . DS) === 0 || strpos($rel, 'config' . DS) === 0) { + $needsRestart = true; + } + $label = ($type === 'added') ? "\033[32m+\033[0m" : + (($type === 'removed') ? "\033[31m-\033[0m" : "\033[33m~\033[0m"); + fwrite(STDERR, date('H:i:s') . " hot-reload: $label $rel\n"); + } + + self::$snapshot = $current; + + if ($needsRestart) { + self::restart(); + } + } + + /** + * Scan all watched directories, return path => mtime map. + */ + static function scan() + { + $files = array(); + foreach (self::$watchDirs as $dir) { + self::scanDir($dir, $files); + } + return $files; + } + + private static function scanDir($dir, &$files) + { + $entries = @scandir($dir); + if (!$entries) return; + foreach ($entries as $e) { + if ($e[0] === '.') continue; + $path = $dir . DS . $e; + if (is_dir($path)) { + self::scanDir($path, $files); + } elseif (is_file($path)) { + $files[$path] = @filemtime($path); + } + } + } + + /** + * Compare current scan to snapshot, return changed files. + */ + static function diff($current) + { + $changes = array(); + // Modified or added + foreach ($current as $path => $mtime) { + if (!isset(self::$snapshot[$path])) { + $changes[$path] = 'added'; + } elseif ($mtime !== self::$snapshot[$path]) { + $changes[$path] = 'modified'; + } + } + // Removed + foreach (self::$snapshot as $path => $mtime) { + if (!isset($current[$path])) { + $changes[$path] = 'removed'; + } + } + return $changes; + } + + /** + * Get a human-readable relative path. + */ + static function relativePath($path) + { + foreach (Q::$paths as $base) { + if (strpos($path, $base . DS) === 0) { + return substr($path, strlen($base) + 1); + } + } + return basename($path); + } + + /** + * Graceful restart — re-exec the server process. + */ + static function restart() + { + self::$restarting = true; + fwrite(STDERR, date('H:i:s') . " hot-reload: restarting server...\n"); + + // Re-exec: replace current process with a fresh one + // This preserves the original command-line arguments + $args = $_SERVER['argv'] ?? array(); + $php = PHP_BINARY; + + if (function_exists('pcntl_exec')) { + pcntl_exec($php, $args); + // If pcntl_exec fails, fall through + } + + // Fallback: signal the event loop to stop, then exec + fwrite(STDERR, date('H:i:s') . " hot-reload: stopping for manual restart\n"); + Q_Evented::stop(); + } +} diff --git a/src/Q/WebServer.php b/src/Q/WebServer.php index 45f7280..68e8cd5 100644 --- a/src/Q/WebServer.php +++ b/src/Q/WebServer.php @@ -347,6 +347,16 @@ class Q_WebServer self::stop(); Q_Evented::stop(); }); + Q_Evented::onSignal(SIGHUP, function () { + echo "\n Reloading (SIGHUP)...\n"; + self::stop(); + Q_Evented::stop(); + // Re-exec with same arguments + $args = $_SERVER['argv'] ?? array(); + if (function_exists('pcntl_exec')) { + pcntl_exec(PHP_BINARY, $args); + } + }); // Reap zombie children from fork-per-request PHP execution Q_Evented::onSignal(SIGCHLD, function () { while (($pid = pcntl_waitpid(-1, $st, WNOHANG)) > 0) { @@ -382,6 +392,14 @@ class Q_WebServer }); } + // Hot reload — watch for file changes in classes/, handlers/, config/ + if (Q_Config::get('Q', 'webserver', 'hotReload', false)) { + Q_HotReload::init(); + Q_Evented::repeat(2, function () { + Q_HotReload::check(); + }); + } + Q_Evented::run(); } @@ -539,6 +557,7 @@ class Q_WebServer && self::$keepAliveCount[$key] < $maxKeepAlive; try { + $savedRoot = self::$rootDir; $keepOpen = self::handleRequest($client, $parsed); } catch (\Throwable $e) { // Never let a request crash the event loop @@ -553,6 +572,11 @@ class Q_WebServer (self::$onRequest)($parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms); } return; + } finally { + // Restore rootDir after vhost override + if (self::$rootDir !== $savedRoot) { + self::$rootDir = $savedRoot; + } } $ms = round((microtime(true) - $start) * 1000, 1); @@ -779,6 +803,17 @@ class Q_WebServer $method = $parsed['method']; $path = $parsed['path']; + // Virtual hosts — override rootDir based on Host header + $host = $parsed['headers']['host'] ?? ''; + $host = strtolower(preg_replace('/:\d+$/', '', $host)); // strip port + $hostConfig = Q_Config::get('Q', 'webserver', 'hosts', $host, null); + if ($hostConfig && isset($hostConfig['root'])) { + $vroot = realpath($hostConfig['root']); + if ($vroot && is_dir($vroot)) { + self::$rootDir = rtrim(str_replace(array('/', '\\'), DS, $vroot), DS) . DS; + } + } + // 1. Dashboard + Panel + WebSocket + Health (/Q/*) // 1. Serve client JS files (before /Q/ check — socket.io.js is at /socket.io/) $jsMap = array(); @@ -2427,14 +2462,16 @@ HTML private static function resolveStatic($urlPath) { // Path resolution cache — avoids repeated realpath() syscalls + // Key includes rootDir so vhosts don't cross-contaminate static $pathCache = array(); - if (isset($pathCache[$urlPath])) { - $cached = $pathCache[$urlPath]; + $cacheKey = self::$rootDir . $urlPath; + if (isset($pathCache[$cacheKey])) { + $cached = $pathCache[$cacheKey]; // Quick mtime check for invalidation (cheaper than realpath) if ($cached === null || file_exists($cached)) { return $cached; } - unset($pathCache[$urlPath]); + unset($pathCache[$cacheKey]); } $rel = str_replace('/', DS, ltrim($urlPath, '/')); @@ -2443,17 +2480,17 @@ HTML $fsPath = realpath(self::$rootDir . $rel); if (!$fsPath) { // Cache negative results too (404s won't re-stat) - if (count($pathCache) < 10000) $pathCache[$urlPath] = null; + if (count($pathCache) < 10000) $pathCache[$cacheKey] = null; return null; } $fsPath = str_replace(array('/','\\'), DS, $fsPath); $root = rtrim(self::$rootDir, DS); if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) { - $pathCache[$urlPath] = null; + $pathCache[$cacheKey] = null; return null; // path traversal } $result = (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null; - if (count($pathCache) < 10000) $pathCache[$urlPath] = $result; + if (count($pathCache) < 10000) $pathCache[$cacheKey] = $result; return $result; } diff --git a/src/Q/WebServer/Cache.php b/src/Q/WebServer/Cache.php index ec36ffc..fee1a6e 100644 --- a/src/Q/WebServer/Cache.php +++ b/src/Q/WebServer/Cache.php @@ -258,7 +258,8 @@ class Q_WebServer_Cache */ static function cacheKey($parsed) { - $parts = $parsed['path'] . '?' . ($parsed['query'] ?? ''); + $host = $parsed['headers']['host'] ?? ''; + $parts = $host . $parsed['path'] . '?' . ($parsed['query'] ?? ''); // Include Accept-Encoding in key for compressed variants $ae = $parsed['headers']['accept-encoding'] ?? '';