From e9db4d46f80963724c5cc05ac6a38ea5d0e3bfa6 Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Fri, 22 May 2026 17:04:52 +0100 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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(); } From 7700985460ccc831415d8f21ac927e8daa328ca9 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 10:43:55 +0200 Subject: [PATCH 05/19] Improve grandchild process cleanup on Windows --- apps/cli/process-manager-daemon.ts | 91 ++++++++++++++++-------------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/apps/cli/process-manager-daemon.ts b/apps/cli/process-manager-daemon.ts index 4f2537d53b..69725e773f 100644 --- a/apps/cli/process-manager-daemon.ts +++ b/apps/cli/process-manager-daemon.ts @@ -454,63 +454,72 @@ export class ProcessManagerDaemon { if ( process.platform === 'win32' ) { if ( signal === 'SIGKILL' ) { - // Windows has no process-group concept Node can reach. /T walks the descendant - // tree via parent-PID lookup; /F forces termination. Without /T, grandchildren - // (e.g. the PHP server spawned by the wrapper) would be orphaned. - spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { - windowsHide: true, - stdio: 'ignore', - } ); - return; - } - // Console apps on Windows have no SIGTERM equivalent — `child.kill( 'SIGTERM' )` - // maps to TerminateProcess of a single PID, so neither cleanup nor tree-walk runs. - // Closing the IPC channel triggers the wrapper's 'disconnect' handler instead, which - // kills the PHP child and exits cleanly. Force escalation falls back to taskkill /T. - if ( managedProcess.child.connected ) { + // Windows has no process group Node can reach; taskkill it instead. + this.taskkillTree( pid, true ); + } else if ( managedProcess.child.connected ) { + // Console apps have no SIGTERM equivalent, so close the IPC channel: the wrapper's + // 'disconnect' handler kills the PHP child and exits cleanly. try { managedProcess.child.disconnect(); - // Wait very briefly to allow the disconnect handler to run in the child process + // Give the disconnect handler a moment to run in the child. await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); } catch { // Do nothing } - return; - } - try { - managedProcess.child.kill( signal ); - } catch { - // Do nothing + } else { + try { + managedProcess.child.kill( signal ); + } catch { + // Do nothing + } } - return; - } - // Children are spawned with `detached: true` on non-Windows, so each lives in its own - // 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 { - // Group send can fail if the leader has already exited but children remain. + // A reported grandchild (native PHP's server) is orphaned once the wrapper exits during + // the SIGTERM/disconnect path, leaving it outside the main child's /T tree, so taskkill + // it directly. The SIGTERM → SIGKILL escalation in stopProcess gives it a graceful + // attempt before forcing termination. + if ( managedProcess.grandchildrenPids ) { + for ( const grandchildPid of managedProcess.grandchildrenPids ) { + this.taskkillTree( grandchildPid, signal === 'SIGKILL' ); + } + } + } else { + // Non-Windows children are spawned `detached`, so each leads its own process group; native + // PHP's server may lead another. Signal both groups when the wrapper reports the pid. try { - managedProcess.child.kill( signal ); + process.kill( -pid, signal ); } catch { - // Do nothing - } - } - - // 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 grandchildPid of managedProcess.grandchildrenPids ) { + // Group send can fail if the leader has already exited but children remain. try { - process.kill( grandchildPid, signal ); + managedProcess.child.kill( signal ); } catch { // Do nothing } } + + if ( managedProcess.grandchildrenPids ) { + for ( const pid of managedProcess.grandchildrenPids ) { + try { + process.kill( -pid, signal ); + } catch { + // Do nothing + } + } + } + } + } + + // Terminates a Windows process tree via taskkill: /T walks descendants by parent-PID; /F + // forces termination, otherwise taskkill requests a graceful close console apps may ignore. + private taskkillTree( pid: number, force: boolean ): void { + const args = [ '/T', '/PID', String( pid ) ]; + if ( force ) { + args.unshift( '/F' ); } + spawnSync( 'taskkill', args, { + windowsHide: true, + stdio: 'ignore', + } ); } private async shutdown( reason?: string ): Promise< void > { From fd3df18e2a2c07a8809f2225e2cffc14ca3339a2 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 10:55:26 +0200 Subject: [PATCH 06/19] Unit test --- apps/cli/tests/daemon.test.ts | 64 +++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/daemon.test.ts b/apps/cli/tests/daemon.test.ts index a65503f23b..06dc61e125 100644 --- a/apps/cli/tests/daemon.test.ts +++ b/apps/cli/tests/daemon.test.ts @@ -25,10 +25,12 @@ class MockChildProcess extends EventEmitter { } const spawnMock = vi.fn(); +const spawnSyncMock = vi.fn(); vi.mock( 'child_process', () => { const mockedModule = { spawn: spawnMock, + spawnSync: spawnSyncMock, }; return { ...mockedModule, @@ -239,7 +241,7 @@ describe( 'ProcessManagerDaemon', () => { } ); it.skipIf( process.platform === 'win32' )( - 'signals the wrapper group and each reported subprocess pid when killing the wrapper', + 'signals the wrapper group and each reported subprocess group when killing the wrapper', async () => { const child = new MockChildProcess(); spawnMock.mockReturnValue( child ); @@ -280,10 +282,68 @@ 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(); } } ); + + it( 'taskkills the wrapper and reported subprocess trees on Windows', async () => { + const child = new MockChildProcess(); + spawnMock.mockReturnValue( child ); + const { ProcessManagerDaemon } = await import( '../process-manager-daemon' ); + + const daemon = new ProcessManagerDaemon(); + const daemonInternal = daemon as unknown as { + handleRequest: ( request: unknown ) => Promise< { + type: string; + payload: { process?: { pmId: number; name: string; status: string; pid?: number } }; + } >; + managedProcesses: Map< number, unknown >; + signalProcessGroup: ( managedProcess: unknown, signal: NodeJS.Signals ) => Promise< void >; + }; + + const response = await daemonInternal.handleRequest( { + type: 'start-process', + requestId: '1', + processName: testProcessName, + scriptPath: '/tmp/test-child.js', + env: {}, + args: [], + } ); + + const processDesc = response.payload.process; + if ( ! processDesc ) { + throw new Error( 'Expected start-process response to include a process' ); + } + + child.emit( 'message', { topic: 'server-process-started', data: { pid: 9876 } } ); + + const managedProcess = daemonInternal.managedProcesses.get( processDesc.pmId ); + if ( ! managedProcess ) { + throw new Error( 'Expected process manager to store the managed process' ); + } + + const originalPlatform = process.platform; + Object.defineProperty( process, 'platform', { value: 'win32', configurable: true } ); + try { + await daemonInternal.signalProcessGroup( managedProcess, 'SIGKILL' ); + expect( spawnSyncMock ).toHaveBeenCalledWith( + 'taskkill', + [ '/F', '/T', '/PID', '4321' ], + expect.objectContaining( { windowsHide: true } ) + ); + expect( spawnSyncMock ).toHaveBeenCalledWith( + 'taskkill', + [ '/F', '/T', '/PID', '9876' ], + expect.objectContaining( { windowsHide: true } ) + ); + } finally { + Object.defineProperty( process, 'platform', { + value: originalPlatform, + configurable: true, + } ); + } + } ); } ); From ea71db11815c27bdca8b8b85d7f0ed0d85068b7f Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 1 Jun 2026 10:02:21 +0100 Subject: [PATCH 07/19] Refactor native PHP child helpers --- apps/cli/lib/native-php/blueprints.ts | 92 +++++ apps/cli/lib/native-php/php-process.ts | 143 +++++++ apps/cli/lib/native-php/phpmyadmin.ts | 45 +++ apps/cli/lib/native-php/site-setup.ts | 171 ++++++++ apps/cli/php-server-child.ts | 526 +++---------------------- 5 files changed, 501 insertions(+), 476 deletions(-) create mode 100644 apps/cli/lib/native-php/blueprints.ts create mode 100644 apps/cli/lib/native-php/php-process.ts create mode 100644 apps/cli/lib/native-php/phpmyadmin.ts create mode 100644 apps/cli/lib/native-php/site-setup.ts diff --git a/apps/cli/lib/native-php/blueprints.ts b/apps/cli/lib/native-php/blueprints.ts new file mode 100644 index 0000000000..36d7217efd --- /dev/null +++ b/apps/cli/lib/native-php/blueprints.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; +import { getBlueprintsPharPath } from 'cli/lib/dependency-management/paths'; +import { runPhpCommand } from './php-process'; +import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; +import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; + +export async function runBlueprint( + config: ServerConfig, + blueprint: NonNullable< ServerConfig[ 'blueprint' ] >, + phpVersion: NativePhpSupportedVersion, + signal: AbortSignal +): Promise< void > { + // blueprints.phar accepts local paths only; remote URIs need the Playground runtime. + if ( blueprint.uri.startsWith( 'http://' ) || blueprint.uri.startsWith( 'https://' ) ) { + throw new Error( + `Remote blueprint URIs are not supported by the native PHP runtime: ${ blueprint.uri }` + ); + } + + const enableDebugLog = config.enableDebugLog ?? false; + const enableDebugDisplay = config.enableDebugDisplay ?? false; + const defaultConstants: Record< string, boolean | string > = { + // The SQLite driver requires a non-empty DB_NAME at runtime. + DB_NAME: 'wordpress', + WP_DEBUG: enableDebugLog || enableDebugDisplay, + WP_DEBUG_LOG: enableDebugLog, + WP_DEBUG_DISPLAY: enableDebugDisplay, + }; + + const preferredVersions = { + php: config.phpVersion || blueprint.contents?.preferredVersions?.php || DEFAULT_PHP_VERSION, + wp: config.wpVersion || blueprint.contents?.preferredVersions?.wp || 'latest', + }; + blueprint.contents.constants = { + ...blueprint.contents.constants, + ...defaultConstants, + }; + blueprint.contents.preferredVersions = preferredVersions; + + const blueprintDir = path.dirname( blueprint.uri ); + const tmpPath = path.join( blueprintDir, `studio-blueprint-${ config.siteId }.json` ); + await fs.promises.writeFile( tmpPath, JSON.stringify( blueprint.contents ) ); + + // blueprints.phar detects SQLite under plugins, while Studio installs it under mu-plugins. + const muPluginsSqlite = path.join( + config.sitePath, + 'wp-content', + 'mu-plugins', + 'sqlite-database-integration' + ); + const pluginsSqlite = path.join( + config.sitePath, + 'wp-content', + 'plugins', + 'sqlite-database-integration' + ); + const needsSymlink = fs.existsSync( muPluginsSqlite ) && ! fs.existsSync( pluginsSqlite ); + let symlinkIno: number | undefined; + if ( needsSymlink ) { + fs.symlinkSync( muPluginsSqlite, pluginsSqlite, 'junction' ); + // Remove only the entry created here, not unrelated content that replaced it. + symlinkIno = fs.statSync( pluginsSqlite ).ino; + } + + try { + await runPhpCommand( + [ + getBlueprintsPharPath(), + 'exec', + tmpPath, + '--mode=apply-to-existing-site', + `--site-path=${ config.sitePath }`, + `--site-url=${ config.absoluteUrl ?? `http://localhost:${ config.port }` }`, + '--db-engine=sqlite', + ], + { phpVersion, signal } + ); + } finally { + await fs.promises.unlink( tmpPath ).catch( () => {} ); + if ( needsSymlink ) { + try { + if ( fs.statSync( pluginsSqlite ).ino === symlinkIno ) { + await fs.promises.rm( pluginsSqlite, { recursive: true, force: true } ); + } + } catch { + // Best effort - leaving the symlink behind is non-fatal. + } + } + } +} diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts new file mode 100644 index 0000000000..004816bf92 --- /dev/null +++ b/apps/cli/lib/native-php/php-process.ts @@ -0,0 +1,143 @@ +import { ChildProcess, spawn } from 'node:child_process'; +import { getPhpBinaryPath } from 'cli/lib/dependency-management/paths'; +import { getDefaultPhpArgs } from 'cli/lib/native-php'; +import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; + +type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void; + +export type SpawnPhpProcessOptions = { + disallowRiskyFunctions?: boolean; + env?: NodeJS.ProcessEnv; + mode?: 'pipe' | 'capture-stdout'; + enableXdebug?: boolean; + onlyPathsThatPhpCanAccess?: string[]; + phpVersion: NativePhpSupportedVersion; + siteFolder?: string; + signal?: AbortSignal; +}; + +export function spawnPhpProcess( + args: string[], + { + phpVersion, + siteFolder, + signal, + env, + mode = 'pipe', + enableXdebug = false, + onlyPathsThatPhpCanAccess = [], + disallowRiskyFunctions = false, + }: SpawnPhpProcessOptions +): ChildProcess { + const defaultArgs = getDefaultPhpArgs( + phpVersion, + onlyPathsThatPhpCanAccess, + disallowRiskyFunctions, + enableXdebug + ); + const phpArgs = [ ...defaultArgs, ...args ]; + const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), phpArgs, { + cwd: siteFolder, + env: env ? { ...process.env, ...env } : process.env, + stdio: [ 'ignore', 'pipe', 'pipe' ], + signal, + } ); + + if ( mode === 'pipe' ) { + phpScriptProcess.stdout?.pipe( process.stdout, { end: false } ); + } + + if ( mode === 'pipe' || mode === 'capture-stdout' ) { + phpScriptProcess.stderr?.pipe( process.stderr, { end: false } ); + } + + return phpScriptProcess; +} + +type RunPhpCommandOptions = SpawnPhpProcessOptions; + +export async function runPhpCommand( + args: string[], + options: RunPhpCommandOptions +): Promise< { stdout: string } > { + return await new Promise< { stdout: string } >( ( resolve, reject ) => { + const phpScriptProcess = spawnPhpProcess( args, options ); + + let stdout = ''; + const reportActivity = () => process.send?.( { topic: 'activity' } ); + phpScriptProcess.stdout?.on( 'data', ( chunk ) => { + reportActivity(); + if ( options.mode === 'capture-stdout' ) { + stdout += chunk.toString(); + } + } ); + phpScriptProcess.stderr?.on( 'data', reportActivity ); + + phpScriptProcess.once( 'error', ( error: Error ) => { + reject( error ); + } ); + phpScriptProcess.once( 'close', ( code ) => { + if ( code === 0 ) { + resolve( { stdout } ); + return; + } + + reject( new Error( `PHP command failed (code: ${ code })` ) ); + } ); + } ); +} + +export 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' ) ); + } ); + } ); +} + +export async function stopPhpChild( + child: ChildProcess, + timeoutMs: number, + errorToConsole: ErrorLogger +): 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' ); + } + }, timeoutMs ); + + child.once( 'close', () => { + clearTimeout( forceKillTimeout ); + resolve(); + } ); + + child.kill( 'SIGTERM' ); + } ); +} + +export function markPhpChildAsCritical( + child: ChildProcess, + label: string, + errorToConsole: ErrorLogger +): void { + child.once( 'exit', ( code, signalName ) => { + errorToConsole( `${ label } exited unexpectedly (code: ${ code }, signal: ${ signalName })` ); + process.exit( code ?? 1 ); + } ); +} diff --git a/apps/cli/lib/native-php/phpmyadmin.ts b/apps/cli/lib/native-php/phpmyadmin.ts new file mode 100644 index 0000000000..15136a160a --- /dev/null +++ b/apps/cli/lib/native-php/phpmyadmin.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; + +function phpStringLiteral( value: string ): string { + return `'${ value.replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ) }'`; +} + +export function getNativePhpMyAdminWpEnvPath( config: Pick< ServerConfig, 'siteId' > ): string { + const safeSiteId = config.siteId.replace( /[^a-zA-Z0-9._-]/g, '-' ); + return path.join( os.tmpdir(), 'studio-phpmyadmin-wp-env', safeSiteId, 'wp-env.php' ); +} + +export function getPhpMyAdminSessionPath( config: Pick< ServerConfig, 'siteId' > ): string { + const safeSiteId = config.siteId.replace( /[^a-zA-Z0-9._-]/g, '-' ); + return path.join( os.tmpdir(), 'studio-phpmyadmin-sessions', safeSiteId ); +} + +export async function writeNativePhpMyAdminWpEnv( config: ServerConfig ): Promise< string > { + const wpEnvPath = getNativePhpMyAdminWpEnvPath( config ); + const sqliteDriverPath = path.join( + config.sitePath, + 'wp-content', + 'mu-plugins', + 'sqlite-database-integration', + 'wp-includes', + 'database', + 'load.php' + ); + const sqliteDatabasePath = path.join( config.sitePath, 'wp-content', 'database', '.ht.sqlite' ); + const wpEnvPhp = ` array ( + 'type' => 'sqlite', + 'path' => ${ phpStringLiteral( sqliteDatabasePath ) }, + 'driver_path' => ${ phpStringLiteral( sqliteDriverPath ) }, + ), +); +`; + + await fs.promises.mkdir( path.dirname( wpEnvPath ), { recursive: true } ); + await fs.promises.mkdir( getPhpMyAdminSessionPath( config ), { recursive: true } ); + await fs.promises.writeFile( wpEnvPath, wpEnvPhp ); + return wpEnvPath; +} diff --git a/apps/cli/lib/native-php/site-setup.ts b/apps/cli/lib/native-php/site-setup.ts new file mode 100644 index 0000000000..0b266a67c1 --- /dev/null +++ b/apps/cli/lib/native-php/site-setup.ts @@ -0,0 +1,171 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; +import { decodePassword } from '@studio/common/lib/passwords'; +import { getWpCliPharPath } from 'cli/lib/dependency-management/paths'; +import { runPhpCommand } from './php-process'; +import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; +import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; + +const DEFAULT_WP_CONFIG_CONSTANTS = { DB_NAME: 'wordpress' } as const; + +type Logger = ( ...args: Parameters< typeof console.log > ) => void; + +export async function ensureWpConfig( + siteFolder: string, + phpVersion: NativePhpSupportedVersion, + signal: AbortSignal, + wpConfigTransformerPath: string, + config?: Pick< ServerConfig, 'enableDebugLog' | 'enableDebugDisplay' > +): Promise< void > { + const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); + const wpConfigSamplePath = path.join( siteFolder, 'wp-config-sample.php' ); + const ensureWpConfigScript = ` +$transformer_path = $argv[1] ?? ''; +$wp_config_path = $argv[2] ?? ''; +$constants = json_decode( $argv[3] ?? '', true ); + +require_once $transformer_path; + +$transformer = WP_Config_Transformer::from_file( $wp_config_path ); +$transformer->define_constants( $constants ); +$transformer->to_file( $wp_config_path ); +`; + + if ( ! fs.existsSync( wpConfigPath ) && fs.existsSync( wpConfigSamplePath ) ) { + await fs.promises.copyFile( wpConfigSamplePath, wpConfigPath ); + } + + const enableDebugLog = config?.enableDebugLog ?? false; + const enableDebugDisplay = config?.enableDebugDisplay ?? false; + const constants = { + ...DEFAULT_WP_CONFIG_CONSTANTS, + WP_DEBUG: enableDebugLog || enableDebugDisplay, + WP_DEBUG_LOG: enableDebugLog, + WP_DEBUG_DISPLAY: enableDebugDisplay, + }; + + try { + await runPhpCommand( + [ + '-r', + ensureWpConfigScript, + wpConfigTransformerPath, + wpConfigPath, + JSON.stringify( constants ), + ], + { phpVersion, signal } + ); + } catch ( error ) { + throw new Error( + `Failed to ensure wp-config.php constants: ${ + error instanceof Error ? error.message : String( error ) + }` + ); + } +} + +export async function isWordPressInstalled( + siteFolder: string, + phpVersion: NativePhpSupportedVersion, + signal: AbortSignal +): Promise< boolean > { + const installationCheckScript = ` +error_reporting( E_ERROR ); +ini_set( 'display_errors', '0' ); + +$wp_load = getcwd() . '/wp-load.php'; +if ( ! file_exists( $wp_load ) ) { + echo '0'; + exit( 0 ); +} +require_once $wp_load; +echo is_blog_installed() ? '1' : '0'; +`; + + let stdout = ''; + try { + const result = await runPhpCommand( [ '-r', installationCheckScript ], { + phpVersion, + siteFolder, + signal, + mode: 'capture-stdout', + } ); + stdout = result.stdout; + } catch ( error ) { + throw new Error( + `Failed to check WordPress installation status: ${ + error instanceof Error ? error.message : String( error ) + }` + ); + } + + const status = stdout.trim(); + return status === '1'; +} + +export async function installWordPress( + config: ServerConfig, + phpVersion: NativePhpSupportedVersion, + signal: AbortSignal, + setDefaultPermalinksPath: string, + logToConsole: Logger +): Promise< void > { + const alreadyInstalled = await isWordPressInstalled( config.sitePath, phpVersion, signal ); + if ( alreadyInstalled ) { + logToConsole( `WordPress already installed; skipping installer` ); + return; + } + + const siteTitle = config.siteTitle ?? 'My WordPress Website'; + const username = config.adminUsername ?? 'admin'; + const password = config.adminPassword ? decodePassword( config.adminPassword ) : 'password'; + const email = config.adminEmail ?? 'admin@localhost.com'; + const siteUrl = config.absoluteUrl ?? `http://localhost:${ config.port }`; + // WP-CLI defaults to en_US; Studio's DEFAULT_LOCALE of "en" is not a WP locale code. + const locale = + config.siteLanguage && config.siteLanguage !== DEFAULT_LOCALE ? config.siteLanguage : undefined; + + await runPhpCommand( + [ + getWpCliPharPath(), + 'core', + 'install', + `--path=${ config.sitePath }`, + `--url=${ siteUrl }`, + `--title=${ siteTitle }`, + `--admin_user=${ username }`, + `--admin_password=${ password }`, + `--admin_email=${ email }`, + ...( locale ? [ `--locale=${ locale }` ] : [] ), + '--skip-email', + ], + { phpVersion, signal } + ); + + await runPhpCommand( + [ + getWpCliPharPath(), + 'option', + 'update', + 'studio_admin_username', + username, + `--path=${ config.sitePath }`, + ], + { phpVersion, signal } + ); + + try { + await runPhpCommand( [ setDefaultPermalinksPath ], { + phpVersion, + siteFolder: config.sitePath, + signal, + } ); + } catch ( error ) { + throw new Error( + `Failed to set default permalinks: ${ + error instanceof Error ? error.message : String( error ) + }` + ); + } +} diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index c72f8af0ca..129630185f 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -6,34 +6,34 @@ * working directory. Shares the IPC contract with `wordpress-server-child.ts`. */ -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'; -import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; import { writeStudioMuPluginsForNativePhpRuntime } from '@studio/common/lib/mu-plugins'; -import { decodePassword } from '@studio/common/lib/passwords'; -import { - NativePhpSupportedVersion, - resolveNativePhpVersion, -} from '@studio/common/lib/php-binary-metadata'; +import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; import { z } from 'zod'; import { managerMessageSchema, ChildMessageRaw, ServerConfig, } from 'cli/lib/types/wordpress-server-ipc'; +import { getPhpMyAdminPath } from './lib/dependency-management/paths'; +import { runBlueprint } from './lib/native-php/blueprints'; +import { + markPhpChildAsCritical, + spawnPhpProcess, + stopPhpChild, + waitForChildSpawn, +} from './lib/native-php/php-process'; import { - getBlueprintsPharPath, - getPhpBinaryPath, - getPhpMyAdminPath, - getWpCliPharPath, -} from './lib/dependency-management/paths'; -import { getDefaultPhpArgs } from './lib/native-php'; + getNativePhpMyAdminWpEnvPath, + getPhpMyAdminSessionPath, + writeNativePhpMyAdminWpEnv, +} from './lib/native-php/phpmyadmin'; +import { ensureWpConfig, installWordPress } from './lib/native-php/site-setup'; import { SymlinkWatcher, collectSymlinkAllowlistEntries } from './lib/symlinks'; +import type { ChildProcess } from 'node:child_process'; const ROUTER_PATH = path.resolve( import.meta.dirname, 'php', 'router.php' ); const SET_DEFAULT_PERMALINKS_PATH = path.resolve( @@ -46,48 +46,6 @@ const WP_CONFIG_TRANSFORMER_PATH = path.resolve( 'php', 'wp-config-transformer.php' ); -const DEFAULT_WP_CONFIG_CONSTANTS = { DB_NAME: 'wordpress' } as const; - -function phpStringLiteral( value: string ): string { - return `'${ value.replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ) }'`; -} - -function getNativePhpMyAdminWpEnvPath( config: Pick< ServerConfig, 'siteId' > ): string { - const safeSiteId = config.siteId.replace( /[^a-zA-Z0-9._-]/g, '-' ); - return path.join( os.tmpdir(), 'studio-phpmyadmin-wp-env', safeSiteId, 'wp-env.php' ); -} - -function getPhpMyAdminSessionPath( config: Pick< ServerConfig, 'siteId' > ): string { - const safeSiteId = config.siteId.replace( /[^a-zA-Z0-9._-]/g, '-' ); - return path.join( os.tmpdir(), 'studio-phpmyadmin-sessions', safeSiteId ); -} - -async function writeNativePhpMyAdminWpEnv( config: ServerConfig ): Promise< string > { - const wpEnvPath = getNativePhpMyAdminWpEnvPath( config ); - const sqliteDriverPath = path.join( - config.sitePath, - 'wp-content', - 'mu-plugins', - 'sqlite-database-integration', - 'wp-includes', - 'database', - 'load.php' - ); - const sqliteDatabasePath = path.join( config.sitePath, 'wp-content', 'database', '.ht.sqlite' ); - const wpEnvPhp = ` array ( - 'type' => 'sqlite', - 'path' => ${ phpStringLiteral( sqliteDatabasePath ) }, - 'driver_path' => ${ phpStringLiteral( sqliteDriverPath ) }, - ), -); -`; - - await fs.promises.mkdir( path.dirname( wpEnvPath ), { recursive: true } ); - await fs.promises.mkdir( getPhpMyAdminSessionPath( config ), { recursive: true } ); - await fs.promises.writeFile( wpEnvPath, wpEnvPhp ); - 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 @@ -206,181 +164,6 @@ async function getAvailablePort(): Promise< number > { } ); } -type SpawnPhpProcessOptions = { - disallowRiskyFunctions?: boolean; - env?: NodeJS.ProcessEnv; - mode?: 'pipe' | 'capture-stdout'; - enableXdebug?: boolean; - onlyPathsThatPhpCanAccess?: string[]; - phpVersion: NativePhpSupportedVersion; - siteFolder?: string; - signal?: AbortSignal; -}; - -function spawnPhpProcess( - args: string[], - { - phpVersion, - siteFolder, - signal, - env, - mode = 'pipe', - enableXdebug = false, - onlyPathsThatPhpCanAccess = [], - disallowRiskyFunctions = false, - }: SpawnPhpProcessOptions -): ChildProcess { - const defaultArgs = getDefaultPhpArgs( - phpVersion, - onlyPathsThatPhpCanAccess, - disallowRiskyFunctions, - enableXdebug - ); - const phpArgs = [ ...defaultArgs, ...args ]; - const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), phpArgs, { - cwd: siteFolder, - env: env ? { ...process.env, ...env } : process.env, - stdio: [ 'ignore', 'pipe', 'pipe' ], - signal, - } ); - - if ( mode === 'pipe' ) { - phpScriptProcess.stdout?.pipe( process.stdout, { end: false } ); - } - - // Keep stderr visible in all modes for easier debugging. - if ( mode === 'pipe' || mode === 'capture-stdout' ) { - phpScriptProcess.stderr?.pipe( process.stderr, { end: false } ); - } - - return phpScriptProcess; -} - -type RunPhpCommandOptions = SpawnPhpProcessOptions; - -async function runPhpCommand( - args: string[], - options: RunPhpCommandOptions -): Promise< { stdout: string } > { - return await new Promise< { stdout: string } >( ( resolve, reject ) => { - const phpScriptProcess = spawnPhpProcess( args, options ); - - let stdout = ''; - const reportActivity = () => process.send?.( { topic: 'activity' } ); - phpScriptProcess.stdout?.on( 'data', ( chunk ) => { - reportActivity(); - if ( options.mode === 'capture-stdout' ) { - stdout += chunk.toString(); - } - } ); - phpScriptProcess.stderr?.on( 'data', reportActivity ); - - phpScriptProcess.once( 'error', ( error: Error ) => { - reject( error ); - } ); - phpScriptProcess.once( 'close', ( code ) => { - if ( code === 0 ) { - resolve( { stdout } ); - return; - } - - reject( new Error( `PHP command failed (code: ${ code })` ) ); - } ); - } ); -} - -async function ensureWpConfig( - siteFolder: string, - phpVersion: NativePhpSupportedVersion, - signal: AbortSignal, - config?: Pick< ServerConfig, 'enableDebugLog' | 'enableDebugDisplay' > -): Promise< void > { - const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); - const wpConfigSamplePath = path.join( siteFolder, 'wp-config-sample.php' ); - const ensureWpConfigScript = ` -$transformer_path = $argv[1] ?? ''; -$wp_config_path = $argv[2] ?? ''; -$constants = json_decode( $argv[3] ?? '', true ); - -require_once $transformer_path; - -$transformer = WP_Config_Transformer::from_file( $wp_config_path ); -$transformer->define_constants( $constants ); -$transformer->to_file( $wp_config_path ); -`; - - if ( ! fs.existsSync( wpConfigPath ) && fs.existsSync( wpConfigSamplePath ) ) { - await fs.promises.copyFile( wpConfigSamplePath, wpConfigPath ); - } - - const enableDebugLog = config?.enableDebugLog ?? false; - const enableDebugDisplay = config?.enableDebugDisplay ?? false; - const constants = { - ...DEFAULT_WP_CONFIG_CONSTANTS, - WP_DEBUG: enableDebugLog || enableDebugDisplay, - WP_DEBUG_LOG: enableDebugLog, - WP_DEBUG_DISPLAY: enableDebugDisplay, - }; - - try { - await runPhpCommand( - [ - '-r', - ensureWpConfigScript, - WP_CONFIG_TRANSFORMER_PATH, - wpConfigPath, - JSON.stringify( constants ), - ], - { phpVersion, signal } - ); - } catch ( error ) { - throw new Error( - `Failed to ensure wp-config.php constants: ${ - error instanceof Error ? error.message : String( error ) - }` - ); - } -} - -async function isWordPressInstalled( - siteFolder: string, - phpVersion: NativePhpSupportedVersion, - signal: AbortSignal -): Promise< boolean > { - const installationCheckScript = ` -error_reporting( E_ERROR ); -ini_set( 'display_errors', '0' ); - -$wp_load = getcwd() . '/wp-load.php'; -if ( ! file_exists( $wp_load ) ) { - echo '0'; - exit( 0 ); -} -require_once $wp_load; -echo is_blog_installed() ? '1' : '0'; -`; - - let stdout = ''; - try { - const result = await runPhpCommand( [ '-r', installationCheckScript ], { - phpVersion, - siteFolder, - signal, - mode: 'capture-stdout', - } ); - stdout = result.stdout; - } catch ( error ) { - throw new Error( - `Failed to check WordPress installation status: ${ - error instanceof Error ? error.message : String( error ) - }` - ); - } - - const status = stdout.trim(); - return status === '1'; -} - async function waitForServerReady( url: string, signal?: AbortSignal ): Promise< void > { const pollIntervalMs = 50; const timeoutMs = 30_000; @@ -401,72 +184,6 @@ async function waitForServerReady( url: string, signal?: AbortSignal ): Promise< } } -async function installWordPress( - config: ServerConfig, - phpVersion: NativePhpSupportedVersion, - signal: AbortSignal -): Promise< void > { - const alreadyInstalled = await isWordPressInstalled( config.sitePath, phpVersion, signal ); - if ( alreadyInstalled ) { - logToConsole( `WordPress already installed; skipping installer` ); - return; - } - - const siteTitle = config.siteTitle ?? 'My WordPress Website'; - const username = config.adminUsername ?? 'admin'; - const password = config.adminPassword ? decodePassword( config.adminPassword ) : 'password'; - const email = config.adminEmail ?? 'admin@localhost.com'; - const siteUrl = config.absoluteUrl ?? `http://localhost:${ config.port }`; - // Only pass --locale for non-default locales; WP-CLI defaults to en_US for English. - // DEFAULT_LOCALE is 'en' which is not a valid WP locale code. - const locale = - config.siteLanguage && config.siteLanguage !== DEFAULT_LOCALE ? config.siteLanguage : undefined; - - await runPhpCommand( - [ - getWpCliPharPath(), - 'core', - 'install', - `--path=${ config.sitePath }`, - `--url=${ siteUrl }`, - `--title=${ siteTitle }`, - `--admin_user=${ username }`, - `--admin_password=${ password }`, - `--admin_email=${ email }`, - ...( locale ? [ `--locale=${ locale }` ] : [] ), - '--skip-email', - ], - { phpVersion, signal } - ); - - // Store the admin username in WP options so the auto-login MU plugin can find it. - await runPhpCommand( - [ - getWpCliPharPath(), - 'option', - 'update', - 'studio_admin_username', - username, - `--path=${ config.sitePath }`, - ], - { phpVersion, signal } - ); - - try { - await runPhpCommand( [ SET_DEFAULT_PERMALINKS_PATH ], { - phpVersion, - siteFolder: config.sitePath, - signal, - } ); - } catch ( error ) { - throw new Error( - `Failed to set default permalinks: ${ - error instanceof Error ? error.message : String( error ) - }` - ); - } -} - // The symlink watcher is used to detect new symlinks in wp-content and its subdirectories. When a // new symlink is detected, it is added to the open_basedir allow list and the server is restarted. function startSymlinkWatcher( sitePath: string ): void { @@ -595,57 +312,15 @@ async function closePhpProxyServer(): Promise< void > { } ).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 ); - - 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 ); - } ); + await Promise.all( + children.map( ( child ) => stopPhpChild( child, STOP_SERVER_TIMEOUT, errorToConsole ) ) + ); } function proxyRequestToPhpWorker( @@ -737,14 +412,26 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise try { stopSignal.throwIfAborted(); - await ensureWpConfig( config.sitePath, phpVersion, stopSignal, config ); + await ensureWpConfig( + config.sitePath, + phpVersion, + stopSignal, + WP_CONFIG_TRANSFORMER_PATH, + config + ); stopSignal.throwIfAborted(); const muPluginsPath = await writeStudioMuPluginsForNativePhpRuntime( config.sitePath, config.isWpAutoUpdating ); stopSignal.throwIfAborted(); - await installWordPress( config, phpVersion, stopSignal ); + await installWordPress( + config, + phpVersion, + stopSignal, + SET_DEFAULT_PERMALINKS_PATH, + logToConsole + ); stopSignal.throwIfAborted(); if ( config.blueprint ) { @@ -813,10 +500,6 @@ async function doStartServer( 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 ), disallowRiskyFunctions: true, @@ -824,24 +507,8 @@ async function doStartServer( } ); spawnedChild = serverChild; - 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 ); - } ); + await waitForChildSpawn( serverChild, stopSignal ); + markPhpChildAsCritical( serverChild, 'PHP child process', errorToConsole ); stopSignal?.throwIfAborted(); await waitForServerReady( `http://localhost:${ config.port }/`, stopSignal ); @@ -903,7 +570,11 @@ async function doStartPooledServer( } ); spawnedChildren.push( serverChild ); await waitForChildSpawn( serverChild, stopSignal ); - markPhpChildAsCritical( serverChild, `PHP worker ${ index + 1 }/${ workerPoolSize }` ); + markPhpChildAsCritical( + serverChild, + `PHP worker ${ index + 1 }/${ workerPoolSize }`, + errorToConsole + ); } stopSignal?.throwIfAborted(); @@ -923,8 +594,9 @@ async function doStartPooledServer( startSymlinkWatcher( config.sitePath ); return spawnedChildren[ 0 ]; } catch ( error ) { - if ( proxyServer ) { - await new Promise< void >( ( resolve ) => proxyServer.close( () => resolve() ) ).catch( + const serverToClose = proxyServer; + if ( serverToClose ) { + await new Promise< void >( ( resolve ) => serverToClose.close( () => resolve() ) ).catch( () => {} ); } @@ -994,111 +666,6 @@ function sendErrorMessage( messageId: string, error: unknown ): Promise< void > } ); } -async function runBlueprint( - config: ServerConfig, - blueprint: NonNullable< ServerConfig[ 'blueprint' ] >, - phpVersion: NativePhpSupportedVersion, - signal: AbortSignal -): Promise< void > { - // blueprints.phar's CLI accepts only local paths. Remote URIs are supported by - // the Playground runtime (via FetchFilesystem) but not here. - if ( blueprint.uri.startsWith( 'http://' ) || blueprint.uri.startsWith( 'https://' ) ) { - throw new Error( - `Remote blueprint URIs are not supported by the native PHP runtime: ${ blueprint.uri }` - ); - } - - // Mirror the Playground runtime: merge Studio's defaults into blueprint.contents - // with the same precedence so both runtimes apply blueprints consistently. - const enableDebugLog = config.enableDebugLog ?? false; - const enableDebugDisplay = config.enableDebugDisplay ?? false; - const defaultConstants: Record< string, boolean | string > = { - // Fallback for sites where DB_NAME was stripped from wp-config.php — the SQLite - // driver (v3+) requires a non-empty DB_NAME at runtime. - DB_NAME: 'wordpress', - WP_DEBUG: enableDebugLog || enableDebugDisplay, - WP_DEBUG_LOG: enableDebugLog, - WP_DEBUG_DISPLAY: enableDebugDisplay, - }; - - const preferredVersions = { - php: config.phpVersion || blueprint.contents?.preferredVersions?.php || DEFAULT_PHP_VERSION, - wp: config.wpVersion || blueprint.contents?.preferredVersions?.wp || 'latest', - }; - blueprint.contents.constants = { - ...blueprint.contents.constants, - ...defaultConstants, - }; - blueprint.contents.preferredVersions = preferredVersions; - - // Write the merged blueprint next to the original so blueprints.phar resolves any - // relative file references against the original blueprint's directory — its runner - // uses dirname(blueprintPath) as the execution context. - const blueprintDir = path.dirname( blueprint.uri ); - const tmpPath = path.join( blueprintDir, `studio-blueprint-${ config.siteId }.json` ); - await fs.promises.writeFile( tmpPath, JSON.stringify( blueprint.contents ) ); - - // blueprints.phar checks wp-content/plugins/sqlite-database-integration/load.php to detect - // SQLite, but Studio puts it in mu-plugins. Create a temporary symlink so the PHAR can find it. - const muPluginsSqlite = path.join( - config.sitePath, - 'wp-content', - 'mu-plugins', - 'sqlite-database-integration' - ); - const pluginsSqlite = path.join( - config.sitePath, - 'wp-content', - 'plugins', - 'sqlite-database-integration' - ); - // Use 'junction' type so this works on Windows without elevated permissions. - // On macOS/Linux the type argument is ignored for directories. - const needsSymlink = fs.existsSync( muPluginsSqlite ) && ! fs.existsSync( pluginsSqlite ); - let symlinkIno: number | undefined; - if ( needsSymlink ) { - fs.symlinkSync( muPluginsSqlite, pluginsSqlite, 'junction' ); - // Record the inode so cleanup only removes the entry we created. statSync follows - // the link, so the inode resolves to the mu-plugins target and changes if the - // entry has been replaced with unrelated content. - symlinkIno = fs.statSync( pluginsSqlite ).ino; - } - - try { - // blueprints.phar spawns its own PHP subprocesses while applying a blueprint. - // On Windows those subprocesses auto-load the php.ini we wrote next to - // php.exe, which carries the bundled-extension and CA-bundle config. On - // macOS/Linux every extension is statically linked into the binary, so no - // extra setup is needed for the subprocess. - await runPhpCommand( - [ - getBlueprintsPharPath(), - 'exec', - tmpPath, - '--mode=apply-to-existing-site', - `--site-path=${ config.sitePath }`, - `--site-url=${ config.absoluteUrl ?? `http://localhost:${ config.port }` }`, - '--db-engine=sqlite', - ], - { phpVersion, signal } - ); - } finally { - await fs.promises.unlink( tmpPath ).catch( () => {} ); - if ( needsSymlink ) { - try { - if ( fs.statSync( pluginsSqlite ).ino === symlinkIno ) { - // Use rm with recursive to handle Windows junctions, which fs.unlink - // rejects with EPERM. The inode check above guards against accidentally - // recursing into an unrelated directory. - await fs.promises.rm( pluginsSqlite, { recursive: true, force: true } ); - } - } catch { - // Best effort — leaving the symlink behind is non-fatal. - } - } - } -} - const abortControllers: Record< string, AbortController > = {}; async function ipcMessageHandler( packet: unknown ) { @@ -1151,13 +718,20 @@ async function ipcMessageHandler( packet: unknown ) { blueprintConfig.sitePath, blueprintPhpVersion, abortController.signal, + WP_CONFIG_TRANSFORMER_PATH, blueprintConfig ); await writeStudioMuPluginsForNativePhpRuntime( blueprintConfig.sitePath, blueprintConfig.isWpAutoUpdating ); - await installWordPress( blueprintConfig, blueprintPhpVersion, abortController.signal ); + await installWordPress( + blueprintConfig, + blueprintPhpVersion, + abortController.signal, + SET_DEFAULT_PERMALINKS_PATH, + logToConsole + ); if ( ! blueprintConfig.blueprint ) { throw new Error( 'Blueprint is required' ); } From 9cc6be72a38c2af1d5083c27d0154289e8c7b14f Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 12:34:12 +0200 Subject: [PATCH 08/19] Improved E2E cleanup retry behavior --- apps/studio/e2e/e2e-helpers.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 997c537048..6151a36fce 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -95,14 +95,23 @@ export class E2ESession { async cleanup() { await this.closeApp(); - // Retry on ENOTEMPTY: CLI child processes (e.g. copying skills to server-files) may still be - // writing to the session directory briefly after the Electron process exits. + // rimraf retries EBUSY/EMFILE/ENFILE itself; we only retry the transient post-exit locks + // it gives up on: + // - ENOTEMPTY: a CLI child may still be writing to the session dir after Electron exits. + // - EPERM: on Windows a just-killed native-PHP worker's loaded DLLs (php-bin/*.dll) stay + // locked until the kernel finishes tearing it down. rimraf's EPERM path only chmods a + // read-only attr, which can't clear this sharing violation. + const RETRYABLE_CODES = [ 'ENOTEMPTY', 'EPERM' ]; for ( let attempt = 0; attempt < 5; attempt++ ) { try { await rimraf( this.sessionPath ); return; } catch ( error ) { - if ( ! isErrnoException( error ) || error.code !== 'ENOTEMPTY' || attempt === 4 ) { + if ( + ! isErrnoException( error ) || + ! RETRYABLE_CODES.includes( error.code ?? '' ) || + attempt === 4 + ) { throw error; } await new Promise< void >( ( resolve ) => setTimeout( resolve, 500 ) ); From 71c6125e8160b88cc675149044bae837b2da0c14 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 12:34:24 +0200 Subject: [PATCH 09/19] Improved native PHP child process cleanup --- apps/cli/lib/native-php/php-process.ts | 30 ++++++++++++++++++++++++++ apps/cli/php-server-child.ts | 17 ++++++--------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index 034a3cf2e2..14a7d4ac21 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -5,6 +5,13 @@ import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-me type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void; +// Every PHP process spawned through `spawnPhpProcess` that hasn't exited yet — long-lived workers +// and short-lived one-off commands (WordPress install, blueprint application) alike. Tracked from +// the instant of spawn so involuntary shutdown can reap in-flight children that callers haven't +// yet stored in their own state. Without this, a worker spawned mid-startup or a running blueprint +// subprocess is orphaned when the wrapper exits and, on Windows, survives to keep php-bin DLLs locked. +const livePhpProcesses = new Set< ChildProcess >(); + export type SpawnPhpProcessOptions = { detached?: boolean; disallowRiskyFunctions?: boolean; @@ -46,6 +53,11 @@ export function spawnPhpProcess( detached, } ); + // Track from the instant of spawn so shutdown can reap this child even before callers + // store it in their own state. Deregister on exit to keep the set live. + livePhpProcesses.add( phpScriptProcess ); + phpScriptProcess.once( 'exit', () => livePhpProcesses.delete( phpScriptProcess ) ); + if ( mode === 'pipe' ) { phpScriptProcess.stdout?.pipe( process.stdout, { end: false } ); } @@ -57,6 +69,24 @@ export function spawnPhpProcess( return phpScriptProcess; } +// Force-kill every PHP process spawned through `spawnPhpProcess` that hasn't exited. Used on +// involuntary shutdown to guarantee no PHP child outlives the wrapper — including workers still +// mid-startup and in-flight command subprocesses that callers don't track individually. +export function killAllLivePhpProcesses(): void { + for ( const child of livePhpProcesses ) { + try { + // Detach the unexpected-exit listener so the imminent SIGKILL is not logged as a crash. + child.removeAllListeners( 'exit' ); + if ( child.exitCode === null && child.signalCode === null ) { + child.kill( 'SIGKILL' ); + } + } catch { + // Best effort - nothing useful to do if this fails. + } + } + livePhpProcesses.clear(); +} + type RunPhpCommandOptions = SpawnPhpProcessOptions; export async function runPhpCommand( diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index 49dbb662df..aad406adfd 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -24,6 +24,7 @@ import { requestSetAdminCredentials, toUrlSearchParams } from './lib/admin-crede import { getPhpMyAdminPath } from './lib/dependency-management/paths'; import { runBlueprint } from './lib/native-php/blueprints'; import { + killAllLivePhpProcesses, markPhpChildAsCritical, spawnPhpProcess, stopPhpChild, @@ -781,17 +782,11 @@ function killPhpProcess(): void { } phpProxyServer = null; - for ( const child of getCurrentPhpProcesses() ) { - try { - // Detach the unexpected-exit listener so the imminent SIGKILL is not logged as a crash. - child.removeAllListeners( 'exit' ); - if ( child.exitCode === null && child.signalCode === null ) { - child.kill( 'SIGKILL' ); - } - } catch { - // Best effort - nothing useful to do if this fails. - } - } + // Reap every PHP process we've spawned, not just the promoted servers in + // `getCurrentPhpProcesses()` — that misses workers still mid-startup and in-flight + // command subprocesses (install, blueprint), which would otherwise be orphaned. + killAllLivePhpProcesses(); + phpProcess = null; phpWorkerProcesses = []; phpWorkerPorts = []; From 4b06c8b2f68530466cb66bb5b1ba1daf15f94e40 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 13:37:40 +0200 Subject: [PATCH 10/19] Reap wp, import, export CLI command children --- apps/cli/commands/wp.ts | 29 +++++--- apps/cli/lib/native-php/php-process.ts | 66 ++++++++++++++++++- apps/cli/lib/run-wp-cli-command.ts | 18 ++++- apps/studio/e2e/e2e-helpers.ts | 6 +- .../src/modules/cli/lib/execute-command.ts | 15 ++++- 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index 23d1d7d48a..bc41a95932 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -12,6 +12,7 @@ import { getPhpBinaryPath, getWpCliPharPath } from 'cli/lib/dependency-managemen import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; import { getSiteRuntime } from 'cli/lib/feature-flags'; import { getDefaultPhpArgs } from 'cli/lib/native-php'; +import { DETACH_FOR_GROUP_KILL, reapPhpTreeOnInterrupt } from 'cli/lib/native-php/php-process'; import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; @@ -58,18 +59,28 @@ async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promi { cwd: site.path, stdio: 'inherit', + detached: DETACH_FOR_GROUP_KILL, } ); - const { code, signal } = await new Promise< { - code: number | null; - signal: NodeJS.Signals | null; - } >( ( resolve, reject ) => { - child.once( 'error', reject ); - child.once( 'exit', ( exitCode, exitSignal ) => - resolve( { code: exitCode, signal: exitSignal } ) - ); - } ); + // Reap php.exe and any subprocess it spawned if this command is interrupted before the child exits. + const removeReaper = reapPhpTreeOnInterrupt( child ); + + let code: number | null; + let signal: NodeJS.Signals | null; + try { + ( { code, signal } = await new Promise< { + code: number | null; + signal: NodeJS.Signals | null; + } >( ( resolve, reject ) => { + child.once( 'error', reject ); + child.once( 'exit', ( exitCode, exitSignal ) => + resolve( { code: exitCode, signal: exitSignal } ) + ); + } ) ); + } finally { + removeReaper(); + } if ( signal ) { process.kill( process.pid, signal ); diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index 14a7d4ac21..8eb7d352e9 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -1,10 +1,16 @@ -import { ChildProcess, spawn } from 'node:child_process'; +import { ChildProcess, spawn, spawnSync } from 'node:child_process'; +import os from 'node:os'; import { getPhpBinaryPath } from 'cli/lib/dependency-management/paths'; import { getDefaultPhpArgs } from 'cli/lib/native-php'; import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void; +// A PHP child started with this flag becomes a process-group leader on POSIX, so its whole subtree +// can be signalled at once via the negative PID. On Windows we reap with `taskkill /T` instead, so +// a new group buys nothing and would only complicate console/Ctrl+C handling — hence win32 = false. +export const DETACH_FOR_GROUP_KILL = process.platform !== 'win32'; + // Every PHP process spawned through `spawnPhpProcess` that hasn't exited yet — long-lived workers // and short-lived one-off commands (WordPress install, blueprint application) alike. Tracked from // the instant of spawn so involuntary shutdown can reap in-flight children that callers haven't @@ -87,6 +93,64 @@ export function killAllLivePhpProcesses(): void { livePhpProcesses.clear(); } +// Terminate a PHP child and every descendant it spawned. TerminateProcess on Windows doesn't +// cascade to descendants, so we walk the tree with `taskkill /T`. On POSIX we signal the child's +// process group via the negative PID — which only reaches descendants if the child was spawned with +// `DETACH_FOR_GROUP_KILL` (a group leader) — falling back to the lone child if the group is gone. +export function killPhpProcessTree( + child: ChildProcess, + signal: NodeJS.Signals = 'SIGKILL' +): void { + const pid = child.pid; + if ( ! pid ) { + return; + } + + if ( process.platform === 'win32' ) { + spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { + windowsHide: true, + stdio: 'ignore', + } ); + return; + } + + try { + process.kill( -pid, signal ); + } catch { + try { + child.kill( signal ); + } catch { + // Already gone. + } + } +} + +// Tears down a PHP child's process tree if this CLI process is interrupted (SIGINT/SIGTERM) before +// the command finishes, then exits with the conventional 128+signal code so the orphaned php.exe — +// and any grandchildren it spawned — don't outlive the command. Returns a disposer that removes the +// handlers; call it once the command settles so successive commands don't stack handlers or fire +// against an already-exited child. SIGKILL can't be caught here — Studio's quit handler tree-kills +// for that case, and standalone SIGKILL is unavoidable. +export function reapPhpTreeOnInterrupt( child: ChildProcess ): () => void { + const handleInterrupt = ( signal: NodeJS.Signals ) => { + // Forward the received signal to the group so php (and its grandchildren) shut down the same + // way a terminal Ctrl+C would, rather than being hard-killed mid-write. (On Windows the + // signal is moot — `taskkill /F` is the only reliable option for a console process tree.) + killPhpProcessTree( child, signal ); + process.exit( 128 + ( os.constants.signals[ signal ] ?? 0 ) ); + }; + const onSigint = () => handleInterrupt( 'SIGINT' ); + const onSigterm = () => handleInterrupt( 'SIGTERM' ); + + process.on( 'SIGINT', onSigint ); + process.on( 'SIGTERM', onSigterm ); + + return () => { + process.off( 'SIGINT', onSigint ); + process.off( 'SIGTERM', onSigterm ); + }; +} + type RunPhpCommandOptions = SpawnPhpProcessOptions; export async function runPhpCommand( diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 1c0261071f..16515af103 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -31,6 +31,11 @@ import { import { getSiteRuntime } from 'cli/lib/feature-flags'; import { validatePhpVersion } from 'cli/lib/utils'; import { getDefaultPhpArgs } from './native-php'; +import { + DETACH_FOR_GROUP_KILL, + killPhpProcessTree, + reapPhpTreeOnInterrupt, +} from './native-php/php-process'; import type { SiteData } from 'cli/lib/cli-config/core'; import type { ReadableStream as WebReadableStream } from 'node:stream/web'; @@ -135,10 +140,12 @@ async function runNativeWpCliCommand( { cwd: site.path, stdio: [ 'ignore', 'pipe', 'pipe' ], + detached: DETACH_FOR_GROUP_KILL, } ); await ensureChildSpawned( child ); + const removeReaper = reapPhpTreeOnInterrupt( child ); const exitCode = new Promise< number >( ( resolve, reject ) => { child.once( 'error', ( error: Error ) => reject( error ) ); @@ -148,8 +155,10 @@ async function runNativeWpCliCommand( return { response: new WpCliResponse( child.stdout, child.stderr, exitCode ), [ Symbol.dispose ]() { + removeReaper(); + // Tree-kill so any subprocess WP-CLI spawned dies with it, not just the php.exe itself. if ( child.exitCode === null && child.signalCode === null && ! child.killed ) { - child.kill( 'SIGKILL' ); + killPhpProcessTree( child, 'SIGKILL' ); } }, }; @@ -270,10 +279,11 @@ async function runNativeGlobalWpCliCommand( args: string[] ): Promise< Disposabl const child = spawn( getPhpBinaryPath( phpVersion ), [ ...defaultArgs, getWpCliPharPath(), ...args ], - { stdio: [ 'ignore', 'pipe', 'pipe' ] } + { stdio: [ 'ignore', 'pipe', 'pipe' ], detached: DETACH_FOR_GROUP_KILL } ); await ensureChildSpawned( child ); + const removeReaper = reapPhpTreeOnInterrupt( child ); const exitCode = new Promise< number >( ( resolve, reject ) => { child.once( 'error', ( error: Error ) => reject( error ) ); @@ -283,8 +293,10 @@ async function runNativeGlobalWpCliCommand( args: string[] ): Promise< Disposabl return { response: new WpCliResponse( child.stdout, child.stderr, exitCode ), [ Symbol.dispose ]() { + removeReaper(); + // Tree-kill so any subprocess WP-CLI spawned dies with it, not just the php.exe itself. if ( child.exitCode === null && child.signalCode === null && ! child.killed ) { - child.kill( 'SIGKILL' ); + killPhpProcessTree( child, 'SIGKILL' ); } }, }; diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 6151a36fce..888946be55 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -98,9 +98,9 @@ export class E2ESession { // rimraf retries EBUSY/EMFILE/ENFILE itself; we only retry the transient post-exit locks // it gives up on: // - ENOTEMPTY: a CLI child may still be writing to the session dir after Electron exits. - // - EPERM: on Windows a just-killed native-PHP worker's loaded DLLs (php-bin/*.dll) stay - // locked until the kernel finishes tearing it down. rimraf's EPERM path only chmods a - // read-only attr, which can't clear this sharing violation. + // - EPERM: on Windows a just-killed process's loaded DLLs stay locked until the kernel + // finishes tearing it down. rimraf's EPERM path only chmods a read-only attr, which + // can't clear this sharing violation. const RETRYABLE_CODES = [ 'ENOTEMPTY', 'EPERM' ]; for ( let attempt = 0; attempt < 5; attempt++ ) { try { diff --git a/apps/studio/src/modules/cli/lib/execute-command.ts b/apps/studio/src/modules/cli/lib/execute-command.ts index 19096896eb..d827481176 100644 --- a/apps/studio/src/modules/cli/lib/execute-command.ts +++ b/apps/studio/src/modules/cli/lib/execute-command.ts @@ -1,5 +1,5 @@ import { app } from 'electron'; -import { fork, ChildProcess, StdioOptions } from 'node:child_process'; +import { fork, spawnSync, ChildProcess, StdioOptions } from 'node:child_process'; import * as Sentry from '@sentry/electron/main'; import { z } from 'zod'; import { TypedEventEmitter } from 'src/modules/cli/lib/typed-event-emitter'; @@ -182,6 +182,19 @@ export function executeCliCommand( function appQuitHandler() { const pid = child.pid; child.removeAllListeners(); + + // `child.kill()` only terminates the forked CLI Node process. On Windows its descendants + // (e.g. a native-PHP `php.exe` running a WP-CLI import) don't get cascaded a kill and would + // orphan — surviving the app and keeping their loaded DLLs locked. taskkill /T walks and + // kills the whole tree while the parent is still alive to anchor it. + if ( process.platform === 'win32' && pid ) { + spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { + windowsHide: true, + stdio: 'ignore', + } ); + return; + } + const result = child.kill(); if ( result ) { console.log( `Successfully killed child process with pid ${ pid }. Args:`, args ); From f54e74ffcabe6779189d4a43c46c4b51a07738f4 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 14:26:52 +0200 Subject: [PATCH 11/19] Recursively kill php children in happy path --- apps/cli/lib/native-php/php-process.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index 8eb7d352e9..f657989d42 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -75,16 +75,16 @@ export function spawnPhpProcess( return phpScriptProcess; } -// Force-kill every PHP process spawned through `spawnPhpProcess` that hasn't exited. Used on -// involuntary shutdown to guarantee no PHP child outlives the wrapper — including workers still -// mid-startup and in-flight command subprocesses that callers don't track individually. +// Force-kill every PHP process spawned through `spawnPhpProcess` that hasn't exited, so none +// outlives the wrapper. Tree-kills (not `child.kill()`) because on Windows TerminateProcess doesn't +// cascade — a worker's own subprocess would be orphaned and keep php-bin DLLs locked. export function killAllLivePhpProcesses(): void { for ( const child of livePhpProcesses ) { try { - // Detach the unexpected-exit listener so the imminent SIGKILL is not logged as a crash. + // Detach the unexpected-exit listener so the imminent kill is not logged as a crash. child.removeAllListeners( 'exit' ); if ( child.exitCode === null && child.signalCode === null ) { - child.kill( 'SIGKILL' ); + killPhpProcessTree( child, 'SIGKILL' ); } } catch { // Best effort - nothing useful to do if this fails. From 23f47bd9aab84add8e455d60f4dcd9fb3398b54d Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 15:11:00 +0200 Subject: [PATCH 12/19] Kill grandchildren in stopPhpChild --- apps/cli/lib/native-php/php-process.ts | 72 ++++++++++--------- apps/studio/e2e/e2e-helpers.ts | 9 +-- .../src/modules/cli/lib/execute-command.ts | 6 +- 3 files changed, 43 insertions(+), 44 deletions(-) diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index f657989d42..a0df8b5913 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -6,16 +6,12 @@ import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-me type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void; -// A PHP child started with this flag becomes a process-group leader on POSIX, so its whole subtree -// can be signalled at once via the negative PID. On Windows we reap with `taskkill /T` instead, so -// a new group buys nothing and would only complicate console/Ctrl+C handling — hence win32 = false. +// Makes a PHP child a process-group leader on POSIX so its subtree can be signalled via the +// negative PID. On Windows we reap with `taskkill /T` instead, so a new group isn't needed. export const DETACH_FOR_GROUP_KILL = process.platform !== 'win32'; -// Every PHP process spawned through `spawnPhpProcess` that hasn't exited yet — long-lived workers -// and short-lived one-off commands (WordPress install, blueprint application) alike. Tracked from -// the instant of spawn so involuntary shutdown can reap in-flight children that callers haven't -// yet stored in their own state. Without this, a worker spawned mid-startup or a running blueprint -// subprocess is orphaned when the wrapper exits and, on Windows, survives to keep php-bin DLLs locked. +// Every PHP process spawned through `spawnPhpProcess` that hasn't exited, so shutdown can reap +// in-flight children (mid-startup workers, install/blueprint subprocesses) callers don't track. const livePhpProcesses = new Set< ChildProcess >(); export type SpawnPhpProcessOptions = { @@ -75,9 +71,8 @@ export function spawnPhpProcess( return phpScriptProcess; } -// Force-kill every PHP process spawned through `spawnPhpProcess` that hasn't exited, so none -// outlives the wrapper. Tree-kills (not `child.kill()`) because on Windows TerminateProcess doesn't -// cascade — a worker's own subprocess would be orphaned and keep php-bin DLLs locked. +// Force-kill every tracked PHP process so none outlives the wrapper. Tree-kills because on Windows +// TerminateProcess doesn't cascade — a worker's subprocess would survive and keep DLLs locked. export function killAllLivePhpProcesses(): void { for ( const child of livePhpProcesses ) { try { @@ -93,10 +88,9 @@ export function killAllLivePhpProcesses(): void { livePhpProcesses.clear(); } -// Terminate a PHP child and every descendant it spawned. TerminateProcess on Windows doesn't -// cascade to descendants, so we walk the tree with `taskkill /T`. On POSIX we signal the child's -// process group via the negative PID — which only reaches descendants if the child was spawned with -// `DETACH_FOR_GROUP_KILL` (a group leader) — falling back to the lone child if the group is gone. +// Terminate a PHP child and its descendants: `taskkill /T` on Windows (TerminateProcess doesn't +// cascade), or the process group on POSIX (requires `DETACH_FOR_GROUP_KILL`), falling back to the +// lone child. export function killPhpProcessTree( child: ChildProcess, signal: NodeJS.Signals = 'SIGKILL' @@ -125,17 +119,13 @@ export function killPhpProcessTree( } } -// Tears down a PHP child's process tree if this CLI process is interrupted (SIGINT/SIGTERM) before -// the command finishes, then exits with the conventional 128+signal code so the orphaned php.exe — -// and any grandchildren it spawned — don't outlive the command. Returns a disposer that removes the -// handlers; call it once the command settles so successive commands don't stack handlers or fire -// against an already-exited child. SIGKILL can't be caught here — Studio's quit handler tree-kills -// for that case, and standalone SIGKILL is unavoidable. +// On SIGINT/SIGTERM, tears down the PHP child's tree and exits 128+signal so php.exe and its +// grandchildren don't outlive the command. Returns a disposer to remove the handlers once the +// command settles. (SIGKILL can't be caught — Studio's quit handler tree-kills for that.) export function reapPhpTreeOnInterrupt( child: ChildProcess ): () => void { const handleInterrupt = ( signal: NodeJS.Signals ) => { - // Forward the received signal to the group so php (and its grandchildren) shut down the same - // way a terminal Ctrl+C would, rather than being hard-killed mid-write. (On Windows the - // signal is moot — `taskkill /F` is the only reliable option for a console process tree.) + // Forward the signal to the group so php shuts down like it would on a terminal Ctrl+C, + // rather than being hard-killed. (Moot on Windows — `taskkill /F` is the only option.) killPhpProcessTree( child, signal ); process.exit( 128 + ( os.constants.signals[ signal ] ?? 0 ) ); }; @@ -212,19 +202,33 @@ export async function stopPhpChild( } 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' ); + let settled = false; + const finish = () => { + if ( settled ) { + return; } - }, timeoutMs ); - - child.once( 'close', () => { - clearTimeout( forceKillTimeout ); + settled = true; + child.off( 'exit', finish ); resolve(); - } ); + }; - child.kill( 'SIGTERM' ); + // Resolve on 'exit', not 'close': a descendant that inherited the stdio pipes can hold them + // open after the child dies, so 'close' may never fire and would hang the stop indefinitely. + child.once( 'exit', finish ); + + // Tree-kill so the child's subprocesses die too (Windows TerminateProcess doesn't cascade); + // otherwise they keep DLLs locked and hold the stdio pipes open. + killPhpProcessTree( child, 'SIGTERM' ); + + setTimeout( () => { + if ( settled ) { + return; + } + errorToConsole( 'PHP child did not exit in time; force-killing its process tree' ); + killPhpProcessTree( child, 'SIGKILL' ); + // Backstop: resolve even if 'exit' is somehow delayed, so the stop can never hang. + setTimeout( finish, 1000 ); + }, timeoutMs ); } ); } diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 888946be55..db691bc0c1 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -95,12 +95,9 @@ export class E2ESession { async cleanup() { await this.closeApp(); - // rimraf retries EBUSY/EMFILE/ENFILE itself; we only retry the transient post-exit locks - // it gives up on: - // - ENOTEMPTY: a CLI child may still be writing to the session dir after Electron exits. - // - EPERM: on Windows a just-killed process's loaded DLLs stay locked until the kernel - // finishes tearing it down. rimraf's EPERM path only chmods a read-only attr, which - // can't clear this sharing violation. + // rimraf retries EBUSY/EMFILE/ENFILE itself; we cover the two it gives up on: + // ENOTEMPTY (a CLI child still writing post-exit) and EPERM (on Windows, a just-killed + // process's DLLs stay locked briefly — rimraf only chmods read-only attrs, not this). const RETRYABLE_CODES = [ 'ENOTEMPTY', 'EPERM' ]; for ( let attempt = 0; attempt < 5; attempt++ ) { try { diff --git a/apps/studio/src/modules/cli/lib/execute-command.ts b/apps/studio/src/modules/cli/lib/execute-command.ts index d827481176..22603f3137 100644 --- a/apps/studio/src/modules/cli/lib/execute-command.ts +++ b/apps/studio/src/modules/cli/lib/execute-command.ts @@ -183,10 +183,8 @@ export function executeCliCommand( const pid = child.pid; child.removeAllListeners(); - // `child.kill()` only terminates the forked CLI Node process. On Windows its descendants - // (e.g. a native-PHP `php.exe` running a WP-CLI import) don't get cascaded a kill and would - // orphan — surviving the app and keeping their loaded DLLs locked. taskkill /T walks and - // kills the whole tree while the parent is still alive to anchor it. + // `child.kill()` only terminates the forked CLI process; on Windows its php.exe descendants + // would orphan and keep their DLLs locked. `taskkill /T` walks the whole tree instead. if ( process.platform === 'win32' && pid ) { spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { windowsHide: true, From 45e903b4f62ac1f432a821b3053c48e4d907babb Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 15:20:53 +0200 Subject: [PATCH 13/19] Move files around --- apps/cli/commands/wp.ts | 2 +- apps/cli/lib/dependency-management/php-binary.ts | 2 +- apps/cli/lib/{native-php.ts => native-php/config.ts} | 2 +- apps/cli/lib/native-php/php-process.ts | 2 +- apps/cli/lib/run-wp-cli-command.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename apps/cli/lib/{native-php.ts => native-php/config.ts} (99%) diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index bc41a95932..9ff428733f 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -11,7 +11,7 @@ import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getPhpBinaryPath, getWpCliPharPath } from 'cli/lib/dependency-management/paths'; import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; import { getSiteRuntime } from 'cli/lib/feature-flags'; -import { getDefaultPhpArgs } from 'cli/lib/native-php'; +import { getDefaultPhpArgs } from 'cli/lib/native-php/config'; import { DETACH_FOR_GROUP_KILL, reapPhpTreeOnInterrupt } from 'cli/lib/native-php/php-process'; import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; diff --git a/apps/cli/lib/dependency-management/php-binary.ts b/apps/cli/lib/dependency-management/php-binary.ts index 338d94be59..6d40494495 100644 --- a/apps/cli/lib/dependency-management/php-binary.ts +++ b/apps/cli/lib/dependency-management/php-binary.ts @@ -11,7 +11,7 @@ import { type PhpBinaryDownloadInfo, type NativePhpSupportedVersion, } from '@studio/common/lib/php-binary-metadata'; -import { ensureNativePhpIniFiles } from '../native-php'; +import { ensureNativePhpIniFiles } from 'cli/lib/native-php/config'; import { getPhpBinaryPath } from './paths'; import type { SupportedPHPVersion } from '@studio/common/types/php-versions'; diff --git a/apps/cli/lib/native-php.ts b/apps/cli/lib/native-php/config.ts similarity index 99% rename from apps/cli/lib/native-php.ts rename to apps/cli/lib/native-php/config.ts index c8ec74ce9a..ebe6cda2af 100644 --- a/apps/cli/lib/native-php.ts +++ b/apps/cli/lib/native-php/config.ts @@ -4,7 +4,7 @@ import os from 'os'; import path from 'path'; import { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; import { writeFile } from 'atomically'; -import { getPhpBinaryPath } from './dependency-management/paths'; +import { getPhpBinaryPath } from '../dependency-management/paths'; // Disabled by default to shrink the attack surface available to PHP code // running inside a Studio site. Each entry falls into one of: diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index a0df8b5913..6fba9ce1db 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -1,7 +1,7 @@ import { ChildProcess, spawn, spawnSync } from 'node:child_process'; import os from 'node:os'; import { getPhpBinaryPath } from 'cli/lib/dependency-management/paths'; -import { getDefaultPhpArgs } from 'cli/lib/native-php'; +import { getDefaultPhpArgs } from 'cli/lib/native-php/config'; import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void; diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 16515af103..f28dfa6081 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -30,7 +30,7 @@ import { } from 'cli/lib/dependency-management/paths'; import { getSiteRuntime } from 'cli/lib/feature-flags'; import { validatePhpVersion } from 'cli/lib/utils'; -import { getDefaultPhpArgs } from './native-php'; +import { getDefaultPhpArgs } from './native-php/config'; import { DETACH_FOR_GROUP_KILL, killPhpProcessTree, From b4f5cd1bdb750a9847521d509abdebc945a463de Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 15:42:09 +0200 Subject: [PATCH 14/19] Consider that OPcache is included in binary in PHP >=8.5 --- apps/cli/lib/native-php/config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/lib/native-php/config.ts b/apps/cli/lib/native-php/config.ts index ebe6cda2af..5804f537a4 100644 --- a/apps/cli/lib/native-php/config.ts +++ b/apps/cli/lib/native-php/config.ts @@ -4,6 +4,7 @@ import os from 'os'; import path from 'path'; import { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; import { writeFile } from 'atomically'; +import semver from 'semver'; import { getPhpBinaryPath } from '../dependency-management/paths'; // Disabled by default to shrink the attack surface available to PHP code @@ -151,9 +152,14 @@ export function getNativePhpIniContents( phpVersion: NativePhpSupportedVersion ) if ( process.platform === 'win32' ) { directives.push( `extension_dir="${ toPhpIniPath( getExtensionDir( phpVersion ) ) }"`, - 'zend_extension=opcache', ...WINDOWS_PHP_EXTENSIONS.map( ( extension ) => `extension=${ extension }` ) ); + + const coercedVersion = semver.coerce( phpVersion ); + // As of PHP 8.5, the OPcache extension is always bundled with PHP + if ( coercedVersion && semver.lt( coercedVersion, '8.5.0' ) ) { + directives.push( 'zend_extension=opcache' ); + } } return `${ directives.join( os.EOL ) }${ os.EOL }`; From 7c0e8937074ac67b688ab20fd0bddea45f088376 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 16:40:23 +0200 Subject: [PATCH 15/19] Tweaking --- apps/cli/lib/native-php/php-process.ts | 22 ++++++++++------------ apps/cli/php-server-child.ts | 16 ++++++++++------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index 6fba9ce1db..7cba7385b6 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -101,10 +101,19 @@ export function killPhpProcessTree( } if ( process.platform === 'win32' ) { - spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { + // Bounded so a hung taskkill can't stall the caller's event loop indefinitely (which would + // hang shutdown). `signal`/`error` on the result means it was cut off before finishing — + // log it, since that's the smoking gun for a process tree that won't die. + const result = spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { windowsHide: true, stdio: 'ignore', + timeout: 2_000, } ); + if ( result.error || result.signal ) { + console.error( + `[PHP] taskkill for pid ${ pid } did not complete (signal: ${ result.signal }, error: ${ result.error?.message })` + ); + } return; } @@ -231,14 +240,3 @@ export async function stopPhpChild( }, timeoutMs ); } ); } - -export function markPhpChildAsCritical( - child: ChildProcess, - label: string, - errorToConsole: ErrorLogger -): void { - child.once( 'exit', ( code, signalName ) => { - errorToConsole( `${ label } exited unexpectedly (code: ${ code }, signal: ${ signalName })` ); - process.exit( code ?? 1 ); - } ); -} diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index aad406adfd..23c8bbe801 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -25,7 +25,6 @@ import { getPhpMyAdminPath } from './lib/dependency-management/paths'; import { runBlueprint } from './lib/native-php/blueprints'; import { killAllLivePhpProcesses, - markPhpChildAsCritical, spawnPhpProcess, stopPhpChild, waitForChildSpawn, @@ -564,11 +563,16 @@ async function doStartServer( } await waitForChildSpawn( serverChild, stopSignal ); - markPhpChildAsCritical( - serverChild, - `PHP worker ${ index + 1 }/${ NATIVE_PHP_WORKER_POOL_SIZE }`, - errorToConsole - ); + + serverChild.once( 'exit', ( code, signalName ) => { + errorToConsole( + `PHP worker ${ + index + 1 + }/${ NATIVE_PHP_WORKER_POOL_SIZE } exited unexpectedly (code: ${ code }, signal: ${ signalName })` + ); + killAllLivePhpProcesses(); + process.exit( code ?? 1 ); + } ); } stopSignal?.throwIfAborted(); From 625fb8bb9365c2e651fe927fafc37cecc5b7dc8e Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Mon, 1 Jun 2026 16:46:40 +0100 Subject: [PATCH 16/19] Fix Windows e2e cleanup retry for native PHP --- apps/studio/e2e/e2e-helpers.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index db691bc0c1..2ff96c0926 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -99,7 +99,9 @@ export class E2ESession { // ENOTEMPTY (a CLI child still writing post-exit) and EPERM (on Windows, a just-killed // process's DLLs stay locked briefly — rimraf only chmods read-only attrs, not this). const RETRYABLE_CODES = [ 'ENOTEMPTY', 'EPERM' ]; - for ( let attempt = 0; attempt < 5; attempt++ ) { + const maxAttempts = process.platform === 'win32' ? 30 : 5; + const retryDelayMs = process.platform === 'win32' ? 1000 : 500; + for ( let attempt = 0; attempt < maxAttempts; attempt++ ) { try { await rimraf( this.sessionPath ); return; @@ -107,11 +109,11 @@ export class E2ESession { if ( ! isErrnoException( error ) || ! RETRYABLE_CODES.includes( error.code ?? '' ) || - attempt === 4 + attempt === maxAttempts - 1 ) { throw error; } - await new Promise< void >( ( resolve ) => setTimeout( resolve, 500 ) ); + await new Promise< void >( ( resolve ) => setTimeout( resolve, retryDelayMs ) ); } } } From 6a9bf7fbfe58cb9dbbc214a6015df470380dcc8c Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Mon, 1 Jun 2026 18:01:55 +0100 Subject: [PATCH 17/19] Pin e2e locale to English --- apps/studio/e2e/e2e-helpers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 2ff96c0926..96e0d126ef 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { tmpdir } from 'os'; import path from 'path'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; +import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; import { findLatestBuild, parseElectronApp } from 'electron-playwright-helpers'; import fs from 'fs-extra'; import { _electron as electron, Page, ElectronApplication } from 'playwright'; @@ -38,6 +39,11 @@ export class E2ESession { await fs.mkdir( this.cliConfigPath, { recursive: true } ); await fs.mkdir( this.sharedConfigPath, { recursive: true } ); + await fs.writeFile( + path.join( this.sharedConfigPath, 'shared.json' ), + JSON.stringify( { version: 1, locale: DEFAULT_LOCALE }, null, 2 ) + ); + // Pre-create appdata file with beta features enabled for CLI testing // Path must include 'Studio' subfolder to match Electron app's path structure const studioAppDataPath = path.join( this.appDataPath, 'Studio' ); From 40b2bb5ee4174f245bdbee374eb575e1af18ca70 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Jun 2026 16:56:27 +0200 Subject: [PATCH 18/19] Collect all process logs when E2E tests fail --- .buildkite/commands/run-e2e-tests.sh | 21 +++++++++++++++++++-- .buildkite/pipeline.yml | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.buildkite/commands/run-e2e-tests.sh b/.buildkite/commands/run-e2e-tests.sh index a9ca8b41ba..5cff78d0f5 100644 --- a/.buildkite/commands/run-e2e-tests.sh +++ b/.buildkite/commands/run-e2e-tests.sh @@ -152,8 +152,15 @@ if [ "$PLATFORM" = "linux" ]; then # right-edge content (e.g. the preferences Save button) below the fold # for the split-pane settings layout. 1920x1080 matches a typical # desktop and avoids relying on scroll-into-view. + inner_exit=0 xvfb-run -a -s "-screen 0 1920x1080x24" \ - npx playwright test --max-failures=1 --output=/tmp/test-results + npx playwright test --max-failures=1 --output=/tmp/test-results || inner_exit=$? + # On failure, collect daemon logs into /tmp/test-results (copied to the + # artifact dir below). $HOME here is the node user that ran the tests. + if [ "$inner_exit" -ne 0 ] && [ -d "$HOME/.studio/daemon/logs" ]; then + cp -r "$HOME/.studio/daemon/logs" /tmp/test-results/daemon-logs || true + fi + exit "$inner_exit" ' || test_exit=$? if [ -d /tmp/test-results ]; then @@ -168,5 +175,15 @@ else npx playwright install echo 'Running Playwright tests...' - npx playwright test + # Capture the exit code so a failure doesn't trip `set -e` before we collect + # the daemon logs (~/.studio/daemon/logs) for artifact upload. + test_exit=0 + npx playwright test || test_exit=$? + + if [ "$test_exit" -ne 0 ] && [ -d "$HOME/.studio/daemon/logs" ]; then + mkdir -p test-results/daemon-logs + cp -r "$HOME/.studio/daemon/logs/." test-results/daemon-logs/ || true + fi + + exit "$test_exit" fi diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 9bdeafbaee..27ebb796e6 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -12,6 +12,7 @@ e2e_config: &e2e_config - test-results/**/*.zip - test-results/**/*.png - test-results/**/*error-context.md + - test-results/daemon-logs/**/*.log plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] agents: queue: "{{matrix.platform}}" @@ -113,6 +114,7 @@ steps: - test-results/**/*.zip - test-results/**/*.png - test-results/**/*error-context.md + - test-results/daemon-logs/**/*.log env: DEBUG: "pw:browser" # TEMP(rsm-2593): if: removed to force E2E on every push while iterating on Linux E2E setup. Revert before merge. From 797dc28705085fadebe62f80062157da9f181101 Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Mon, 1 Jun 2026 20:30:39 +0100 Subject: [PATCH 19/19] Stabilize Windows native PHP e2e cleanup --- apps/studio/e2e/e2e-helpers.ts | 19 +++++++++++++------ apps/studio/src/index.ts | 4 +++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 96e0d126ef..5707560fc9 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -105,18 +105,25 @@ export class E2ESession { // ENOTEMPTY (a CLI child still writing post-exit) and EPERM (on Windows, a just-killed // process's DLLs stay locked briefly — rimraf only chmods read-only attrs, not this). const RETRYABLE_CODES = [ 'ENOTEMPTY', 'EPERM' ]; - const maxAttempts = process.platform === 'win32' ? 30 : 5; + const maxAttempts = 5; const retryDelayMs = process.platform === 'win32' ? 1000 : 500; for ( let attempt = 0; attempt < maxAttempts; attempt++ ) { try { await rimraf( this.sessionPath ); return; } catch ( error ) { - if ( - ! isErrnoException( error ) || - ! RETRYABLE_CODES.includes( error.code ?? '' ) || - attempt === maxAttempts - 1 - ) { + const isRetryableError = + isErrnoException( error ) && RETRYABLE_CODES.includes( error.code ?? '' ); + if ( ! isRetryableError ) { + throw error; + } + if ( attempt === maxAttempts - 1 ) { + if ( process.platform === 'win32' ) { + console.warn( + `Unable to remove E2E session temp directory "${ this.sessionPath }": ${ error.message }` + ); + return; + } throw error; } await new Promise< void >( ( resolve ) => setTimeout( resolve, retryDelayMs ) ); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index c3089c9d8c..a3013b2b1f 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -64,6 +64,8 @@ import { getAutoUpdaterState, setupUpdates } from 'src/updates'; // eslint-disable-next-line import-x/order import packageJson from '../package.json'; +const STOP_ALL_SERVERS_ON_QUIT_TIMEOUT_MS = process.env.E2E ? 20_000 : 6_000; + // Helper function to get the actual URL for validation function getRendererUrl(): string { return getCurrentRendererUrl(); @@ -535,7 +537,7 @@ async function appBoot() { if ( shouldStopSitesOnQuit ) { event.preventDefault(); - stopAllServers( true, 6_000 ) + stopAllServers( true, STOP_ALL_SERVERS_ON_QUIT_TIMEOUT_MS ) .then( () => { app.exit(); } )