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 8fbd363b8d..38576b1048 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}}" @@ -101,6 +102,10 @@ steps: STUDIO_RUNTIME: native-php if: (build.branch == 'trunk' || build.tag =~ /^v[0-9]+/ || !build.pull_request.draft) && build.pull_request.labels includes 'native-php' + # Linux E2E tests run on the shared `default` queue inside a Debian Node + # container — the same pattern the Linux build/unit-test steps use. Kept as + # a separate step (rather than another matrix entry on *e2e_config) because + # Mac and Windows share queue/plugin defaults that Linux doesn't. # Linux E2E tests run on the shared `default` queue inside a Debian Node # container — the same pattern the Linux build/unit-test steps use. Kept as # a separate step (rather than another matrix entry on *e2e_config) because @@ -119,6 +124,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. @@ -140,6 +146,7 @@ steps: - test-results/**/*.zip - test-results/**/*.png - test-results/**/*error-context.md + - test-results/daemon-logs/**/*.log env: DEBUG: "pw:browser" STUDIO_RUNTIME: native-php diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index 23d1d7d48a..9ff428733f 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -11,7 +11,8 @@ 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'; 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/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/blueprints.ts b/apps/cli/lib/native-php/blueprints.ts new file mode 100644 index 0000000000..b5b7b5cb3e --- /dev/null +++ b/apps/cli/lib/native-php/blueprints.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +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, + }; + + blueprint.contents.constants = { + ...blueprint.contents.constants, + ...defaultConstants, + }; + // Native PHP selects PHP and installs WordPress before Blueprint execution. + // Passing preferredVersions makes blueprints.phar validate versions it does not manage here. + delete blueprint.contents.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.ts b/apps/cli/lib/native-php/config.ts similarity index 96% rename from apps/cli/lib/native-php.ts rename to apps/cli/lib/native-php/config.ts index c8ec74ce9a..5804f537a4 100644 --- a/apps/cli/lib/native-php.ts +++ b/apps/cli/lib/native-php/config.ts @@ -4,7 +4,8 @@ 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 semver from 'semver'; +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: @@ -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 }`; 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..7cba7385b6 --- /dev/null +++ b/apps/cli/lib/native-php/php-process.ts @@ -0,0 +1,242 @@ +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/config'; +import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; + +type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void; + +// 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, 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 = { + detached?: boolean; + 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', + detached = false, + 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, + 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 } ); + } + + if ( mode === 'pipe' || mode === 'capture-stdout' ) { + phpScriptProcess.stderr?.pipe( process.stderr, { end: false } ); + } + + return phpScriptProcess; +} + +// 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 { + // 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 ) { + killPhpProcessTree( child, 'SIGKILL' ); + } + } catch { + // Best effort - nothing useful to do if this fails. + } + } + livePhpProcesses.clear(); +} + +// 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' +): void { + const pid = child.pid; + if ( ! pid ) { + return; + } + + if ( process.platform === 'win32' ) { + // 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; + } + + try { + process.kill( -pid, signal ); + } catch { + try { + child.kill( signal ); + } catch { + // Already gone. + } + } +} + +// 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 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 ) ); + }; + 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( + 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 ) => { + let settled = false; + const finish = () => { + if ( settled ) { + return; + } + settled = true; + child.off( 'exit', finish ); + resolve(); + }; + + // 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/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/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 1c0261071f..f28dfa6081 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -30,7 +30,12 @@ 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, + 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/cli/php-server-child.ts b/apps/cli/php-server-child.ts index cc35f680fc..23c8bbe801 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -1,22 +1,19 @@ /** - * 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'; -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_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, @@ -24,14 +21,22 @@ import { ServerConfig, } from 'cli/lib/types/wordpress-server-ipc'; import { requestSetAdminCredentials, toUrlSearchParams } from './lib/admin-credentials'; +import { getPhpMyAdminPath } from './lib/dependency-management/paths'; +import { runBlueprint } from './lib/native-php/blueprints'; +import { + killAllLivePhpProcesses, + 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( @@ -44,50 +49,46 @@ 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, "\\'" ) }'`; -} +// 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[]; -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' ); -} + constructor( size: number ) { + this.counts = new Array( size ).fill( 0 ); + } -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 ); -} + get( index: number ): number { + return this.counts[ index ] ?? 0; + } -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 ) }, - ), -); -`; + set( index: number, value: number ): void { + if ( index < 0 || index >= this.counts.length ) { + return; + } + this.counts[ index ] = Math.max( 0, value ); + } - 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; + 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 phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); let startupAbortController: AbortController | null = null; let startingPromise: Promise< void > | null = null; let blueprintQueue: Promise< unknown > = Promise.resolve(); @@ -102,6 +103,7 @@ let runningConfig: ServerConfig | null = null; const SYMLINK_RESTART_DEBOUNCE_MS = 750; const STOP_SERVER_TIMEOUT = 5000; +const NATIVE_PHP_WORKER_POOL_SIZE = 4; function logToConsole( ...args: Parameters< typeof console.log > ) { console.log( `[PHP Server]`, ...args ); @@ -111,197 +113,50 @@ function errorToConsole( ...args: Parameters< typeof console.error > ) { console.error( `[PHP Server]`, ...args ); } -type SpawnPhpProcessOptions = { - detached?: boolean; - 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', - detached = false, - 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, - detached, - } ); - - if ( mode === 'pipe' ) { - phpScriptProcess.stdout?.pipe( process.stdout, { end: false } ); +function shouldUsePrimaryWorker( req: http.IncomingMessage ): boolean { + const method = req.method?.toUpperCase() ?? 'GET'; + if ( ! [ 'GET', 'HEAD', 'OPTIONS' ].includes( method ) ) { + return true; } - // Keep stderr visible in all modes for easier debugging. - if ( mode === 'pipe' || mode === 'capture-stdout' ) { - phpScriptProcess.stderr?.pipe( process.stderr, { end: false } ); + const requestUrl = req.url ?? '/'; + if ( requestUrl.startsWith( '/phpmyadmin' ) ) { + return true; } - return phpScriptProcess; + return false; } -type RunPhpCommandOptions = SpawnPhpProcessOptions; +function pickPhpWorker( req: http.IncomingMessage ): { index: number; port: number } { + if ( phpWorkerPorts.length === 0 ) { + throw new Error( 'No PHP worker ports are available' ); + } -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. - } + if ( shouldUsePrimaryWorker( req ) ) { + return { index: 0, port: phpWorkerPorts[ 0 ] }; } - child.kill( signal ); + const bestIndex = phpWorkerRequestTracker.getFirstFreeWorker(); + return { index: bestIndex, port: phpWorkerPorts[ bestIndex ] }; } -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 } ); +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; } - - reject( new Error( `PHP command failed (code: ${ code })` ) ); + const port = address.port; + server.close( () => resolve( port ) ); } ); } ); } -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; @@ -310,7 +165,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(); @@ -322,72 +177,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 ) - }` - ); - } -} - async function setAdminCredentials( config: ServerConfig, signal: AbortSignal ): Promise< void > { try { await requestSetAdminCredentials( config, async ( request ) => { @@ -516,21 +305,7 @@ async function restartPhpServer(): Promise< void > { return; } - const oldChild = phpProcess; - phpProcess = null; - - // Remove the crash listener so the imminent SIGTERM is not reported as an unexpected crash. - oldChild.removeAllListeners( 'exit' ); - killProcessGroup( oldChild, 'SIGTERM' ); - await new Promise< void >( ( resolve ) => { - const timeout = setTimeout( () => { - killProcessGroup( oldChild, 'SIGKILL' ); - }, STOP_SERVER_TIMEOUT ); - oldChild.once( 'close', () => { - clearTimeout( timeout ); - resolve(); - } ); - } ); + await stopCurrentPhpServer(); try { phpProcess = await doStartServer( runningConfig, currentOpenBasedirAllowlist ); @@ -540,6 +315,120 @@ async function restartPhpServer(): Promise< void > { } } +function getCurrentPhpProcesses(): ChildProcess[] { + return [ + ...new Set( [ phpProcess, ...phpWorkerProcesses ].filter( Boolean ) ), + ] as ChildProcess[]; +} + +async function closePhpProxyServer(): Promise< void > { + const proxyServer = phpProxyServer; + phpProxyServer = null; + phpWorkerPorts = []; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); + + if ( ! proxyServer ) { + return; + } + + await new Promise< void >( ( resolve ) => { + proxyServer.close( () => resolve() ); + } ).catch( () => {} ); +} + +async function stopCurrentPhpServer(): Promise< void > { + const children = getCurrentPhpProcesses(); + phpProcess = null; + phpWorkerProcesses = []; + + await closePhpProxyServer(); + await Promise.all( + children.map( ( child ) => stopPhpChild( child, STOP_SERVER_TIMEOUT, errorToConsole ) ) + ); +} + +function proxyRequestToPhpWorker( + config: ServerConfig, + req: http.IncomingMessage, + res: http.ServerResponse +): void { + let worker: { index: number; port: number }; + 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( 'Service temporarily unavailable' ); + 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; + delete headers[ 'proxy-connection' ]; + + const proxyReq = http.request( + { + hostname: '127.0.0.1', + port: worker.port, + path: req.url, + method: req.method, + headers, + }, + ( proxyRes ) => { + res.writeHead( proxyRes.statusCode ?? 502, proxyRes.headers ); + proxyRes.pipe( res ); + } + ); + + proxyReq.on( 'error', ( error ) => { + release(); + 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 > { if ( phpProcess ) { logToConsole( `Server already running` ); @@ -552,14 +441,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 ) { @@ -612,60 +513,78 @@ async function doStartServer( openBasedirAllowlist: Set< string >, stopSignal?: AbortSignal ): Promise< ChildProcess > { - const phpAddress = `localhost:${ config.port }`; const phpVersion = resolveNativePhpVersion( config.phpVersion ?? '' ); - let spawnedChild: ChildProcess | null = null; + const spawnedChildren: ChildProcess[] = []; + let proxyServer: http.Server | null = null; logToConsole( - `Spawning PHP built-in server on ${ phpAddress } with PHP version ${ phpVersion }` + `Spawning native PHP worker pool with ${ NATIVE_PHP_WORKER_POOL_SIZE } workers on public port ${ config.port }` ); 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 ); + const workerPorts: number[] = []; + for ( let index = 0; index < NATIVE_PHP_WORKER_POOL_SIZE; index++ ) { + workerPorts.push( await getAvailablePort() ); } - await new Promise< void >( ( resolve, reject ) => { - serverChild.once( 'spawn', () => { - resolve(); - } ); - serverChild.once( 'error', ( error: Error ) => { - reject( error ); + phpWorkerPorts = workerPorts; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( workerPorts.length ); + + for ( const [ index, workerPort ] of workerPorts.entries() ) { + const phpAddress = `127.0.0.1:${ workerPort }`; + 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, + 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, } ); - stopSignal?.addEventListener( 'abort', () => { - reject( new DOMException( 'Aborted', 'AbortError' ) ); + 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 ); + + 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 ); } ); - } ); + } - serverChild.once( 'exit', ( code, signalName ) => { - errorToConsole( - `PHP child process exited unexpectedly (code: ${ code }, signal: ${ signalName })` - ); - process.exit( code ?? 1 ); - } ); + 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 ); @@ -674,12 +593,23 @@ async function doStartServer( // 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; + return spawnedChildren[ 0 ]; } catch ( error ) { - if ( spawnedChild ) { - killProcessGroup( spawnedChild, 'SIGKILL' ); + const serverToClose = proxyServer; + if ( serverToClose ) { + await new Promise< void >( ( resolve ) => serverToClose.close( () => resolve() ) ).catch( + () => {} + ); + } + for ( const child of spawnedChildren ) { + child.removeAllListeners( 'exit' ); + if ( child.exitCode === null && child.signalCode === null ) { + child.kill( 'SIGKILL' ); + } } + phpWorkerPorts = []; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); + phpWorkerProcesses = []; await stopSymlinkWatcher(); throw error; @@ -702,34 +632,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' ); - killProcessGroup( child, 'SIGKILL' ); - }, STOP_SERVER_TIMEOUT ); - - child.once( 'close', () => { - clearTimeout( forceKillTimeout ); - resolve(); - } ); - - killProcessGroup( child, 'SIGTERM' ); - } ); + await stopCurrentPhpServer(); logToConsole( 'Server stopped gracefully' ); return StopServerResult.OK; @@ -749,109 +667,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, - }; - - blueprint.contents.constants = { - ...blueprint.contents.constants, - ...defaultConstants, - }; - // Native PHP selects PHP and installs WordPress before Blueprint execution. - // Passing preferredVersions makes blueprints.phar validate versions it does not manage here. - delete blueprint.contents.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 ) { @@ -904,13 +719,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' ); } @@ -957,15 +779,22 @@ async function ipcMessageHandler( packet: unknown ) { } function killPhpProcess(): void { - if ( phpProcess ) { - try { - // Detach the unexpected-exit listener so the imminent SIGKILL is not logged as a crash. - phpProcess.removeAllListeners( 'exit' ); - killProcessGroup( phpProcess, 'SIGKILL' ); - } catch { - // Best effort — nothing useful to do if this fails. - } + try { + phpProxyServer?.close(); + } catch { + // Best effort - nothing useful to do if this fails. } + phpProxyServer = null; + + // 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 = []; + phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 ); } function shutdownOnSignal( signal: NodeJS.Signals ): void { diff --git a/apps/cli/process-manager-daemon.ts b/apps/cli/process-manager-daemon.ts index e9fea3d578..69725e773f 100644 --- a/apps/cli/process-manager-daemon.ts +++ b/apps/cli/process-manager-daemon.ts @@ -454,60 +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 can spawn the PHP server in its own group too, so signal both - // when the wrapper reports that pid. - 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 - } - } - - if ( managedProcess.grandchildrenPids ) { - for ( const pid of managedProcess.grandchildrenPids ) { + // Group send can fail if the leader has already exited but children remain. try { - process.kill( -pid, 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 > { diff --git a/apps/cli/tests/daemon.test.ts b/apps/cli/tests/daemon.test.ts index fba8100d77..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 a reported subprocess process group 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 ); @@ -286,4 +288,62 @@ describe( 'ProcessManagerDaemon', () => { } } ); + + 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, + } ); + } + } ); } ); diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 997c537048..146309359a 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -95,19 +95,11 @@ 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. - for ( let attempt = 0; attempt < 5; attempt++ ) { - try { - await rimraf( this.sessionPath ); - return; - } catch ( error ) { - if ( ! isErrnoException( error ) || error.code !== 'ENOTEMPTY' || attempt === 4 ) { - throw error; - } - await new Promise< void >( ( resolve ) => setTimeout( resolve, 500 ) ); - } - } + await rimraf( this.sessionPath, { + backoff: 2, + maxBackoff: 2500, + maxRetries: 50, + } ); } private async launchFirstWindow( testEnv: NodeJS.ProcessEnv = {} ) { diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index cbf7d8e875..38e50f4067 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(); @@ -537,7 +539,7 @@ async function appBoot() { if ( shouldStopSitesOnQuit ) { event.preventDefault(); - stopAllServers( true, 6_000 ) + stopAllServers( true, STOP_ALL_SERVERS_ON_QUIT_TIMEOUT_MS ) .then( () => { app.exit(); } ) diff --git a/apps/studio/src/modules/cli/lib/execute-command.ts b/apps/studio/src/modules/cli/lib/execute-command.ts index 19096896eb..22603f3137 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,17 @@ export function executeCliCommand( function appQuitHandler() { const pid = child.pid; child.removeAllListeners(); + + // `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, + stdio: 'ignore', + } ); + return; + } + const result = child.kill(); if ( result ) { console.log( `Successfully killed child process with pid ${ pid }. Args:`, args );