From e9db4d46f80963724c5cc05ac6a38ea5d0e3bfa6 Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Fri, 22 May 2026 17:04:52 +0100 Subject: [PATCH 1/4] Add native PHP worker pool POC --- apps/cli/php-server-child.ts | 347 +++++++++++++++++++++++++++++++---- 1 file changed, 307 insertions(+), 40 deletions(-) diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index c19364848d..287f02c5b7 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -8,6 +8,8 @@ import { ChildProcess, spawn } from 'node:child_process'; import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; @@ -88,6 +90,10 @@ async function writeNativePhpMyAdminWpEnv( config: ServerConfig ): Promise< stri } let phpProcess: ChildProcess | null = null; +let phpWorkerProcesses: ChildProcess[] = []; +let phpProxyServer: http.Server | null = null; +let phpWorkerPorts: number[] = []; +let nextPhpWorkerIndex = 0; let startupAbortController: AbortController | null = null; let startingPromise: Promise< void > | null = null; let blueprintQueue: Promise< unknown > = Promise.resolve(); @@ -102,6 +108,7 @@ let runningConfig: ServerConfig | null = null; const SYMLINK_RESTART_DEBOUNCE_MS = 750; const STOP_SERVER_TIMEOUT = 5000; +const DEFAULT_NATIVE_PHP_WORKER_POOL_SIZE = 1; function logToConsole( ...args: Parameters< typeof console.log > ) { console.log( `[PHP Server]`, ...args ); @@ -111,6 +118,60 @@ function errorToConsole( ...args: Parameters< typeof console.error > ) { console.error( `[PHP Server]`, ...args ); } +function getNativePhpWorkerPoolSize(): number { + // POC escape hatch for experimenting with native PHP request concurrency. + const parsed = Number.parseInt( process.env.STUDIO_NATIVE_PHP_WORKER_POOL ?? '', 10 ); + if ( ! Number.isFinite( parsed ) || parsed < 2 ) { + return DEFAULT_NATIVE_PHP_WORKER_POOL_SIZE; + } + return Math.min( parsed, 8 ); +} + +function shouldUsePrimaryWorker( req: http.IncomingMessage ): boolean { + const method = req.method?.toUpperCase() ?? 'GET'; + if ( ! [ 'GET', 'HEAD', 'OPTIONS' ].includes( method ) ) { + return true; + } + + const requestUrl = req.url ?? '/'; + if ( requestUrl.startsWith( '/phpmyadmin' ) ) { + return true; + } + + return false; +} + +function pickPhpWorkerPort( req: http.IncomingMessage ): number { + if ( phpWorkerPorts.length === 0 ) { + throw new Error( 'No PHP worker ports are available' ); + } + + if ( shouldUsePrimaryWorker( req ) ) { + return phpWorkerPorts[ 0 ]; + } + + const port = phpWorkerPorts[ nextPhpWorkerIndex % phpWorkerPorts.length ]; + nextPhpWorkerIndex++; + return port; +} + +async function getAvailablePort(): Promise< number > { + return await new Promise< number >( ( resolve, reject ) => { + const server = net.createServer(); + server.unref(); + server.once( 'error', reject ); + server.listen( 0, '127.0.0.1', () => { + const address = server.address(); + if ( ! address || typeof address === 'string' ) { + server.close( () => reject( new Error( 'Could not allocate a PHP worker port' ) ) ); + return; + } + const port = address.port; + server.close( () => resolve( port ) ); + } ); + } ); +} + type SpawnPhpProcessOptions = { disallowRiskyFunctions?: boolean; env?: NodeJS.ProcessEnv; @@ -294,7 +355,7 @@ async function waitForServerReady( url: string, signal?: AbortSignal ): Promise< while ( true ) { signal?.throwIfAborted(); try { - await fetch( url, { signal } ); + await fetch( url, { redirect: 'manual', signal } ); return; } catch { signal?.throwIfAborted(); @@ -469,30 +530,153 @@ async function restartPhpServer(): Promise< void > { return; } - const oldChild = phpProcess; - phpProcess = null; + await stopCurrentPhpServer(); + + try { + phpProcess = await doStartServer( runningConfig, currentOpenBasedirAllowlist ); + } catch ( error ) { + errorToConsole( `Failed to restart PHP server:`, error ); + process.exit( 1 ); + } +} + +function getCurrentPhpProcesses(): ChildProcess[] { + return [ + ...new Set( [ phpProcess, ...phpWorkerProcesses ].filter( Boolean ) ), + ] as ChildProcess[]; +} + +async function closePhpProxyServer(): Promise< void > { + const proxyServer = phpProxyServer; + phpProxyServer = null; + phpWorkerPorts = []; + nextPhpWorkerIndex = 0; + + if ( ! proxyServer ) { + return; + } - // Detach so the imminent SIGTERM is not reported as an unexpected crash. - oldChild.removeAllListeners( 'exit' ); - oldChild.kill( 'SIGTERM' ); await new Promise< void >( ( resolve ) => { - const timeout = setTimeout( () => { - if ( ! oldChild.killed ) { - oldChild.kill( 'SIGKILL' ); + proxyServer.close( () => resolve() ); + } ).catch( () => {} ); +} + +async function stopPhpChild( child: ChildProcess ): Promise< void > { + child.removeAllListeners( 'exit' ); + if ( child.exitCode !== null || child.signalCode !== null ) { + return; + } + + await new Promise< void >( ( resolve ) => { + const forceKillTimeout = setTimeout( () => { + errorToConsole( 'PHP child did not exit in time; sending SIGKILL' ); + if ( child.exitCode === null && child.signalCode === null ) { + child.kill( 'SIGKILL' ); } }, STOP_SERVER_TIMEOUT ); - oldChild.once( 'close', () => { - clearTimeout( timeout ); + + child.once( 'close', () => { + clearTimeout( forceKillTimeout ); resolve(); } ); + + child.kill( 'SIGTERM' ); } ); +} + +async function stopCurrentPhpServer(): Promise< void > { + const children = getCurrentPhpProcesses(); + phpProcess = null; + phpWorkerProcesses = []; + await closePhpProxyServer(); + await Promise.all( children.map( ( child ) => stopPhpChild( child ) ) ); +} + +async function waitForChildSpawn( child: ChildProcess, signal?: AbortSignal ): Promise< void > { + await new Promise< void >( ( resolve, reject ) => { + child.once( 'spawn', () => { + resolve(); + } ); + child.once( 'error', ( error: Error ) => { + reject( error ); + } ); + signal?.addEventListener( 'abort', () => { + reject( new DOMException( 'Aborted', 'AbortError' ) ); + } ); + } ); +} + +function markPhpChildAsCritical( child: ChildProcess, label: string ): void { + child.once( 'exit', ( code, signalName ) => { + errorToConsole( `${ label } exited unexpectedly (code: ${ code }, signal: ${ signalName })` ); + process.exit( code ?? 1 ); + } ); +} + +function proxyRequestToPhpWorker( + config: ServerConfig, + req: http.IncomingMessage, + res: http.ServerResponse +): void { + let targetPort: number; try { - phpProcess = await doStartServer( runningConfig, currentOpenBasedirAllowlist ); + targetPort = pickPhpWorkerPort( req ); } catch ( error ) { - errorToConsole( `Failed to restart PHP server:`, error ); - process.exit( 1 ); + res.writeHead( 503 ); + res.end( error instanceof Error ? error.message : String( error ) ); + return; } + + const headers = { ...req.headers }; + headers.host = req.headers.host ?? `localhost:${ config.port }`; + delete headers.connection; + delete headers[ 'proxy-connection' ]; + + const proxyReq = http.request( + { + hostname: '127.0.0.1', + port: targetPort, + path: req.url, + method: req.method, + headers, + }, + ( proxyRes ) => { + res.writeHead( proxyRes.statusCode ?? 502, proxyRes.headers ); + proxyRes.pipe( res ); + } + ); + + proxyReq.on( 'error', ( error ) => { + if ( ! res.headersSent ) { + res.writeHead( 502 ); + } + res.end( `PHP worker proxy error: ${ error.message }` ); + } ); + + req.pipe( proxyReq ); +} + +async function startPhpProxyServer( + config: ServerConfig, + stopSignal?: AbortSignal +): Promise< http.Server > { + const proxyServer = http.createServer( ( req, res ) => + proxyRequestToPhpWorker( config, req, res ) + ); + + await new Promise< void >( ( resolve, reject ) => { + proxyServer.once( 'error', reject ); + stopSignal?.addEventListener( 'abort', () => { + proxyServer.close(); + reject( new DOMException( 'Aborted', 'AbortError' ) ); + } ); + proxyServer.listen( config.port, 'localhost', () => { + resolve(); + } ); + } ); + + return proxyServer; } async function startServer( config: ServerConfig, signal: AbortSignal ): Promise< void > { @@ -561,6 +745,11 @@ async function doStartServer( openBasedirAllowlist: Set< string >, stopSignal?: AbortSignal ): Promise< ChildProcess > { + const workerPoolSize = getNativePhpWorkerPoolSize(); + if ( workerPoolSize > 1 ) { + return await doStartPooledServer( config, openBasedirAllowlist, workerPoolSize, stopSignal ); + } + const phpAddress = `localhost:${ config.port }`; const phpVersion = resolveNativePhpVersion( config.phpVersion ?? '' ); let spawnedChild: ChildProcess | null = null; @@ -627,6 +816,86 @@ async function doStartServer( } } +async function doStartPooledServer( + config: ServerConfig, + openBasedirAllowlist: Set< string >, + workerPoolSize: number, + stopSignal?: AbortSignal +): Promise< ChildProcess > { + const phpVersion = resolveNativePhpVersion( config.phpVersion ?? '' ); + const spawnedChildren: ChildProcess[] = []; + let proxyServer: http.Server | null = null; + + logToConsole( + `Spawning native PHP worker pool with ${ workerPoolSize } workers on public port ${ config.port }` + ); + + try { + const phpMyAdminWpEnvPath = await writeNativePhpMyAdminWpEnv( config ); + const workerPorts: number[] = []; + for ( let index = 0; index < workerPoolSize; index++ ) { + workerPorts.push( await getAvailablePort() ); + } + + phpWorkerPorts = workerPorts; + nextPhpWorkerIndex = 0; + + for ( const [ index, workerPort ] of workerPorts.entries() ) { + const phpAddress = `127.0.0.1:${ workerPort }`; + logToConsole( `Spawning PHP worker ${ index + 1 }/${ workerPoolSize } on ${ phpAddress }` ); + const serverChild = spawnPhpProcess( [ '-S', phpAddress, ROUTER_PATH ], { + phpVersion, + siteFolder: config.sitePath, + env: { + STUDIO_PHPMYADMIN_PATH: getPhpMyAdminPath(), + STUDIO_NATIVE_PHPMYADMIN_WP_ENV_PATH: phpMyAdminWpEnvPath, + STUDIO_PHPMYADMIN_SESSION_PATH: getPhpMyAdminSessionPath( config ), + }, + onlyPathsThatPhpCanAccess: Array.from( openBasedirAllowlist ), + disallowRiskyFunctions: true, + enableXdebug: config.enableXdebug, + } ); + spawnedChildren.push( serverChild ); + await waitForChildSpawn( serverChild, stopSignal ); + markPhpChildAsCritical( serverChild, `PHP worker ${ index + 1 }/${ workerPoolSize }` ); + } + + stopSignal?.throwIfAborted(); + await Promise.all( + workerPorts.map( ( workerPort ) => + waitForServerReady( `http://127.0.0.1:${ workerPort }/`, stopSignal ) + ) + ); + + proxyServer = await startPhpProxyServer( config, stopSignal ); + phpProxyServer = proxyServer; + phpWorkerProcesses = spawnedChildren; + + stopSignal?.throwIfAborted(); + await waitForServerReady( `http://localhost:${ config.port }/`, stopSignal ); + + startSymlinkWatcher( config.sitePath ); + return spawnedChildren[ 0 ]; + } catch ( error ) { + if ( proxyServer ) { + await new Promise< void >( ( resolve ) => proxyServer.close( () => resolve() ) ).catch( + () => {} + ); + } + for ( const child of spawnedChildren ) { + child.removeAllListeners( 'exit' ); + if ( child.exitCode === null && child.signalCode === null ) { + child.kill( 'SIGKILL' ); + } + } + phpWorkerPorts = []; + phpWorkerProcesses = []; + await stopSymlinkWatcher(); + + throw error; + } +} + enum StopServerResult { ABORTED_STARTUP = 'ABORTED_STARTUP', OK = 'OK', @@ -643,36 +912,22 @@ async function stopServer(): Promise< StopServerResult > { runningConfig = null; currentOpenBasedirAllowlist.clear(); - if ( ! phpProcess ) { + const children = getCurrentPhpProcesses(); + if ( children.length === 0 && ! phpProxyServer ) { logToConsole( 'No server running, nothing to stop' ); return StopServerResult.OK; } - if ( phpProcess.exitCode !== null || phpProcess.signalCode !== null ) { + if ( + children.length > 0 && + children.every( ( child ) => child.exitCode !== null || child.signalCode !== null ) && + ! phpProxyServer + ) { logToConsole( 'Server already stopped' ); return StopServerResult.OK; } - const child = phpProcess; - phpProcess = null; - - child.removeAllListeners( 'exit' ); - - await new Promise< void >( ( resolve ) => { - const forceKillTimeout = setTimeout( () => { - errorToConsole( 'PHP child did not exit in time; sending SIGKILL' ); - if ( ! child.killed ) { - child.kill( 'SIGKILL' ); - } - }, STOP_SERVER_TIMEOUT ); - - child.once( 'exit', () => { - clearTimeout( forceKillTimeout ); - resolve(); - } ); - - child.kill( 'SIGTERM' ); - } ); + await stopCurrentPhpServer(); logToConsole( 'Server stopped gracefully' ); return StopServerResult.OK; @@ -902,15 +1157,27 @@ async function ipcMessageHandler( packet: unknown ) { } function killPhpProcess(): void { - if ( phpProcess && ! phpProcess.killed ) { + try { + phpProxyServer?.close(); + } catch { + // Best effort - nothing useful to do if this fails. + } + phpProxyServer = null; + + for ( const child of getCurrentPhpProcesses() ) { try { // Detach the unexpected-exit listener so the imminent SIGKILL is not logged as a crash. - phpProcess.removeAllListeners( 'exit' ); - phpProcess.kill( 'SIGKILL' ); + child.removeAllListeners( 'exit' ); + if ( child.exitCode === null && child.signalCode === null ) { + child.kill( 'SIGKILL' ); + } } catch { - // Best effort — nothing useful to do if this fails. + // Best effort - nothing useful to do if this fails. } } + phpProcess = null; + phpWorkerProcesses = []; + phpWorkerPorts = []; } function shutdownOnSignal( signal: NodeJS.Signals ): void { From 630d1db849e4c96c736941331a5ecb7af4e18adb Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 27 May 2026 07:47:41 +0100 Subject: [PATCH 2/4] Track the request queue per worker --- apps/cli/php-server-child.ts | 70 ++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index 287f02c5b7..c72f8af0ca 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -89,11 +89,45 @@ async function writeNativePhpMyAdminWpEnv( config: ServerConfig ): Promise< stri return wpEnvPath; } +// Tracks how many proxied requests each PHP worker is currently handling. +// Each `php -S` worker processes one request at a time, so a non-zero count +// means the worker is busy and any additional requests are queued at the TCP +// layer. The picker uses these counts to prefer idle workers, then to balance +// the queue depth when all are busy. +class PhpWorkerRequestTracker { + private readonly counts: number[]; + + constructor( size: number ) { + this.counts = new Array( size ).fill( 0 ); + } + + get( index: number ): number { + return this.counts[ index ] ?? 0; + } + + set( index: number, value: number ): void { + if ( index < 0 || index >= this.counts.length ) { + return; + } + this.counts[ index ] = Math.max( 0, value ); + } + + getFirstFreeWorker(): number { + let bestIndex = 0; + for ( let i = 1; i < this.counts.length; i++ ) { + if ( this.counts[ i ] < this.counts[ bestIndex ] ) { + bestIndex = i; + } + } + return bestIndex; + } +} + let phpProcess: ChildProcess | null = null; let phpWorkerProcesses: ChildProcess[] = []; let phpProxyServer: http.Server | null = null; let phpWorkerPorts: number[] = []; -let nextPhpWorkerIndex = 0; +let phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); let startupAbortController: AbortController | null = null; let startingPromise: Promise< void > | null = null; let blueprintQueue: Promise< unknown > = Promise.resolve(); @@ -121,6 +155,7 @@ function errorToConsole( ...args: Parameters< typeof console.error > ) { function getNativePhpWorkerPoolSize(): number { // POC escape hatch for experimenting with native PHP request concurrency. const parsed = Number.parseInt( process.env.STUDIO_NATIVE_PHP_WORKER_POOL ?? '', 10 ); + console.log( 'getNativePhpWorkerPoolSize', parsed ); if ( ! Number.isFinite( parsed ) || parsed < 2 ) { return DEFAULT_NATIVE_PHP_WORKER_POOL_SIZE; } @@ -141,18 +176,17 @@ function shouldUsePrimaryWorker( req: http.IncomingMessage ): boolean { return false; } -function pickPhpWorkerPort( req: http.IncomingMessage ): number { +function pickPhpWorker( req: http.IncomingMessage ): { index: number; port: number } { if ( phpWorkerPorts.length === 0 ) { throw new Error( 'No PHP worker ports are available' ); } if ( shouldUsePrimaryWorker( req ) ) { - return phpWorkerPorts[ 0 ]; + return { index: 0, port: phpWorkerPorts[ 0 ] }; } - const port = phpWorkerPorts[ nextPhpWorkerIndex % phpWorkerPorts.length ]; - nextPhpWorkerIndex++; - return port; + const bestIndex = phpWorkerRequestTracker.getFirstFreeWorker(); + return { index: bestIndex, port: phpWorkerPorts[ bestIndex ] }; } async function getAvailablePort(): Promise< number > { @@ -550,7 +584,7 @@ async function closePhpProxyServer(): Promise< void > { const proxyServer = phpProxyServer; phpProxyServer = null; phpWorkerPorts = []; - nextPhpWorkerIndex = 0; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); if ( ! proxyServer ) { return; @@ -619,15 +653,26 @@ function proxyRequestToPhpWorker( req: http.IncomingMessage, res: http.ServerResponse ): void { - let targetPort: number; + let worker: { index: number; port: number }; try { - targetPort = pickPhpWorkerPort( req ); + worker = pickPhpWorker( req ); } catch ( error ) { res.writeHead( 503 ); res.end( error instanceof Error ? error.message : String( error ) ); return; } + phpWorkerRequestTracker.set( worker.index, phpWorkerRequestTracker.get( worker.index ) + 1 ); + let released = false; + const release = () => { + if ( released ) { + return; + } + released = true; + phpWorkerRequestTracker.set( worker.index, phpWorkerRequestTracker.get( worker.index ) - 1 ); + }; + res.once( 'close', release ); + const headers = { ...req.headers }; headers.host = req.headers.host ?? `localhost:${ config.port }`; delete headers.connection; @@ -636,7 +681,7 @@ function proxyRequestToPhpWorker( const proxyReq = http.request( { hostname: '127.0.0.1', - port: targetPort, + port: worker.port, path: req.url, method: req.method, headers, @@ -648,6 +693,7 @@ function proxyRequestToPhpWorker( ); proxyReq.on( 'error', ( error ) => { + release(); if ( ! res.headersSent ) { res.writeHead( 502 ); } @@ -838,7 +884,7 @@ async function doStartPooledServer( } phpWorkerPorts = workerPorts; - nextPhpWorkerIndex = 0; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( workerPorts.length ); for ( const [ index, workerPort ] of workerPorts.entries() ) { const phpAddress = `127.0.0.1:${ workerPort }`; @@ -889,6 +935,7 @@ async function doStartPooledServer( } } phpWorkerPorts = []; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); phpWorkerProcesses = []; await stopSymlinkWatcher(); @@ -1178,6 +1225,7 @@ function killPhpProcess(): void { phpProcess = null; phpWorkerProcesses = []; phpWorkerPorts = []; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); } function shutdownOnSignal( signal: NodeJS.Signals ): void { From 98bd09bd3cbfadadbce675c81a6621107c269e53 Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Thu, 28 May 2026 18:58:23 +0100 Subject: [PATCH 3/4] Potential fix for pull request finding 'CodeQL / Information exposure through a stack trace' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/cli/php-server-child.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index c72f8af0ca..b935ce8c19 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -657,8 +657,13 @@ function proxyRequestToPhpWorker( try { worker = pickPhpWorker( req ); } catch ( error ) { + errorToConsole( + `Failed to select PHP worker: ${ + error instanceof Error ? error.stack ?? error.message : String( error ) + }` + ); res.writeHead( 503 ); - res.end( error instanceof Error ? error.message : String( error ) ); + res.end( 'Service temporarily unavailable' ); return; } From daa7d8ac490bed27dcb294508a72e7d367b88f44 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 29 May 2026 17:00:15 +0100 Subject: [PATCH 4/4] Always spawn the php pool the "poor man's" way --- apps/cli/php-server-child.ts | 161 +++++++---------------------- apps/cli/process-manager-daemon.ts | 11 +- apps/cli/tests/daemon.test.ts | 4 +- 3 files changed, 46 insertions(+), 130 deletions(-) diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index 8fc7a43938..36b6625619 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -1,9 +1,11 @@ /** - * WordPress Studio Server Child Process — Native PHP + * Native PHP site server — our "Poor Man's php-fpm". * - * Runs a single WordPress site using the PHP binary's built-in web server - * (`php -S localhost:${port} router.php`), with the site directory as the - * working directory. Shares the IPC contract with `wordpress-server-child.ts`. + * Runs a WordPress site as a fixed pool of `php -S … router.php` workers with a + * Node.js HTTP proxy in front that load-balances requests across them: a cheap + * stand-in for fpm-style process concurrency, not a real FastCGI process manager. + * + * Shares the IPC contract with the Playground-based `wordpress-server-child.ts`. */ import { ChildProcess, spawn } from 'node:child_process'; @@ -142,7 +144,7 @@ let runningConfig: ServerConfig | null = null; const SYMLINK_RESTART_DEBOUNCE_MS = 750; const STOP_SERVER_TIMEOUT = 5000; -const DEFAULT_NATIVE_PHP_WORKER_POOL_SIZE = 1; +const NATIVE_PHP_WORKER_POOL_SIZE = 4; function logToConsole( ...args: Parameters< typeof console.log > ) { console.log( `[PHP Server]`, ...args ); @@ -152,16 +154,6 @@ function errorToConsole( ...args: Parameters< typeof console.error > ) { console.error( `[PHP Server]`, ...args ); } -function getNativePhpWorkerPoolSize(): number { - // POC escape hatch for experimenting with native PHP request concurrency. - const parsed = Number.parseInt( process.env.STUDIO_NATIVE_PHP_WORKER_POOL ?? '', 10 ); - console.log( 'getNativePhpWorkerPoolSize', parsed ); - if ( ! Number.isFinite( parsed ) || parsed < 2 ) { - return DEFAULT_NATIVE_PHP_WORKER_POOL_SIZE; - } - return Math.min( parsed, 8 ); -} - function shouldUsePrimaryWorker( req: http.IncomingMessage ): boolean { const method = req.method?.toUpperCase() ?? 'GET'; if ( ! [ 'GET', 'HEAD', 'OPTIONS' ].includes( method ) ) { @@ -261,19 +253,6 @@ function spawnPhpProcess( type RunPhpCommandOptions = SpawnPhpProcessOptions; -function killProcessGroup( child: ChildProcess, signal: NodeJS.Signals ): void { - if ( process.platform !== 'win32' && child.pid ) { - try { - process.kill( -child.pid, signal ); - return; - } catch { - // Fall back to the parent process if the process group is already gone. - } - } - - child.kill( signal ); -} - async function runPhpCommand( args: string[], options: RunPhpCommandOptions @@ -688,13 +667,6 @@ async function waitForChildSpawn( child: ChildProcess, signal?: AbortSignal ): P } ); } -function markPhpChildAsCritical( child: ChildProcess, label: string ): void { - child.once( 'exit', ( code, signalName ) => { - errorToConsole( `${ label } exited unexpectedly (code: ${ code }, signal: ${ signalName })` ); - process.exit( code ?? 1 ); - } ); -} - function proxyRequestToPhpWorker( config: ServerConfig, req: http.IncomingMessage, @@ -848,104 +820,19 @@ async function doStartServer( config: ServerConfig, openBasedirAllowlist: Set< string >, stopSignal?: AbortSignal -): Promise< ChildProcess > { - const workerPoolSize = getNativePhpWorkerPoolSize(); - if ( workerPoolSize > 1 ) { - return await doStartPooledServer( config, openBasedirAllowlist, workerPoolSize, stopSignal ); - } - - const phpAddress = `localhost:${ config.port }`; - const phpVersion = resolveNativePhpVersion( config.phpVersion ?? '' ); - let spawnedChild: ChildProcess | null = null; - - logToConsole( - `Spawning PHP built-in server on ${ phpAddress } with PHP version ${ phpVersion }` - ); - - try { - const phpMyAdminWpEnvPath = await writeNativePhpMyAdminWpEnv( config ); - const serverChild = spawnPhpProcess( [ '-S', phpAddress, ROUTER_PATH ], { - phpVersion, - siteFolder: config.sitePath, - env: { - STUDIO_PHPMYADMIN_PATH: getPhpMyAdminPath(), - STUDIO_NATIVE_PHPMYADMIN_WP_ENV_PATH: phpMyAdminWpEnvPath, - STUDIO_PHPMYADMIN_SESSION_PATH: getPhpMyAdminSessionPath( config ), - // Lets `php -S` serve concurrent requests so a single slow request - // doesn't block the whole site. Unix-only — Windows silently ignores it - // because the built-in server has no fork() there. - PHP_CLI_SERVER_WORKERS: '4', - }, - onlyPathsThatPhpCanAccess: Array.from( openBasedirAllowlist ), - detached: process.platform !== 'win32', - disallowRiskyFunctions: true, - enableXdebug: config.enableXdebug, - } ); - spawnedChild = serverChild; - if ( serverChild.pid !== undefined ) { - const message: ChildMessageRaw = { - topic: 'server-process-started', - data: { pid: serverChild.pid }, - }; - process.send?.( message ); - } - - await new Promise< void >( ( resolve, reject ) => { - serverChild.once( 'spawn', () => { - resolve(); - } ); - serverChild.once( 'error', ( error: Error ) => { - reject( error ); - } ); - stopSignal?.addEventListener( 'abort', () => { - reject( new DOMException( 'Aborted', 'AbortError' ) ); - } ); - } ); - - serverChild.once( 'exit', ( code, signalName ) => { - errorToConsole( - `PHP child process exited unexpectedly (code: ${ code }, signal: ${ signalName })` - ); - process.exit( code ?? 1 ); - } ); - - stopSignal?.throwIfAborted(); - await waitForServerReady( `http://localhost:${ config.port }/`, stopSignal ); - - // Watch for symlinks created after startup. open_basedir cannot be extended - // at runtime, so the watcher triggers a debounced restart with an updated - // allowlist when a new symlink target is discovered. - startSymlinkWatcher( config.sitePath ); - - return spawnedChild; - } catch ( error ) { - if ( spawnedChild ) { - killProcessGroup( spawnedChild, 'SIGKILL' ); - } - await stopSymlinkWatcher(); - - throw error; - } -} - -async function doStartPooledServer( - config: ServerConfig, - openBasedirAllowlist: Set< string >, - workerPoolSize: number, - stopSignal?: AbortSignal ): Promise< ChildProcess > { const phpVersion = resolveNativePhpVersion( config.phpVersion ?? '' ); const spawnedChildren: ChildProcess[] = []; let proxyServer: http.Server | null = null; logToConsole( - `Spawning native PHP worker pool with ${ workerPoolSize } workers on public port ${ config.port }` + `Spawning native PHP worker pool with ${ NATIVE_PHP_WORKER_POOL_SIZE } workers on public port ${ config.port }` ); try { const phpMyAdminWpEnvPath = await writeNativePhpMyAdminWpEnv( config ); const workerPorts: number[] = []; - for ( let index = 0; index < workerPoolSize; index++ ) { + for ( let index = 0; index < NATIVE_PHP_WORKER_POOL_SIZE; index++ ) { workerPorts.push( await getAvailablePort() ); } @@ -954,7 +841,11 @@ async function doStartPooledServer( for ( const [ index, workerPort ] of workerPorts.entries() ) { const phpAddress = `127.0.0.1:${ workerPort }`; - logToConsole( `Spawning PHP worker ${ index + 1 }/${ workerPoolSize } on ${ phpAddress }` ); + logToConsole( + `Spawning PHP worker ${ index + 1 }/${ NATIVE_PHP_WORKER_POOL_SIZE } on ${ phpAddress }` + ); + // Workers are spawned without `detached`, so they share this wrapper's process + // group. That lets the daemon's group-kill reach every worker in one signal. const serverChild = spawnPhpProcess( [ '-S', phpAddress, ROUTER_PATH ], { phpVersion, siteFolder: config.sitePath, @@ -968,8 +859,27 @@ async function doStartPooledServer( enableXdebug: config.enableXdebug, } ); spawnedChildren.push( serverChild ); + + // Report every worker pid to the daemon. The shared process group already lets + // the daemon clean these up, but the individual pids give it a direct fallback. + if ( serverChild.pid !== undefined ) { + const message: ChildMessageRaw = { + topic: 'server-process-started', + data: { pid: serverChild.pid }, + }; + process.send?.( message ); + } + await waitForChildSpawn( serverChild, stopSignal ); - markPhpChildAsCritical( serverChild, `PHP worker ${ index + 1 }/${ workerPoolSize }` ); + + serverChild.once( 'exit', ( code, signalName ) => { + errorToConsole( + `PHP worker ${ + index + 1 + }/${ NATIVE_PHP_WORKER_POOL_SIZE } exited unexpectedly (code: ${ code }, signal: ${ signalName })` + ); + process.exit( code ?? 1 ); + } ); } stopSignal?.throwIfAborted(); @@ -986,6 +896,9 @@ async function doStartPooledServer( stopSignal?.throwIfAborted(); await waitForServerReady( `http://localhost:${ config.port }/`, stopSignal ); + // Watch for symlinks created after startup. open_basedir cannot be extended + // at runtime, so the watcher triggers a debounced restart with an updated + // allowlist when a new symlink target is discovered. startSymlinkWatcher( config.sitePath ); return spawnedChildren[ 0 ]; } catch ( error ) { diff --git a/apps/cli/process-manager-daemon.ts b/apps/cli/process-manager-daemon.ts index e9fea3d578..4f2537d53b 100644 --- a/apps/cli/process-manager-daemon.ts +++ b/apps/cli/process-manager-daemon.ts @@ -486,8 +486,8 @@ export class ProcessManagerDaemon { } // Children are spawned with `detached: true` on non-Windows, so each lives in its own - // process group. Native PHP can spawn the PHP server in its own group too, so signal both - // when the wrapper reports that pid. + // process group. Native PHP spawns its PHP workers inside the wrapper's group, so this + // group signal already reaches them. try { process.kill( -pid, signal ); } catch { @@ -499,10 +499,13 @@ export class ProcessManagerDaemon { } } + // Belt-and-suspenders: signal each reported worker pid directly too. They share the + // wrapper's group (handled above), but tracking the pids lets us still terminate any + // worker that somehow outlived or escaped the group. if ( managedProcess.grandchildrenPids ) { - for ( const pid of managedProcess.grandchildrenPids ) { + for ( const grandchildPid of managedProcess.grandchildrenPids ) { try { - process.kill( -pid, signal ); + process.kill( grandchildPid, signal ); } catch { // Do nothing } diff --git a/apps/cli/tests/daemon.test.ts b/apps/cli/tests/daemon.test.ts index fba8100d77..a65503f23b 100644 --- a/apps/cli/tests/daemon.test.ts +++ b/apps/cli/tests/daemon.test.ts @@ -239,7 +239,7 @@ describe( 'ProcessManagerDaemon', () => { } ); it.skipIf( process.platform === 'win32' )( - 'signals a reported subprocess process group when killing the wrapper', + 'signals the wrapper group and each reported subprocess pid when killing the wrapper', async () => { const child = new MockChildProcess(); spawnMock.mockReturnValue( child ); @@ -280,7 +280,7 @@ describe( 'ProcessManagerDaemon', () => { try { await daemonInternal.signalProcessGroup( managedProcess, 'SIGKILL' ); expect( killSpy ).toHaveBeenCalledWith( -4321, 'SIGKILL' ); - expect( killSpy ).toHaveBeenCalledWith( -9876, 'SIGKILL' ); + expect( killSpy ).toHaveBeenCalledWith( 9876, 'SIGKILL' ); } finally { killSpy.mockRestore(); }