diff --git a/README.md b/README.md index 16c986c..1f40706 100644 --- a/README.md +++ b/README.md @@ -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). \ No newline at end of file +Part of the [Qbix Platform](https://github.com/Qbix/Platform). diff --git a/composer.json b/composer.json index e631b3c..e68b9cc 100644 --- a/composer.json +++ b/composer.json @@ -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"], diff --git a/qbixserver.phar b/qbixserver.phar index eb5ae72..575c00a 100644 Binary files a/qbixserver.phar and b/qbixserver.phar differ diff --git a/src/Q/WebServer.php b/src/Q/WebServer.php index 9219774..1bb0c91 100644 --- a/src/Q/WebServer.php +++ b/src/Q/WebServer.php @@ -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' +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(); diff --git a/src/Q/WebServer/Pool.php b/src/Q/WebServer/Pool.php index 9dd9df4..4fd0f77 100644 --- a/src/Q/WebServer/Pool.php +++ b/src/Q/WebServer/Pool.php @@ -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); + } } /**