From de92f2df9c03bcdf7f50f7c554888a5306cd8026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 13 Mar 2026 16:03:27 +0000 Subject: [PATCH 1/6] Fix: site starts with PHP errors, shows error in browser instead of dialog When a site has a PHP fatal error (in functions.php, plugins, etc.), Studio now starts the site and displays the PHP error in the browser instead of showing an error dialog. This allows users to see exactly what's wrong. The fix intercepts Playground CLI's process.exit(1) call and captures the actual PHP error output. The orphaned HTTP server is repurposed to serve an error page. A file watcher watches for .php changes and automatically restarts the server when the error is fixed. --- apps/cli/wordpress-server-child.ts | 234 ++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 1 deletion(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index fda5a5821c..56b7219ce4 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -10,6 +10,8 @@ * - Sends response back when ready * - Sends activity heartbeats to prevent timeout during long operations */ +import { watch, type FSWatcher } from 'fs'; +import http, { type Server as HttpServer } from 'http'; import { dirname } from 'path'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { isWordPressDirectory } from '@studio/common/lib/fs-utils'; @@ -40,6 +42,8 @@ import { let server: RunCLIServer | null = null; let startingPromise: Promise< void > | null = null; let lastCliArgs: Record< string, unknown > | null = null; +let orphanedServer: HttpServer | null = null; +let siteFileWatcher: FSWatcher | null = null; // Intercept and prefix all console output from playground-cli const originalConsoleLog = console.log; @@ -272,6 +276,203 @@ function wrapWithStartingPromise< Args extends unknown[], Return extends void >( }; } +/** + * Determines if an error from runCLI() is caused by user PHP code (themes/plugins) + * rather than an infrastructure issue (WASM memory, port conflicts, etc.). + */ +function isPhpUserError( error: unknown ): boolean { + if ( ! ( error instanceof Error ) ) { + return false; + } + + const message = error.message; + + // Infrastructure errors — should NOT trigger fallback + if ( + message.includes( 'Cannot allocate Wasm memory' ) || + message.includes( 'EADDRINUSE' ) || + message.includes( 'Operation aborted' ) || + message.includes( '"unreachable" WASM instruction' ) + ) { + return false; + } + + // PHP user errors — should trigger fallback + if ( message.match( /PHP Fatal error:/i ) ) { + return true; + } + if ( message.match( /Fatal error/i ) ) { + return true; + } + if ( message.includes( 'wp-die-message' ) ) { + return true; + } + if ( message.includes( 'PHP.run() failed with exit code' ) ) { + return true; + } + // Intercepted process.exit(1) from @wp-playground/cli during server startup + if ( message.includes( 'WordPress server startup failed' ) ) { + return true; + } + + return false; +} + +function generateErrorPageHtml( errorMessage: string ): string { + const escaped = errorMessage + .replace( /&/g, '&' ) + .replace( //g, '>' ); + return `PHP Error + +

PHP Error Detected

${ escaped }
+

Studio is watching for file changes. Fix the PHP error and the site will automatically restart.

`; +} + +/** + * Replaces the request handler on an HTTP server to serve an error page. + */ +function serveErrorPage( httpServer: HttpServer, errorMessage: string ): void { + const html = generateErrorPageHtml( errorMessage ); + httpServer.removeAllListeners( 'request' ); + httpServer.on( 'request', ( _req, res ) => { + res.writeHead( 500, { 'Content-Type': 'text/html; charset=utf-8' } ); + res.end( html ); + } ); +} + +/** + * Watch for PHP file changes and attempt to restart the server. + */ +function watchForPhpChanges( config: ServerConfig ): void { + let debounce: ReturnType< typeof setTimeout > | null = null; + let retrying = false; + + siteFileWatcher = watch( config.sitePath, { recursive: true }, ( _event, filename ) => { + if ( ! filename?.endsWith( '.php' ) || retrying ) { + return; + } + if ( debounce ) { + clearTimeout( debounce ); + } + debounce = setTimeout( () => { + void ( async () => { + retrying = true; + logToConsole( 'PHP file change detected, restarting server…' ); + try { + // Close orphaned server to free the port for the new runCLI + if ( orphanedServer ) { + orphanedServer.close(); + orphanedServer = null; + } + const args = await getBaseRunCLIArgs( 'server', config ); + lastCliArgs = sanitizeRunCLIArgs( args ); + server = await runCLIWithoutExit( args ); + + // Success — clean up watcher + siteFileWatcher?.close(); + siteFileWatcher = null; + logToConsole( 'Server restarted successfully' ); + + if ( config.adminPassword || config.adminUsername || config.adminEmail ) { + await setAdminCredentials( + server, + config.adminPassword, + config.adminUsername, + config.adminEmail + ); + } + } catch { + // runCLIWithoutExit already repurposed the orphaned server with new error + logToConsole( 'Restart failed, still watching…' ); + } finally { + retrying = false; + } + } )(); + }, 2000 ); + } ); +} + +/** + * Wraps runCLI to prevent @wp-playground/cli from calling process.exit(1) on + * PHP fatal errors, and to capture orphaned HTTP servers for reuse. + * + * Playground's internal error handler calls process.exit(1) directly in a .catch(), + * bypassing all try-catch blocks. This wrapper: + * 1. Overrides process.exit to throw instead of exiting + * 2. Captures stdout/console output so we get the actual PHP error message + * 3. Intercepts http.createServer to capture Playground's HTTP server reference + * 4. On failure, repurposes the orphaned server to serve an error page + */ +async function runCLIWithoutExit( + args: RunCLIArgs & { command: 'server' } +): Promise< RunCLIServer > { + const originalExit = process.exit; + const capturedOutput: string[] = []; + + // Capture all output channels — PHP WASM writes errors via process.stdout.write, + // while Playground's printError uses console.log/error. + const savedConsoleLog = console.log; + const savedConsoleError = console.error; + const savedStdoutWrite = process.stdout.write; + console.log = ( ...logArgs: unknown[] ) => { + capturedOutput.push( logArgs.map( String ).join( ' ' ) ); + savedConsoleLog( ...logArgs ); + }; + console.error = ( ...errorArgs: unknown[] ) => { + capturedOutput.push( errorArgs.map( String ).join( ' ' ) ); + savedConsoleError( ...errorArgs ); + }; + process.stdout.write = function ( ...writeArgs: Parameters< typeof savedStdoutWrite > ) { + capturedOutput.push( String( writeArgs[ 0 ] ) ); + return savedStdoutWrite( ...writeArgs ); + } as typeof process.stdout.write; + + process.exit = ( ( code?: number ) => { + if ( code !== 0 ) { + const errorMsg = + capturedOutput.join( '\n' ) || `WordPress server startup failed (exit code ${ code })`; + throw new Error( errorMsg ); + } + return originalExit( code ); + } ) as typeof process.exit; + + // Intercept HTTP servers created by Playground so we can repurpose them on failure. + // Playground binds an HTTP server to the port before booting WordPress — if boot fails, + // this orphaned server still holds the port. + const createdServers: HttpServer[] = []; + const originalCreateServer = http.createServer; + http.createServer = ( ( ...createArgs: unknown[] ) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const srv = ( originalCreateServer as any )( ...createArgs ) as HttpServer; + createdServers.push( srv ); + return srv; + } ) as typeof http.createServer; + + try { + return await runCLI( args ); + } catch ( error ) { + // Repurpose Playground's orphaned HTTP server to serve our error page. + // This avoids EADDRINUSE — the server is already bound to the port. + if ( createdServers.length > 0 ) { + const srv = createdServers[ 0 ]; + serveErrorPage( srv, parsePhpError( error ) ); + orphanedServer = srv; + // Close any extra servers (unlikely, but be safe) + for ( let i = 1; i < createdServers.length; i++ ) { + createdServers[ i ].close(); + } + } + throw error; + } finally { + process.exit = originalExit; + console.log = savedConsoleLog; + console.error = savedConsoleError; + process.stdout.write = savedStdoutWrite; + http.createServer = originalCreateServer; + } +} + const startServer = wrapWithStartingPromise( async ( config: ServerConfig, signal: AbortSignal ): Promise< void > => { if ( server ) { @@ -290,7 +491,7 @@ const startServer = wrapWithStartingPromise( const args = await getBaseRunCLIArgs( 'server', config ); lastCliArgs = sanitizeRunCLIArgs( args ); - server = await runCLI( args ); + server = await runCLIWithoutExit( args ); if ( config.adminPassword || config.adminUsername || config.adminEmail ) { await setAdminCredentials( @@ -302,6 +503,14 @@ const startServer = wrapWithStartingPromise( } } catch ( error ) { server = null; + + if ( isPhpUserError( error ) ) { + logToConsole( `PHP error detected during startup: ${ parsePhpError( error ) }` ); + // orphanedServer is already serving the error page (set by runCLIWithoutExit) + watchForPhpChanges( config ); + return; + } + errorToConsole( `Failed to start server:`, error ); throw error; } @@ -311,6 +520,17 @@ const startServer = wrapWithStartingPromise( const STOP_SERVER_TIMEOUT = 5000; async function stopServer(): Promise< void > { + if ( siteFileWatcher ) { + siteFileWatcher.close(); + siteFileWatcher = null; + } + if ( orphanedServer ) { + orphanedServer.close(); + orphanedServer = null; + logToConsole( 'Error page server stopped' ); + return; + } + if ( ! server ) { logToConsole( 'No server running, nothing to stop' ); return; @@ -500,6 +720,18 @@ async function ipcMessageHandler( packet: unknown ) { } } +// Prevent the process from crashing on unhandled errors from worker threads +// (e.g., PHP WASM fatal errors). These are handled by the startServer catch block +// via the runCLI() promise rejection, but worker thread errors can also surface +// as separate unhandled rejections that would otherwise crash the process. +process.on( 'uncaughtException', ( error ) => { + errorToConsole( 'Uncaught exception in child process:', error ); +} ); + +process.on( 'unhandledRejection', ( reason ) => { + errorToConsole( 'Unhandled rejection in child process:', reason ); +} ); + if ( process.send ) { process.on( 'message', ipcMessageHandler ); process.send( { topic: 'ready' } ); From eb225d908d388a85518696791590b798cf36bc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 16 Mar 2026 17:28:25 +0000 Subject: [PATCH 2/6] Refactor: extract PHP error handling into dedicated module Move pure error-handling functions (isPhpUserError, parsePhpError, generateErrorPageHtml, serveErrorPage) to apps/cli/lib/php-error-handling.ts and simplify output capture by using the existing global console/stdout interceptors instead of duplicating them inside runCLIWithoutExit. --- apps/cli/lib/php-error-handling.ts | 90 +++++++++++++++ apps/cli/wordpress-server-child.ts | 175 ++++++----------------------- 2 files changed, 127 insertions(+), 138 deletions(-) create mode 100644 apps/cli/lib/php-error-handling.ts diff --git a/apps/cli/lib/php-error-handling.ts b/apps/cli/lib/php-error-handling.ts new file mode 100644 index 0000000000..240fac8ef5 --- /dev/null +++ b/apps/cli/lib/php-error-handling.ts @@ -0,0 +1,90 @@ +import type { Server as HttpServer } from 'http'; + +/** + * Determines if an error from runCLI() is caused by user PHP code (themes/plugins) + * rather than an infrastructure issue (WASM memory, port conflicts, etc.). + * + * Uses an exclusion list: known infrastructure errors return false, everything else + * is treated as a PHP user error. This is safer than an inclusion list because + * unrecognized errors show an error page (recoverable) instead of crashing the process. + */ +export function isPhpUserError( error: unknown ): boolean { + if ( ! ( error instanceof Error ) ) { + return false; + } + + const message = error.message; + + const isInfrastructureError = + message.includes( 'Cannot allocate Wasm memory' ) || + message.includes( 'EADDRINUSE' ) || + message.includes( 'Operation aborted' ) || + message.includes( '"unreachable" WASM instruction' ); + + return ! isInfrastructureError; +} + +/** + * Extract the actual PHP error from captured Playground output. + * + * Playground outputs PHP errors in HTML format like: + * Fatal error: Uncaught Error: Call to undefined function foo() in /path/file.php:12 + * or with the PHP.run() wrapper: + * Error: PHP.run() failed with exit code 255. + * Fatal error: Uncaught Error: ... + */ +export function parsePhpError( capturedOutput: string ): string { + // Match Playground's HTML-formatted fatal error output + // e.g., Fatal error: Uncaught Error: Call to undefined function foo() in /file.php:12 + const htmlFatalMatch = capturedOutput.match( + /Fatal error<\/b>:\s*(.+?)(?:\s+in\s+\/wordpress\/(.+?:\d+)|$)/i + ); + if ( htmlFatalMatch ) { + const errorDetail = htmlFatalMatch[ 1 ].trim(); + const location = htmlFatalMatch[ 2 ] ? ` in ${ htmlFatalMatch[ 2 ] }` : ''; + return `Fatal error: ${ errorDetail }${ location }`; + } + + // Match standard PHP fatal error format + const fatalMatch = capturedOutput.match( /PHP Fatal error:\s*(.+)/i ); + if ( fatalMatch ) { + return `PHP Fatal error: ${ fatalMatch[ 1 ].trim() }`; + } + + // Fall back to WordPress critical error HTML output + const wpDieMatch = capturedOutput.match( /
]*>([\s\S]*?)<\/div>/ ); + if ( wpDieMatch ) { + const textContent = wpDieMatch[ 1 ] + .replace( /<[^>]+>/g, ' ' ) + .replace( /\s+/g, ' ' ) + .trim(); + if ( textContent ) { + return `WordPress error: ${ textContent }`; + } + } + + return 'PHP error during startup'; +} + +export function generateErrorPageHtml( errorMessage: string ): string { + const escaped = errorMessage + .replace( /&/g, '&' ) + .replace( //g, '>' ); + return `PHP Error + +

PHP Error Detected

${ escaped }
+

Studio is watching for file changes. Fix the PHP error and the site will automatically restart.

`; +} + +/** + * Replaces the request handler on an HTTP server to serve an error page. + */ +export function serveErrorPage( httpServer: HttpServer, errorMessage: string ): void { + const html = generateErrorPageHtml( errorMessage ); + httpServer.removeAllListeners( 'request' ); + httpServer.on( 'request', ( _req, res ) => { + res.writeHead( 500, { 'Content-Type': 'text/html; charset=utf-8' } ); + res.end( html ); + } ); +} diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 56b7219ce4..d4b29d2e11 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -31,6 +31,7 @@ import { import { WordPressInstallMode } from '@wp-playground/wordpress'; import { z } from 'zod'; import { sanitizeRunCLIArgs } from 'cli/lib/cli-args-sanitizer'; +import { isPhpUserError, parsePhpError, serveErrorPage } from 'cli/lib/php-error-handling'; import { getSqliteCommandPath, getWpCliPharPath } from 'cli/lib/server-files'; import { isSqliteIntegrationInstalled } from 'cli/lib/sqlite-integration'; import { @@ -45,6 +46,11 @@ let lastCliArgs: Record< string, unknown > | null = null; let orphanedServer: HttpServer | null = null; let siteFileWatcher: FSWatcher | null = null; +// Output capture for diagnosing PHP errors during boot. +// When capturedBootOutput is non-null, all console/stdout output is recorded. +let capturedBootOutput: string[] | null = null; +let lastCapturedOutput: string | null = null; + // Intercept and prefix all console output from playground-cli const originalConsoleLog = console.log; const originalConsoleError = console.error; @@ -52,6 +58,7 @@ const originalConsoleWarn = console.warn; console.log = ( ...args: unknown[] ) => { originalConsoleLog( '[playground-cli]', ...args ); + capturedBootOutput?.push( args.map( String ).join( ' ' ) ); const message = args.join( ' ' ); process.send!( { topic: 'activity' } ); const formattedMessage = formatPlaygroundCliMessage( message ); @@ -62,6 +69,7 @@ console.log = ( ...args: unknown[] ) => { console.error = ( ...args: unknown[] ) => { originalConsoleError( '[playground-cli]', ...args ); + capturedBootOutput?.push( args.map( String ).join( ' ' ) ); process.send!( { topic: 'activity' } ); }; @@ -74,6 +82,7 @@ const originalStdoutWrite = process.stdout.write.bind( process.stdout ); const originalStderrWrite = process.stderr.write.bind( process.stderr ); process.stdout.write = function ( ...args: Parameters< typeof originalStdoutWrite > ) { + capturedBootOutput?.push( String( args[ 0 ] ) ); process.send!( { topic: 'activity' } ); return originalStdoutWrite( ...args ); } as typeof process.stdout.write; @@ -276,71 +285,6 @@ function wrapWithStartingPromise< Args extends unknown[], Return extends void >( }; } -/** - * Determines if an error from runCLI() is caused by user PHP code (themes/plugins) - * rather than an infrastructure issue (WASM memory, port conflicts, etc.). - */ -function isPhpUserError( error: unknown ): boolean { - if ( ! ( error instanceof Error ) ) { - return false; - } - - const message = error.message; - - // Infrastructure errors — should NOT trigger fallback - if ( - message.includes( 'Cannot allocate Wasm memory' ) || - message.includes( 'EADDRINUSE' ) || - message.includes( 'Operation aborted' ) || - message.includes( '"unreachable" WASM instruction' ) - ) { - return false; - } - - // PHP user errors — should trigger fallback - if ( message.match( /PHP Fatal error:/i ) ) { - return true; - } - if ( message.match( /Fatal error/i ) ) { - return true; - } - if ( message.includes( 'wp-die-message' ) ) { - return true; - } - if ( message.includes( 'PHP.run() failed with exit code' ) ) { - return true; - } - // Intercepted process.exit(1) from @wp-playground/cli during server startup - if ( message.includes( 'WordPress server startup failed' ) ) { - return true; - } - - return false; -} - -function generateErrorPageHtml( errorMessage: string ): string { - const escaped = errorMessage - .replace( /&/g, '&' ) - .replace( //g, '>' ); - return `PHP Error - -

PHP Error Detected

${ escaped }
-

Studio is watching for file changes. Fix the PHP error and the site will automatically restart.

`; -} - -/** - * Replaces the request handler on an HTTP server to serve an error page. - */ -function serveErrorPage( httpServer: HttpServer, errorMessage: string ): void { - const html = generateErrorPageHtml( errorMessage ); - httpServer.removeAllListeners( 'request' ); - httpServer.on( 'request', ( _req, res ) => { - res.writeHead( 500, { 'Content-Type': 'text/html; charset=utf-8' } ); - res.end( html ); - } ); -} - /** * Watch for PHP file changes and attempt to restart the server. */ @@ -383,8 +327,11 @@ function watchForPhpChanges( config: ServerConfig ): void { ); } } catch { - // runCLIWithoutExit already repurposed the orphaned server with new error logToConsole( 'Restart failed, still watching…' ); + if ( orphanedServer ) { + const errorMsg = parsePhpError( lastCapturedOutput || 'PHP error during startup' ); + serveErrorPage( orphanedServer, errorMsg ); + } } finally { retrying = false; } @@ -400,39 +347,22 @@ function watchForPhpChanges( config: ServerConfig ): void { * Playground's internal error handler calls process.exit(1) directly in a .catch(), * bypassing all try-catch blocks. This wrapper: * 1. Overrides process.exit to throw instead of exiting - * 2. Captures stdout/console output so we get the actual PHP error message + * 2. Enables output capture via the global console/stdout interceptors * 3. Intercepts http.createServer to capture Playground's HTTP server reference - * 4. On failure, repurposes the orphaned server to serve an error page + * 4. On failure, the orphaned server is repurposed to serve an error page */ + async function runCLIWithoutExit( args: RunCLIArgs & { command: 'server' } ): Promise< RunCLIServer > { const originalExit = process.exit; - const capturedOutput: string[] = []; - - // Capture all output channels — PHP WASM writes errors via process.stdout.write, - // while Playground's printError uses console.log/error. - const savedConsoleLog = console.log; - const savedConsoleError = console.error; - const savedStdoutWrite = process.stdout.write; - console.log = ( ...logArgs: unknown[] ) => { - capturedOutput.push( logArgs.map( String ).join( ' ' ) ); - savedConsoleLog( ...logArgs ); - }; - console.error = ( ...errorArgs: unknown[] ) => { - capturedOutput.push( errorArgs.map( String ).join( ' ' ) ); - savedConsoleError( ...errorArgs ); - }; - process.stdout.write = function ( ...writeArgs: Parameters< typeof savedStdoutWrite > ) { - capturedOutput.push( String( writeArgs[ 0 ] ) ); - return savedStdoutWrite( ...writeArgs ); - } as typeof process.stdout.write; + + // Enable output capture via the global interceptors + capturedBootOutput = []; process.exit = ( ( code?: number ) => { if ( code !== 0 ) { - const errorMsg = - capturedOutput.join( '\n' ) || `WordPress server startup failed (exit code ${ code })`; - throw new Error( errorMsg ); + throw new Error( `WordPress server startup failed (exit code ${ code })` ); } return originalExit( code ); } ) as typeof process.exit; @@ -452,13 +382,13 @@ async function runCLIWithoutExit( try { return await runCLI( args ); } catch ( error ) { - // Repurpose Playground's orphaned HTTP server to serve our error page. + // Save captured output for error parsing + lastCapturedOutput = ( capturedBootOutput ?? [] ).join( '\n' ); + + // Keep Playground's orphaned HTTP server so the caller can repurpose it. // This avoids EADDRINUSE — the server is already bound to the port. if ( createdServers.length > 0 ) { - const srv = createdServers[ 0 ]; - serveErrorPage( srv, parsePhpError( error ) ); - orphanedServer = srv; - // Close any extra servers (unlikely, but be safe) + orphanedServer = createdServers[ 0 ]; for ( let i = 1; i < createdServers.length; i++ ) { createdServers[ i ].close(); } @@ -466,9 +396,7 @@ async function runCLIWithoutExit( throw error; } finally { process.exit = originalExit; - console.log = savedConsoleLog; - console.error = savedConsoleError; - process.stdout.write = savedStdoutWrite; + capturedBootOutput = null; http.createServer = originalCreateServer; } } @@ -505,8 +433,15 @@ const startServer = wrapWithStartingPromise( server = null; if ( isPhpUserError( error ) ) { - logToConsole( `PHP error detected during startup: ${ parsePhpError( error ) }` ); - // orphanedServer is already serving the error page (set by runCLIWithoutExit) + const errorMessage = parsePhpError( + lastCapturedOutput || ( error instanceof Error ? error.message : '' ) + ); + logToConsole( `PHP error detected during startup: ${ errorMessage }` ); + + if ( orphanedServer ) { + serveErrorPage( orphanedServer, errorMessage ); + logToConsole( 'Error page server listening. Watching for file changes…' ); + } watchForPhpChanges( config ); return; } @@ -607,48 +542,12 @@ const runWpCliCommand = sequential( { concurrent: 3, max: 100, deduplicateKey: ( args: string[] ) => args.join( ' ' ) } ); -function parsePhpError( error: unknown ): string { - if ( ! ( error instanceof Error ) ) { - return String( error ); - } - - const message = error.message; - - // Check for WordPress critical error in HTML output - const wpDieMatch = message.match( /
]*>([\s\S]*?)<\/div>/ ); - if ( wpDieMatch ) { - // Extract text from HTML, removing tags - const htmlContent = wpDieMatch[ 1 ]; - const textContent = htmlContent - .replace( /<[^>]+>/g, ' ' ) - .replace( /\s+/g, ' ' ) - .trim(); - if ( textContent ) { - return `WordPress error: ${ textContent }`; - } - } - - // Check for PHP fatal error pattern - const fatalMatch = message.match( /PHP Fatal error:\s*(.+?)(?:\sin\s|$)/i ); - if ( fatalMatch ) { - return `PHP Fatal error: ${ fatalMatch[ 1 ].trim() }`; - } - - // Check for generic PHP.run() failure - provide a cleaner message - if ( message.includes( 'PHP.run() failed with exit code' ) ) { - const exitCodeMatch = message.match( /exit code (\d+)/ ); - const exitCode = exitCodeMatch ? exitCodeMatch[ 1 ] : 'unknown'; - return `WordPress failed to start (PHP exit code ${ exitCode }). Check the site's debug.log for details.`; - } - - return message; -} - function sendErrorMessage( messageId: string, error: unknown ) { + const errorMessage = error instanceof Error ? error.message : String( error ); const errorResponse: ChildMessageRaw = { originalMessageId: messageId, topic: 'error', - errorMessage: parsePhpError( error ), + errorMessage, errorStack: error instanceof Error ? error.stack : undefined, cliArgs: lastCliArgs ?? undefined, }; From 397504ab7b716552718629b5abac3cae73aa5ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 16 Mar 2026 20:48:24 +0000 Subject: [PATCH 3/6] Fix: guard file watcher and error page behind orphaned server check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Playground fails before creating an HTTP server, the orphaned server is null. Previously, the file watcher started unconditionally and the function returned success — leaving nothing on the port and silently retrying with no user feedback. Now the watcher only starts when there is an orphaned server to serve the error page, and the PHP error is sent as a console-message so the parent/UI can display it. Without an orphaned server, the error is rethrown so the parent shows a dialog. --- apps/cli/wordpress-server-child.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index d4b29d2e11..4914d0989b 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -441,9 +441,17 @@ const startServer = wrapWithStartingPromise( if ( orphanedServer ) { serveErrorPage( orphanedServer, errorMessage ); logToConsole( 'Error page server listening. Watching for file changes…' ); + watchForPhpChanges( config ); + process.send!( { + topic: 'console-message', + message: `PHP error: ${ errorMessage }`, + } ); + return; } - watchForPhpChanges( config ); - return; + + // No orphaned server to serve an error page — rethrow so the parent + // receives an error and can show a dialog to the user. + throw error; } errorToConsole( `Failed to start server:`, error ); From 012a9a2828f50e6ce1c158e67bad0344da2e8053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Mar 2026 11:10:10 +0000 Subject: [PATCH 4/6] Clean up minor issues in PHP error handling child process - Remove double-capture of stdout (console.log already captures to capturedBootOutput; the process.stdout.write interceptor was duplicating every entry) - Clear lastCapturedOutput after successful server recovery to free stale error output from memory - Clean up abortControllers record after message handling to prevent unbounded growth over the process lifetime --- apps/cli/wordpress-server-child.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 4914d0989b..c648c042c9 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -82,7 +82,6 @@ const originalStdoutWrite = process.stdout.write.bind( process.stdout ); const originalStderrWrite = process.stderr.write.bind( process.stderr ); process.stdout.write = function ( ...args: Parameters< typeof originalStdoutWrite > ) { - capturedBootOutput?.push( String( args[ 0 ] ) ); process.send!( { topic: 'activity' } ); return originalStdoutWrite( ...args ); } as typeof process.stdout.write; @@ -313,9 +312,10 @@ function watchForPhpChanges( config: ServerConfig ): void { lastCliArgs = sanitizeRunCLIArgs( args ); server = await runCLIWithoutExit( args ); - // Success — clean up watcher + // Success — clean up watcher and stale state siteFileWatcher?.close(); siteFileWatcher = null; + lastCapturedOutput = null; logToConsole( 'Server restarted successfully' ); if ( config.adminPassword || config.adminUsername || config.adminEmail ) { @@ -624,6 +624,8 @@ async function ipcMessageHandler( packet: unknown ) { errorToConsole( `Error handling message ${ validMessage.topic }:`, error ); sendErrorMessage( validMessage.messageId, error ); process.exit( 1 ); + } finally { + delete abortControllers[ validMessage.messageId ]; } } From 27261d3233258323cbfc2e01e400374c143f2a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Mar 2026 13:02:53 +0000 Subject: [PATCH 5/6] Restore stdout capture needed for PHP error parsing The cleanup commit incorrectly removed the stdout capture line, but Playground outputs PHP errors to stdout, not console.log. Without this, parsePhpError falls back to the generic message. --- apps/cli/wordpress-server-child.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index c648c042c9..e05436b876 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -82,6 +82,7 @@ const originalStdoutWrite = process.stdout.write.bind( process.stdout ); const originalStderrWrite = process.stderr.write.bind( process.stderr ); process.stdout.write = function ( ...args: Parameters< typeof originalStdoutWrite > ) { + capturedBootOutput?.push( String( args[ 0 ] ) ); process.send!( { topic: 'activity' } ); return originalStdoutWrite( ...args ); } as typeof process.stdout.write; From 030263a4d0af5749ea659107afc089529de8f7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 19 Mar 2026 17:37:10 +0000 Subject: [PATCH 6/6] Move PHP error recovery from child process to main process Instead of overriding process.exit and http.createServer inside the child process (fragile), handle PHP boot errors in the main process: - When the child dies from a PHP fatal error, the main process reads PM2 logs, starts an error-page HTTP server on the same port, and watches for PHP file changes to auto-restart. - Emits a site-event so the renderer shows the site as "running". - Increases PM2 log read limit from 50 to 200 lines so the specific PHP error isn't lost behind WordPress's verbose error page HTML. - Removes all process.exit/http.createServer overrides from the child. --- apps/cli/lib/php-error-handling.ts | 90 ---------- apps/cli/wordpress-server-child.ts | 206 ++-------------------- apps/studio/src/ipc-handlers.ts | 122 +++++++++---- apps/studio/src/lib/php-error-recovery.ts | 194 ++++++++++++++++++++ 4 files changed, 298 insertions(+), 314 deletions(-) delete mode 100644 apps/cli/lib/php-error-handling.ts create mode 100644 apps/studio/src/lib/php-error-recovery.ts diff --git a/apps/cli/lib/php-error-handling.ts b/apps/cli/lib/php-error-handling.ts deleted file mode 100644 index 240fac8ef5..0000000000 --- a/apps/cli/lib/php-error-handling.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Server as HttpServer } from 'http'; - -/** - * Determines if an error from runCLI() is caused by user PHP code (themes/plugins) - * rather than an infrastructure issue (WASM memory, port conflicts, etc.). - * - * Uses an exclusion list: known infrastructure errors return false, everything else - * is treated as a PHP user error. This is safer than an inclusion list because - * unrecognized errors show an error page (recoverable) instead of crashing the process. - */ -export function isPhpUserError( error: unknown ): boolean { - if ( ! ( error instanceof Error ) ) { - return false; - } - - const message = error.message; - - const isInfrastructureError = - message.includes( 'Cannot allocate Wasm memory' ) || - message.includes( 'EADDRINUSE' ) || - message.includes( 'Operation aborted' ) || - message.includes( '"unreachable" WASM instruction' ); - - return ! isInfrastructureError; -} - -/** - * Extract the actual PHP error from captured Playground output. - * - * Playground outputs PHP errors in HTML format like: - * Fatal error: Uncaught Error: Call to undefined function foo() in /path/file.php:12 - * or with the PHP.run() wrapper: - * Error: PHP.run() failed with exit code 255. - * Fatal error: Uncaught Error: ... - */ -export function parsePhpError( capturedOutput: string ): string { - // Match Playground's HTML-formatted fatal error output - // e.g., Fatal error: Uncaught Error: Call to undefined function foo() in /file.php:12 - const htmlFatalMatch = capturedOutput.match( - /Fatal error<\/b>:\s*(.+?)(?:\s+in\s+\/wordpress\/(.+?:\d+)|$)/i - ); - if ( htmlFatalMatch ) { - const errorDetail = htmlFatalMatch[ 1 ].trim(); - const location = htmlFatalMatch[ 2 ] ? ` in ${ htmlFatalMatch[ 2 ] }` : ''; - return `Fatal error: ${ errorDetail }${ location }`; - } - - // Match standard PHP fatal error format - const fatalMatch = capturedOutput.match( /PHP Fatal error:\s*(.+)/i ); - if ( fatalMatch ) { - return `PHP Fatal error: ${ fatalMatch[ 1 ].trim() }`; - } - - // Fall back to WordPress critical error HTML output - const wpDieMatch = capturedOutput.match( /
]*>([\s\S]*?)<\/div>/ ); - if ( wpDieMatch ) { - const textContent = wpDieMatch[ 1 ] - .replace( /<[^>]+>/g, ' ' ) - .replace( /\s+/g, ' ' ) - .trim(); - if ( textContent ) { - return `WordPress error: ${ textContent }`; - } - } - - return 'PHP error during startup'; -} - -export function generateErrorPageHtml( errorMessage: string ): string { - const escaped = errorMessage - .replace( /&/g, '&' ) - .replace( //g, '>' ); - return `PHP Error - -

PHP Error Detected

${ escaped }
-

Studio is watching for file changes. Fix the PHP error and the site will automatically restart.

`; -} - -/** - * Replaces the request handler on an HTTP server to serve an error page. - */ -export function serveErrorPage( httpServer: HttpServer, errorMessage: string ): void { - const html = generateErrorPageHtml( errorMessage ); - httpServer.removeAllListeners( 'request' ); - httpServer.on( 'request', ( _req, res ) => { - res.writeHead( 500, { 'Content-Type': 'text/html; charset=utf-8' } ); - res.end( html ); - } ); -} diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index e05436b876..f591068d0f 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -10,8 +10,6 @@ * - Sends response back when ready * - Sends activity heartbeats to prevent timeout during long operations */ -import { watch, type FSWatcher } from 'fs'; -import http, { type Server as HttpServer } from 'http'; import { dirname } from 'path'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { isWordPressDirectory } from '@studio/common/lib/fs-utils'; @@ -31,7 +29,6 @@ import { import { WordPressInstallMode } from '@wp-playground/wordpress'; import { z } from 'zod'; import { sanitizeRunCLIArgs } from 'cli/lib/cli-args-sanitizer'; -import { isPhpUserError, parsePhpError, serveErrorPage } from 'cli/lib/php-error-handling'; import { getSqliteCommandPath, getWpCliPharPath } from 'cli/lib/server-files'; import { isSqliteIntegrationInstalled } from 'cli/lib/sqlite-integration'; import { @@ -43,13 +40,6 @@ import { let server: RunCLIServer | null = null; let startingPromise: Promise< void > | null = null; let lastCliArgs: Record< string, unknown > | null = null; -let orphanedServer: HttpServer | null = null; -let siteFileWatcher: FSWatcher | null = null; - -// Output capture for diagnosing PHP errors during boot. -// When capturedBootOutput is non-null, all console/stdout output is recorded. -let capturedBootOutput: string[] | null = null; -let lastCapturedOutput: string | null = null; // Intercept and prefix all console output from playground-cli const originalConsoleLog = console.log; @@ -58,7 +48,6 @@ const originalConsoleWarn = console.warn; console.log = ( ...args: unknown[] ) => { originalConsoleLog( '[playground-cli]', ...args ); - capturedBootOutput?.push( args.map( String ).join( ' ' ) ); const message = args.join( ' ' ); process.send!( { topic: 'activity' } ); const formattedMessage = formatPlaygroundCliMessage( message ); @@ -69,7 +58,6 @@ console.log = ( ...args: unknown[] ) => { console.error = ( ...args: unknown[] ) => { originalConsoleError( '[playground-cli]', ...args ); - capturedBootOutput?.push( args.map( String ).join( ' ' ) ); process.send!( { topic: 'activity' } ); }; @@ -82,7 +70,6 @@ const originalStdoutWrite = process.stdout.write.bind( process.stdout ); const originalStderrWrite = process.stderr.write.bind( process.stderr ); process.stdout.write = function ( ...args: Parameters< typeof originalStdoutWrite > ) { - capturedBootOutput?.push( String( args[ 0 ] ) ); process.send!( { topic: 'activity' } ); return originalStdoutWrite( ...args ); } as typeof process.stdout.write; @@ -285,123 +272,6 @@ function wrapWithStartingPromise< Args extends unknown[], Return extends void >( }; } -/** - * Watch for PHP file changes and attempt to restart the server. - */ -function watchForPhpChanges( config: ServerConfig ): void { - let debounce: ReturnType< typeof setTimeout > | null = null; - let retrying = false; - - siteFileWatcher = watch( config.sitePath, { recursive: true }, ( _event, filename ) => { - if ( ! filename?.endsWith( '.php' ) || retrying ) { - return; - } - if ( debounce ) { - clearTimeout( debounce ); - } - debounce = setTimeout( () => { - void ( async () => { - retrying = true; - logToConsole( 'PHP file change detected, restarting server…' ); - try { - // Close orphaned server to free the port for the new runCLI - if ( orphanedServer ) { - orphanedServer.close(); - orphanedServer = null; - } - const args = await getBaseRunCLIArgs( 'server', config ); - lastCliArgs = sanitizeRunCLIArgs( args ); - server = await runCLIWithoutExit( args ); - - // Success — clean up watcher and stale state - siteFileWatcher?.close(); - siteFileWatcher = null; - lastCapturedOutput = null; - logToConsole( 'Server restarted successfully' ); - - if ( config.adminPassword || config.adminUsername || config.adminEmail ) { - await setAdminCredentials( - server, - config.adminPassword, - config.adminUsername, - config.adminEmail - ); - } - } catch { - logToConsole( 'Restart failed, still watching…' ); - if ( orphanedServer ) { - const errorMsg = parsePhpError( lastCapturedOutput || 'PHP error during startup' ); - serveErrorPage( orphanedServer, errorMsg ); - } - } finally { - retrying = false; - } - } )(); - }, 2000 ); - } ); -} - -/** - * Wraps runCLI to prevent @wp-playground/cli from calling process.exit(1) on - * PHP fatal errors, and to capture orphaned HTTP servers for reuse. - * - * Playground's internal error handler calls process.exit(1) directly in a .catch(), - * bypassing all try-catch blocks. This wrapper: - * 1. Overrides process.exit to throw instead of exiting - * 2. Enables output capture via the global console/stdout interceptors - * 3. Intercepts http.createServer to capture Playground's HTTP server reference - * 4. On failure, the orphaned server is repurposed to serve an error page - */ - -async function runCLIWithoutExit( - args: RunCLIArgs & { command: 'server' } -): Promise< RunCLIServer > { - const originalExit = process.exit; - - // Enable output capture via the global interceptors - capturedBootOutput = []; - - process.exit = ( ( code?: number ) => { - if ( code !== 0 ) { - throw new Error( `WordPress server startup failed (exit code ${ code })` ); - } - return originalExit( code ); - } ) as typeof process.exit; - - // Intercept HTTP servers created by Playground so we can repurpose them on failure. - // Playground binds an HTTP server to the port before booting WordPress — if boot fails, - // this orphaned server still holds the port. - const createdServers: HttpServer[] = []; - const originalCreateServer = http.createServer; - http.createServer = ( ( ...createArgs: unknown[] ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const srv = ( originalCreateServer as any )( ...createArgs ) as HttpServer; - createdServers.push( srv ); - return srv; - } ) as typeof http.createServer; - - try { - return await runCLI( args ); - } catch ( error ) { - // Save captured output for error parsing - lastCapturedOutput = ( capturedBootOutput ?? [] ).join( '\n' ); - - // Keep Playground's orphaned HTTP server so the caller can repurpose it. - // This avoids EADDRINUSE — the server is already bound to the port. - if ( createdServers.length > 0 ) { - orphanedServer = createdServers[ 0 ]; - for ( let i = 1; i < createdServers.length; i++ ) { - createdServers[ i ].close(); - } - } - throw error; - } finally { - process.exit = originalExit; - capturedBootOutput = null; - http.createServer = originalCreateServer; - } -} - const startServer = wrapWithStartingPromise( async ( config: ServerConfig, signal: AbortSignal ): Promise< void > => { if ( server ) { @@ -409,54 +279,25 @@ const startServer = wrapWithStartingPromise( return; } - try { - signal.addEventListener( - 'abort', - () => { - throw new Error( 'Operation aborted' ); - }, - { once: true } - ); - - const args = await getBaseRunCLIArgs( 'server', config ); - lastCliArgs = sanitizeRunCLIArgs( args ); - server = await runCLIWithoutExit( args ); - - if ( config.adminPassword || config.adminUsername || config.adminEmail ) { - await setAdminCredentials( - server, - config.adminPassword, - config.adminUsername, - config.adminEmail - ); - } - } catch ( error ) { - server = null; - - if ( isPhpUserError( error ) ) { - const errorMessage = parsePhpError( - lastCapturedOutput || ( error instanceof Error ? error.message : '' ) - ); - logToConsole( `PHP error detected during startup: ${ errorMessage }` ); - - if ( orphanedServer ) { - serveErrorPage( orphanedServer, errorMessage ); - logToConsole( 'Error page server listening. Watching for file changes…' ); - watchForPhpChanges( config ); - process.send!( { - topic: 'console-message', - message: `PHP error: ${ errorMessage }`, - } ); - return; - } - - // No orphaned server to serve an error page — rethrow so the parent - // receives an error and can show a dialog to the user. - throw error; - } + signal.addEventListener( + 'abort', + () => { + throw new Error( 'Operation aborted' ); + }, + { once: true } + ); - errorToConsole( `Failed to start server:`, error ); - throw error; + const args = await getBaseRunCLIArgs( 'server', config ); + lastCliArgs = sanitizeRunCLIArgs( args ); + server = await runCLI( args ); + + if ( config.adminPassword || config.adminUsername || config.adminEmail ) { + await setAdminCredentials( + server, + config.adminPassword, + config.adminUsername, + config.adminEmail + ); } } ); @@ -464,17 +305,6 @@ const startServer = wrapWithStartingPromise( const STOP_SERVER_TIMEOUT = 5000; async function stopServer(): Promise< void > { - if ( siteFileWatcher ) { - siteFileWatcher.close(); - siteFileWatcher = null; - } - if ( orphanedServer ) { - orphanedServer.close(); - orphanedServer = null; - logToConsole( 'Error page server stopped' ); - return; - } - if ( ! server ) { logToConsole( 'No server running, nothing to stop' ); return; diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 03d38462d8..a5ff80df92 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -35,12 +35,13 @@ import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; import { portFinder } from '@studio/common/lib/port-finder'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; +import { SITE_EVENTS } from '@studio/common/lib/site-events'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; import { Snapshot } from '@studio/common/types/snapshot'; import { StatsGroup, StatsMetric } from '@studio/common/types/stats'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { MACOS_TRAFFIC_LIGHT_POSITION, MAIN_MIN_WIDTH, SIDEBAR_WIDTH } from 'src/constants'; -import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; +import { sendIpcEventToRenderer, sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { getBetaFeatures as getBetaFeaturesFromLib } from 'src/lib/beta-features'; import { getImporterMetric, getBlueprintMetric } from 'src/lib/bump-stats/lib'; import { @@ -58,6 +59,12 @@ import { defaultImporterOptions, importBackup } from 'src/lib/import-export/impo import { BackupArchiveInfo } from 'src/lib/import-export/import/types'; import { getUserLocaleWithFallback } from 'src/lib/locale-node'; import * as oauthClient from 'src/lib/oauth'; +import { + isPhpUserError, + parsePhpError, + startErrorRecovery, + stopErrorRecovery, +} from 'src/lib/php-error-recovery'; import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper'; import { keepSqliteIntegrationUpdated } from 'src/lib/sqlite-versions'; import * as windowsHelpers from 'src/lib/windows-helpers'; @@ -186,14 +193,16 @@ function readWordPressDebugLog( sitePath: string ): string[] | undefined { return readLastLines( debugLogPath, DEBUG_LOG_MAX_LINES ); } +const PM2_LOG_MAX_LINES = 200; + function readPm2Logs( siteId: string ): { stdout?: string[]; stderr?: string[] } { const logsDir = nodePath.join( PM2_HOME, 'logs' ); const stdoutPath = nodePath.join( logsDir, `studio-site-${ siteId }-out.log` ); const stderrPath = nodePath.join( logsDir, `studio-site-${ siteId }-error.log` ); return { - stdout: readLastLines( stdoutPath, DEBUG_LOG_MAX_LINES ), - stderr: readLastLines( stderrPath, DEBUG_LOG_MAX_LINES ), + stdout: readLastLines( stdoutPath, PM2_LOG_MAX_LINES ), + stderr: readLastLines( stderrPath, PM2_LOG_MAX_LINES ), }; } @@ -496,44 +505,47 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom throw new Error( 'WASM_ERROR_NOT_ENOUGH_MEMORY' ); } - const contexts: Record< string, Record< string, unknown > > = { - server: { - running: server.details.running, - phpVersion: server.details.phpVersion, - port: server.details.port, - hasCustomDomain: !! server.details.customDomain, - httpsEnabled: !! server.details.enableHttps, - }, - }; - - const cliError = parseCliError( error ); - if ( cliError?.cliArgs ) { - contexts.startup = cliError.cliArgs; - } - - const debugLog = readWordPressDebugLog( server.details.path ); - if ( debugLog && debugLog.length > 0 ) { - contexts.debugLog = { entries: debugLog }; + if ( errorMessageContains( error, '"unreachable" WASM instruction executed' ) ) { + captureStartServerException( error, server, id ); + throw new Error( 'Please try disabling plugins and themes that might be causing the issue.' ); } - const pm2Logs = readPm2Logs( id ); - if ( pm2Logs.stdout && pm2Logs.stdout.length > 0 ) { - contexts.playgroundLogs = { entries: pm2Logs.stdout }; - } - if ( pm2Logs.stderr && pm2Logs.stderr.length > 0 ) { - contexts.playgroundErrors = { entries: pm2Logs.stderr }; - } + // Check PM2 logs for PHP errors — if found, serve an error page and watch for fixes + if ( isPhpUserError( error ) ) { + const pm2Logs = readPm2Logs( id ); + const logContent = [ ...( pm2Logs.stdout ?? [] ), ...( pm2Logs.stderr ?? [] ) ].join( '\n' ); + const errorMessage = parsePhpError( logContent ); - Sentry.captureException( error, { - tags: { - provider: 'cli', - }, - contexts, - } ); + try { + await startErrorRecovery( server, errorMessage, readPm2Logs ); + console.log( + `[PHP Recovery - ${ id }] Error page serving on port ${ server.details.port }` + ); - if ( errorMessageContains( error, '"unreachable" WASM instruction executed' ) ) { - throw new Error( 'Please try disabling plugins and themes that might be causing the issue.' ); + // Notify renderer that the site is "running" (serving the error page) + void sendIpcEventToRenderer( 'site-event', { + event: SITE_EVENTS.UPDATED, + siteId: id, + site: { + id: server.details.id, + name: server.details.name, + path: server.details.path, + port: server.details.port, + url: + ( 'url' in server.details ? server.details.url : undefined ) ?? + `http://localhost:${ server.details.port }`, + phpVersion: server.details.phpVersion, + }, + running: true, + } ); + return; + } catch ( recoveryError ) { + console.error( `[PHP Recovery - ${ id }] Failed to start recovery:`, recoveryError ); + // Fall through to throw original error + } } + + captureStartServerException( error, server, id ); throw error; } @@ -544,12 +556,50 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom console.log( `Server started for '${ server.details.name }'` ); } +function captureStartServerException( error: unknown, server: SiteServer, id: string ): void { + const contexts: Record< string, Record< string, unknown > > = { + server: { + running: server.details.running, + phpVersion: server.details.phpVersion, + port: server.details.port, + hasCustomDomain: !! server.details.customDomain, + httpsEnabled: !! server.details.enableHttps, + }, + }; + + const cliError = parseCliError( error ); + if ( cliError?.cliArgs ) { + contexts.startup = cliError.cliArgs; + } + + const debugLog = readWordPressDebugLog( server.details.path ); + if ( debugLog && debugLog.length > 0 ) { + contexts.debugLog = { entries: debugLog }; + } + + const pm2Logs = readPm2Logs( id ); + if ( pm2Logs.stdout && pm2Logs.stdout.length > 0 ) { + contexts.playgroundLogs = { entries: pm2Logs.stdout }; + } + if ( pm2Logs.stderr && pm2Logs.stderr.length > 0 ) { + contexts.playgroundErrors = { entries: pm2Logs.stderr }; + } + + Sentry.captureException( error, { + tags: { + provider: 'cli', + }, + contexts, + } ); +} + export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { const server = SiteServer.get( id ); if ( ! server ) { return; } + stopErrorRecovery( id ); await server.stop(); } diff --git a/apps/studio/src/lib/php-error-recovery.ts b/apps/studio/src/lib/php-error-recovery.ts new file mode 100644 index 0000000000..a452458fb2 --- /dev/null +++ b/apps/studio/src/lib/php-error-recovery.ts @@ -0,0 +1,194 @@ +import fs from 'fs'; +import http from 'http'; +import type { SiteServer } from 'src/site-server'; + +const activeRecoveries = new Map< + string, + { + errorServer: http.Server; + watcher: fs.FSWatcher; + debounceTimer?: ReturnType< typeof setTimeout >; + } +>(); + +/** + * Determines if an error from a site start failure is caused by user PHP code + * (themes/plugins) rather than an infrastructure issue (WASM memory, port conflicts, etc.). + * + * Uses an exclusion list: known infrastructure errors return false, everything else + * is treated as a PHP user error. + */ +export function isPhpUserError( error: unknown ): boolean { + if ( ! ( error instanceof Error ) ) { + return false; + } + + const message = error.message; + + const isInfrastructureError = + message.includes( 'Cannot allocate Wasm memory' ) || + message.includes( 'EADDRINUSE' ) || + message.includes( 'Operation aborted' ) || + message.includes( '"unreachable" WASM instruction' ); + + return ! isInfrastructureError; +} + +/** + * Extract the actual PHP error from PM2 log output. + * + * Playground outputs PHP errors in HTML format like: + * Fatal error: Uncaught Error: Call to undefined function foo() in /path/file.php:12 + */ +export function parsePhpError( logContent: string ): string { + const htmlFatalMatch = logContent.match( + /Fatal error<\/b>:\s*(.+?)(?:\s+in\s+\/wordpress\/(.+?:\d+)|$)/i + ); + if ( htmlFatalMatch ) { + const errorDetail = htmlFatalMatch[ 1 ].trim(); + const location = htmlFatalMatch[ 2 ] ? ` in ${ htmlFatalMatch[ 2 ] }` : ''; + return `Fatal error: ${ errorDetail }${ location }`; + } + + const fatalMatch = logContent.match( /PHP Fatal error:\s*(.+)/i ); + if ( fatalMatch ) { + return `PHP Fatal error: ${ fatalMatch[ 1 ].trim() }`; + } + + const wpDieMatch = logContent.match( /
]*>([\s\S]*?)<\/div>/ ); + if ( wpDieMatch ) { + const textContent = wpDieMatch[ 1 ] + .replace( /<[^>]+>/g, ' ' ) + .replace( /\s+/g, ' ' ) + .trim(); + if ( textContent ) { + return `WordPress error: ${ textContent }`; + } + } + + return 'PHP error during startup'; +} + +function generateErrorPageHtml( errorMessage: string ): string { + const escaped = errorMessage + .replace( /&/g, '&' ) + .replace( //g, '>' ); + return `PHP Error + +

PHP Error Detected

${ escaped }
+

Studio is watching for file changes. Fix the PHP error and the site will automatically restart.

`; +} + +/** + * Start error recovery for a site with a PHP error. + * Serves an error page on the site's port and watches for PHP file changes. + * On file change, retries starting the site. + */ +export async function startErrorRecovery( + siteServer: SiteServer, + errorMessage: string, + readPm2Logs: ( siteId: string ) => { stdout?: string[]; stderr?: string[] } +): Promise< void > { + const { id, port, path } = siteServer.details; + + stopErrorRecovery( id ); + + const errorServer = http.createServer( ( _req, res ) => { + res.writeHead( 500, { 'Content-Type': 'text/html; charset=utf-8' } ); + res.end( generateErrorPageHtml( errorMessage ) ); + } ); + + await new Promise< void >( ( resolve, reject ) => { + errorServer.on( 'error', reject ); + errorServer.listen( port, () => resolve() ); + } ); + + let retrying = false; + const watcher = fs.watch( path, { recursive: true }, ( _event, filename ) => { + if ( ! filename?.endsWith( '.php' ) || retrying ) { + return; + } + + const recovery = activeRecoveries.get( id ); + if ( recovery?.debounceTimer ) { + clearTimeout( recovery.debounceTimer ); + } + + if ( recovery ) { + recovery.debounceTimer = setTimeout( () => { + void ( async () => { + retrying = true; + console.log( `[PHP Recovery - ${ id }] PHP file change detected, retrying...` ); + try { + errorServer.close(); + await siteServer.start(); + + // Success - clean up recovery + stopErrorRecovery( id ); + console.log( `[PHP Recovery - ${ id }] Site recovered successfully` ); + } catch ( retryError ) { + // Still failing - restart error server with updated error + console.log( `[PHP Recovery - ${ id }] Retry failed, still watching...` ); + const pm2Logs = readPm2Logs( id ); + const logContent = [ ...( pm2Logs.stdout ?? [] ), ...( pm2Logs.stderr ?? [] ) ].join( + '\n' + ); + const newErrorMessage = parsePhpError( logContent ); + const newHtml = generateErrorPageHtml( newErrorMessage ); + + const newErrorServer = http.createServer( ( _req, res ) => { + res.writeHead( 500, { 'Content-Type': 'text/html; charset=utf-8' } ); + res.end( newHtml ); + } ); + await new Promise< void >( ( resolve ) => { + newErrorServer.listen( port, () => resolve() ); + } ); + + const currentRecovery = activeRecoveries.get( id ); + if ( currentRecovery ) { + currentRecovery.errorServer = newErrorServer; + } + } finally { + retrying = false; + } + } )(); + }, 500 ); + } + } ); + + activeRecoveries.set( id, { errorServer, watcher } ); + + // Mark site as running so the UI shows it + const url = `http://localhost:${ port }`; + siteServer.details = { + ...siteServer.details, + running: true, + url, + }; + siteServer.server.url = url; +} + +/** + * Stop error recovery for a site (clean up error server and file watcher). + */ +export function stopErrorRecovery( siteId: string ): void { + const recovery = activeRecoveries.get( siteId ); + if ( ! recovery ) { + return; + } + + if ( recovery.debounceTimer ) { + clearTimeout( recovery.debounceTimer ); + } + recovery.watcher.close(); + recovery.errorServer.close(); + activeRecoveries.delete( siteId ); +} + +/** + * Check if a site is currently in error recovery mode. + */ +export function isInErrorRecovery( siteId: string ): boolean { + return activeRecoveries.has( siteId ); +}