Added support for Windows

This commit is contained in:
Gregory Magarshak
2026-07-20 13:04:15 -04:00
parent c47141bc6b
commit dd06d0689d
5 changed files with 211 additions and 18 deletions
+26 -12
View File
@@ -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 (~1050ms).
### 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
View File
@@ -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"],
BIN
View File
Binary file not shown.
+178 -4
View File
@@ -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();
+6 -1
View File
@@ -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);
}
}
/**