diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 881a9574de..5e384b1721 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -568,6 +568,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' } ); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 2287e671df..7c88bd45e8 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -40,10 +40,11 @@ import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; import { readSharedConfig, updateSharedConfig } from '@studio/common/lib/shared-config'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; 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 { bumpStat, @@ -68,6 +69,12 @@ import { BackupArchiveInfo } from 'src/lib/import-export/import/types'; import { getUserLocaleWithFallback } from 'src/lib/locale-node'; import { setSentryWpcomUserIdMain } from 'src/lib/main-sentry-utils'; import * as oauthClient from 'src/lib/oauth'; +import { + isPhpUserError, + parsePhpError, + startErrorRecovery, + stopErrorRecovery, +} from 'src/lib/php-error-recovery'; import { getAiInstructionsPath } from 'src/lib/server-files-paths'; import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper'; import { keepSqliteIntegrationUpdated } from 'src/lib/sqlite-versions'; @@ -318,14 +325,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 ), }; } @@ -589,44 +598,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; } @@ -644,12 +656,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 ); +}