mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
Added support for hot reload, start, stop, restart, and virtual hosts
This commit is contained in:
@@ -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. |
|
| `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()`) |
|
| `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.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.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) |
|
| `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
|
### Scheduler
|
||||||
|
|
||||||
Run tasks on intervals or at specific times. Handlers are forked like HTTP
|
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:**
|
**Coming next:**
|
||||||
|
|
||||||
- **Virtual hosts** — `Q.web.hosts.$hostname` config overrides for multi-domain serving
|
- **Clustering** — multi-process worker pool with shared socket for horizontal scaling
|
||||||
- **Hot reload** — watch `classes/`, `handlers/`, `config/` for changes, auto-restart workers
|
- **HTTP long-polling** — Socket.IO polling transport for environments that block WebSocket
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+81
-1
@@ -38,7 +38,7 @@ foreach ($argv as $i => $arg) {
|
|||||||
if ($i === 0) continue;
|
if ($i === 0) continue;
|
||||||
if ($arg === '--help' || $arg === '-h') {
|
if ($arg === '--help' || $arg === '-h') {
|
||||||
echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n\n";
|
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 "Options:\n";
|
||||||
echo " --root=DIR Document root (default: ./web)\n";
|
echo " --root=DIR Document root (default: ./web)\n";
|
||||||
echo " --app=DIR Qbix app directory (uses full Q framework)\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 " --workers=N Pre-fork workers (default: 0 = in-process)\n";
|
||||||
echo " --config=FILE JSON config file\n";
|
echo " --config=FILE JSON config file\n";
|
||||||
echo " --pid=PATH PID file path\n";
|
echo " --pid=PATH PID file path\n";
|
||||||
|
echo " --hotreload Watch files, auto-restart on changes\n";
|
||||||
echo " --debug Verbose logging\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";
|
echo " --version Print version\n";
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
@@ -55,10 +59,26 @@ foreach ($argv as $i => $arg) {
|
|||||||
echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n";
|
echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n";
|
||||||
exit(0);
|
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') {
|
if ($arg === '--debug') {
|
||||||
$opts['debug'] = true;
|
$opts['debug'] = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($arg === '--hotreload') {
|
||||||
|
$opts['hotreload'] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) {
|
if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) {
|
||||||
$opts[$m[1]] = $m[2];
|
$opts[$m[1]] = $m[2];
|
||||||
}
|
}
|
||||||
@@ -208,6 +228,66 @@ if (file_exists($appConfig)) {
|
|||||||
// Preload handlers if configured (Q.handlers.preload: true)
|
// Preload handlers if configured (Q.handlers.preload: true)
|
||||||
Q::preload();
|
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 ────────────────────────────────────────
|
// ── PID file ────────────────────────────────────────
|
||||||
|
|
||||||
if ($opts['pid']) {
|
if ($opts['pid']) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
@@ -347,6 +347,16 @@ class Q_WebServer
|
|||||||
self::stop();
|
self::stop();
|
||||||
Q_Evented::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
|
// Reap zombie children from fork-per-request PHP execution
|
||||||
Q_Evented::onSignal(SIGCHLD, function () {
|
Q_Evented::onSignal(SIGCHLD, function () {
|
||||||
while (($pid = pcntl_waitpid(-1, $st, WNOHANG)) > 0) {
|
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();
|
Q_Evented::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +557,7 @@ class Q_WebServer
|
|||||||
&& self::$keepAliveCount[$key] < $maxKeepAlive;
|
&& self::$keepAliveCount[$key] < $maxKeepAlive;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$savedRoot = self::$rootDir;
|
||||||
$keepOpen = self::handleRequest($client, $parsed);
|
$keepOpen = self::handleRequest($client, $parsed);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Never let a request crash the event loop
|
// Never let a request crash the event loop
|
||||||
@@ -553,6 +572,11 @@ class Q_WebServer
|
|||||||
(self::$onRequest)($parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms);
|
(self::$onRequest)($parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
// Restore rootDir after vhost override
|
||||||
|
if (self::$rootDir !== $savedRoot) {
|
||||||
|
self::$rootDir = $savedRoot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$ms = round((microtime(true) - $start) * 1000, 1);
|
$ms = round((microtime(true) - $start) * 1000, 1);
|
||||||
|
|
||||||
@@ -779,6 +803,17 @@ class Q_WebServer
|
|||||||
$method = $parsed['method'];
|
$method = $parsed['method'];
|
||||||
$path = $parsed['path'];
|
$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. Dashboard + Panel + WebSocket + Health (/Q/*)
|
||||||
// 1. Serve client JS files (before /Q/ check — socket.io.js is at /socket.io/)
|
// 1. Serve client JS files (before /Q/ check — socket.io.js is at /socket.io/)
|
||||||
$jsMap = array();
|
$jsMap = array();
|
||||||
@@ -2427,14 +2462,16 @@ HTML
|
|||||||
private static function resolveStatic($urlPath)
|
private static function resolveStatic($urlPath)
|
||||||
{
|
{
|
||||||
// Path resolution cache — avoids repeated realpath() syscalls
|
// Path resolution cache — avoids repeated realpath() syscalls
|
||||||
|
// Key includes rootDir so vhosts don't cross-contaminate
|
||||||
static $pathCache = array();
|
static $pathCache = array();
|
||||||
if (isset($pathCache[$urlPath])) {
|
$cacheKey = self::$rootDir . $urlPath;
|
||||||
$cached = $pathCache[$urlPath];
|
if (isset($pathCache[$cacheKey])) {
|
||||||
|
$cached = $pathCache[$cacheKey];
|
||||||
// Quick mtime check for invalidation (cheaper than realpath)
|
// Quick mtime check for invalidation (cheaper than realpath)
|
||||||
if ($cached === null || file_exists($cached)) {
|
if ($cached === null || file_exists($cached)) {
|
||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
unset($pathCache[$urlPath]);
|
unset($pathCache[$cacheKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$rel = str_replace('/', DS, ltrim($urlPath, '/'));
|
$rel = str_replace('/', DS, ltrim($urlPath, '/'));
|
||||||
@@ -2443,17 +2480,17 @@ HTML
|
|||||||
$fsPath = realpath(self::$rootDir . $rel);
|
$fsPath = realpath(self::$rootDir . $rel);
|
||||||
if (!$fsPath) {
|
if (!$fsPath) {
|
||||||
// Cache negative results too (404s won't re-stat)
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
$fsPath = str_replace(array('/','\\'), DS, $fsPath);
|
$fsPath = str_replace(array('/','\\'), DS, $fsPath);
|
||||||
$root = rtrim(self::$rootDir, DS);
|
$root = rtrim(self::$rootDir, DS);
|
||||||
if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) {
|
if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) {
|
||||||
$pathCache[$urlPath] = null;
|
$pathCache[$cacheKey] = null;
|
||||||
return null; // path traversal
|
return null; // path traversal
|
||||||
}
|
}
|
||||||
$result = (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null;
|
$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;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ class Q_WebServer_Cache
|
|||||||
*/
|
*/
|
||||||
static function cacheKey($parsed)
|
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
|
// Include Accept-Encoding in key for compressed variants
|
||||||
$ae = $parsed['headers']['accept-encoding'] ?? '';
|
$ae = $parsed['headers']['accept-encoding'] ?? '';
|
||||||
|
|||||||
Reference in New Issue
Block a user