mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
Added support for Windows
This commit is contained in:
@@ -811,13 +811,26 @@ This is safer than php-fpm, which reuses workers across requests and relies on
|
||||
gets a clean process. The fork cost (~0.5ms) is negligible compared to the
|
||||
bootstrap savings (~10–50ms).
|
||||
|
||||
### In-process mode (--workers=0)
|
||||
### How PHP requests are handled
|
||||
|
||||
Without `--workers`, PHP scripts run directly in the event loop. This is
|
||||
single-threaded — fine for development and lightweight APIs. Superglobals
|
||||
(`$_GET`, `$_POST`, etc.) are reset between requests, but static class
|
||||
variables persist (same as php-fpm). Use `--workers=N` in production for
|
||||
full isolation.
|
||||
On Linux and macOS (where `pcntl_fork` is available), **every PHP request
|
||||
is forked** — even without `--workers`. The server forks a child, the child
|
||||
handles the request and exits, the parent continues serving. This means:
|
||||
|
||||
- `exit()` / `die()` in a script only kills the child — the server survives
|
||||
- Long-running scripts don't block static file serving
|
||||
- Each request is truly isolated
|
||||
|
||||
The `--workers=N` flag pre-forks N idle workers for faster dispatch (no fork
|
||||
latency per request). Without it, the server forks on demand. Both modes are
|
||||
shared-nothing.
|
||||
|
||||
**Windows** doesn't have `pcntl_fork`, so PHP scripts run in a subprocess
|
||||
via `proc_open`. This is safe — `exit()` can't crash the server — but each
|
||||
subprocess starts a fresh PHP interpreter (~50ms), so you don't get the
|
||||
preload speed benefit. Static files, WebSocket, caching, and everything
|
||||
else work identically. Good for development; use Linux/macOS for the full
|
||||
10x performance advantage.
|
||||
|
||||
### Growing into the full Qbix Platform
|
||||
|
||||
@@ -1077,11 +1090,12 @@ sudo apt install php-cli php-sockets
|
||||
|
||||
- Nothing. The PHP runtime is included.
|
||||
|
||||
**Windows:** The server runs in single-threaded mode (`--workers=0` only).
|
||||
Static files, PHP scripts, WebSocket, caching, compression, access control —
|
||||
everything works. You lose fork-per-request isolation and signal-based graceful
|
||||
shutdown, because `pcntl` doesn't exist on Windows. Good for development; for
|
||||
production use Linux or macOS (or WSL).
|
||||
**Windows:** The server works without `pcntl`. Static files, PHP scripts,
|
||||
WebSocket, caching, compression, access control — everything works. PHP
|
||||
scripts run in isolated subprocesses via `proc_open`, so `exit()` and
|
||||
crashes won't bring down the server. You lose the preload speed benefit
|
||||
(each subprocess starts fresh) and signal-based graceful shutdown. For
|
||||
the full 10x performance advantage, use Linux or macOS (or WSL).
|
||||
|
||||
---
|
||||
|
||||
@@ -1089,4 +1103,4 @@ production use Linux or macOS (or WSL).
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
Part of the [Qbix Platform](https://github.com/Qbix/Platform).
|
||||
Part of the [Qbix Platform](https://github.com/Qbix/Platform).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qbix/server",
|
||||
"description": "Pure PHP web server — static files, PHP execution, WebSocket, live dashboard",
|
||||
"description": "Pure-PHP Web Server. No NGinX or PHP-FPM needed. Handle 10x more traffic instead!",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"keywords": ["server", "http", "webserver", "php", "static", "websocket"],
|
||||
|
||||
Binary file not shown.
+178
-4
@@ -43,6 +43,12 @@ class Q_WebServer
|
||||
throw new Exception("Q_WebServer already running");
|
||||
}
|
||||
|
||||
// Ignore SIGPIPE — prevents crash when writing to a closed socket
|
||||
// (e.g. client disconnects, or worker dies mid-response)
|
||||
if (function_exists('pcntl_signal')) {
|
||||
pcntl_signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
|
||||
$root = realpath($dir);
|
||||
if (!$root || !is_dir($root)) {
|
||||
throw new Exception("Invalid document root: $dir");
|
||||
@@ -341,6 +347,10 @@ class Q_WebServer
|
||||
self::stop();
|
||||
Q_Evented::stop();
|
||||
});
|
||||
// Reap zombie children from fork-per-request PHP execution
|
||||
Q_Evented::onSignal(SIGCHLD, function () {
|
||||
while (pcntl_waitpid(-1, $st, WNOHANG) > 0) {}
|
||||
});
|
||||
}
|
||||
Q_Evented::run();
|
||||
}
|
||||
@@ -855,12 +865,170 @@ class Q_WebServer
|
||||
unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]);
|
||||
return false;
|
||||
}
|
||||
// In-process: run through Headers for X-Accel-Redirect + compression
|
||||
$parsed['_scriptPath'] = $scriptPath;
|
||||
$response = self::dispatchToQ($parsed);
|
||||
|
||||
// No pool — fork a single child if pcntl is available.
|
||||
// This protects the server from exit()/die() in scripts
|
||||
// and prevents blocking the event loop during PHP execution.
|
||||
if (function_exists('pcntl_fork')) {
|
||||
$pid = pcntl_fork();
|
||||
if ($pid === -1) {
|
||||
// Fork failed — fall through to in-process execution
|
||||
} elseif ($pid === 0) {
|
||||
// ── CHILD: handle request, write response to client, exit ──
|
||||
$parsed['_scriptPath'] = $scriptPath;
|
||||
$response = self::dispatchToQ($parsed);
|
||||
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
|
||||
@fclose($client);
|
||||
exit(0);
|
||||
} else {
|
||||
// ── PARENT: close client socket (child owns it now), reap later ──
|
||||
@fclose($client);
|
||||
$key = (int) $client;
|
||||
if (isset(self::$clientWatchers[$key])) {
|
||||
Q_Evented::cancel(self::$clientWatchers[$key]);
|
||||
}
|
||||
unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]);
|
||||
// Non-blocking reap — don't wait for child
|
||||
pcntl_waitpid($pid, $st, WNOHANG);
|
||||
self::$lastStatus = 200;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use proc_open to run PHP in a subprocess (Windows,
|
||||
// or fork failed). Safe against exit()/die() — the subprocess
|
||||
// dies, not the server. Slower than fork (no shared classes)
|
||||
// but still provides isolation.
|
||||
return self::handlePhpSubprocess($client, $parsed, $scriptPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a PHP script in a subprocess via proc_open.
|
||||
* Used on Windows (no pcntl_fork) or as fallback when fork fails.
|
||||
* The subprocess gets request data via stdin, returns response via stdout.
|
||||
* @method handlePhpSubprocess
|
||||
* @static
|
||||
* @private
|
||||
*/
|
||||
private static function handlePhpSubprocess($client, $parsed, $scriptPath)
|
||||
{
|
||||
// Build a small inline PHP worker that:
|
||||
// 1. Reads request JSON from stdin
|
||||
// 2. Sets up superglobals
|
||||
// 3. Loads Q.php for autoloader/events
|
||||
// 4. Includes the target script
|
||||
// 5. Writes response JSON to stdout
|
||||
$qFile = dirname(__FILE__) . DS . 'Q.php';
|
||||
$workerCode = <<<'WORKER'
|
||||
<?php
|
||||
$json = '';
|
||||
while (!feof(STDIN)) { $c = fread(STDIN, 65536); if ($c === false || $c === '') break; $json .= $c; }
|
||||
$req = json_decode($json, true);
|
||||
if (!$req) { echo json_encode(['status'=>500,'body'=>'Bad request','headers'=>[]]); exit; }
|
||||
if (isset($req['qFile']) && file_exists($req['qFile'])) {
|
||||
require_once $req['qFile'];
|
||||
if (isset($req['projectRoot'])) Q::init($req['projectRoot']);
|
||||
}
|
||||
$_SERVER['REQUEST_METHOD'] = $req['method'] ?? 'GET';
|
||||
$_SERVER['REQUEST_URI'] = $req['uri'] ?? '/';
|
||||
$_SERVER['QUERY_STRING'] = $req['query'] ?? '';
|
||||
$_SERVER['SCRIPT_FILENAME'] = $req['scriptPath'] ?? '';
|
||||
$_SERVER['SCRIPT_NAME'] = '/' . basename($req['scriptPath'] ?? 'index.php');
|
||||
$_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? '';
|
||||
$_SERVER['SERVER_NAME'] = $req['serverName'] ?? 'localhost';
|
||||
$_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080';
|
||||
foreach ($req['headers'] ?? [] as $k=>$v) $_SERVER['HTTP_'.strtoupper(str_replace('-','_',$k))] = $v;
|
||||
if (isset($req['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $req['headers']['content-type'];
|
||||
if (isset($req['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length'];
|
||||
$_GET = $_POST = $_REQUEST = [];
|
||||
if (!empty($req['query'])) parse_str($req['query'], $_GET);
|
||||
$ct = strtolower($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
$raw = $req['body'] ?? '';
|
||||
if (strpos($ct,'application/x-www-form-urlencoded') !== false) parse_str($raw, $_POST);
|
||||
elseif (strpos($ct,'application/json') !== false) $_POST = json_decode($raw, true) ?: [];
|
||||
$_REQUEST = array_merge($_GET, $_POST);
|
||||
ob_start(); $status = 200; $headers = [];
|
||||
try {
|
||||
if (is_file($req['scriptPath'])) include $req['scriptPath']; else { $status = 404; echo 'Not Found'; }
|
||||
foreach (headers_list() as $h) { if (strpos($h,':')!==false) { [$k,$v] = explode(':',$h,2); $headers[trim($k)] = trim($v); } }
|
||||
$code = http_response_code(); if ($code) $status = $code;
|
||||
} catch (Throwable $e) { $status = 500; ob_clean(); echo $e->getMessage(); $headers['Content-Type']='text/plain'; }
|
||||
$body = ob_get_clean();
|
||||
echo json_encode(compact('status','body','headers'), JSON_UNESCAPED_SLASHES);
|
||||
WORKER;
|
||||
|
||||
// Write worker code to a temp file (reuse across requests)
|
||||
static $workerFile = null;
|
||||
if (!$workerFile || !file_exists($workerFile)) {
|
||||
$workerFile = tempnam(sys_get_temp_dir(), 'qbix_worker_');
|
||||
file_put_contents($workerFile, $workerCode);
|
||||
register_shutdown_function(function () use (&$workerFile) {
|
||||
@unlink($workerFile);
|
||||
});
|
||||
}
|
||||
|
||||
// Build request payload
|
||||
$host = $parsed['headers']['host'] ?? 'localhost';
|
||||
$payload = json_encode(array(
|
||||
'method' => $parsed['method'],
|
||||
'uri' => $parsed['uri'],
|
||||
'query' => $parsed['query'],
|
||||
'headers' => $parsed['headers'],
|
||||
'body' => $parsed['body'] ?? '',
|
||||
'scriptPath' => $scriptPath,
|
||||
'documentRoot'=> rtrim(self::$rootDir, DS),
|
||||
'serverName' => explode(':', $host)[0],
|
||||
'serverPort' => (string) self::$port,
|
||||
'qFile' => $qFile,
|
||||
'projectRoot' => dirname(rtrim(self::$rootDir, DS)),
|
||||
), JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// Launch subprocess
|
||||
$descriptors = array(
|
||||
0 => array('pipe', 'r'), // stdin
|
||||
1 => array('pipe', 'w'), // stdout
|
||||
2 => array('pipe', 'w'), // stderr
|
||||
);
|
||||
|
||||
$phpBin = defined('PHP_BINARY') ? PHP_BINARY : 'php';
|
||||
$process = proc_open($phpBin . ' ' . escapeshellarg($workerFile), $descriptors, $pipes);
|
||||
|
||||
if (!is_resource($process)) {
|
||||
// proc_open failed — last resort, run in-process
|
||||
$parsed['_scriptPath'] = $scriptPath;
|
||||
$response = self::dispatchToQ($parsed);
|
||||
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
|
||||
self::$lastStatus = $response['status'] ?? 200;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send request data to child's stdin
|
||||
fwrite($pipes[0], $payload);
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Read response from child's stdout
|
||||
$stdout = '';
|
||||
while (!feof($pipes[1])) {
|
||||
$chunk = fread($pipes[1], 65536);
|
||||
if ($chunk === false || $chunk === '') break;
|
||||
$stdout .= $chunk;
|
||||
}
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_close($process);
|
||||
|
||||
// Parse response
|
||||
$response = json_decode($stdout, true);
|
||||
if (!$response) {
|
||||
$response = array(
|
||||
'status' => 502,
|
||||
'body' => 'Worker subprocess failed',
|
||||
'headers' => array('Content-Type' => 'text/plain'),
|
||||
);
|
||||
}
|
||||
|
||||
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
|
||||
self::$lastStatus = $response['status'] ?? 200;
|
||||
// Store in cache if cacheable
|
||||
Q_WebServer_Cache::put($parsed, $response);
|
||||
return false;
|
||||
}
|
||||
@@ -1268,6 +1436,12 @@ HTML
|
||||
}
|
||||
$_REQUEST = array_merge($_GET, $_POST);
|
||||
|
||||
// Clear any stale headers and output from previous in-process requests,
|
||||
// then start fresh output buffering. This prevents "headers already sent"
|
||||
// errors when scripts call header() after prior output leaked through.
|
||||
while (ob_get_level()) ob_end_clean();
|
||||
header_remove();
|
||||
http_response_code(200);
|
||||
ob_start();
|
||||
$status = 200;
|
||||
$headers = array();
|
||||
|
||||
@@ -234,7 +234,12 @@ class Q_WebServer_Pool
|
||||
'serverPort' => (string)($_SERVER['SERVER_PORT'] ?? '8080'),
|
||||
'remoteAddr' => '127.0.0.1'
|
||||
));
|
||||
fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg);
|
||||
$written = @fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg);
|
||||
if ($written === false || $written === 0) {
|
||||
// Worker died before receiving the request — recycle and re-queue
|
||||
$this->pending[] = array($client, $parsed, $scriptPath);
|
||||
$this->recycle($index, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user