Added support for hot reload, start, stop, restart, and virtual hosts

This commit is contained in:
Gregory Magarshak
2026-07-21 18:12:11 -04:00
parent 62d9b23396
commit bdac23b6f2
9 changed files with 469 additions and 10 deletions
+65 -2
View File
@@ -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
---
Binary file not shown.
+81 -1
View File
@@ -38,7 +38,7 @@ foreach ($argv as $i => $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']) {
+52
View File
@@ -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
```
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.qbix.server</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/php</string>
<string>/var/www/myapp/qbixserver.php</string>
<string>--root=./web</string>
<string>--port=8080</string>
<string>--pid=/tmp/qbixserver.pid</string>
</array>
<key>WorkingDirectory</key>
<string>/var/www/myapp</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/qbixserver.log</string>
<key>StandardErrorPath</key>
<string>/var/log/qbixserver.error.log</string>
</dict>
</plist>
+25
View File
@@ -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
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* Hot reload watches directories for file changes and triggers
* a graceful server restart when detected.
*
* Uses filemtime() polling (works everywhere, no extensions needed).
* Watches classes/, handlers/, config/ directories.
*
* Config:
* "Q": {"webserver": {"hotReload": true}}
*
* When a change is detected:
* - Handler changes: no action needed (lazy-loaded, next fork loads fresh)
* - Class/config changes: graceful restart via re-exec
*
* @class Q_HotReload
*/
class Q_HotReload
{
/** @var array path => 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();
}
}
+43 -6
View File
@@ -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;
}
+2 -1
View File
@@ -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'] ?? '';