From e980b11a81babb1e7d5c503a041e33e634d2ff75 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 14:45:27 -0400 Subject: [PATCH 1/3] Studio: Start a site from its path in cli.json instead of listing every site first --- apps/studio/src/ipc-handlers.ts | 31 ++++++++++--------- apps/studio/src/tests/ipc-handlers.test.ts | 35 +++++++++++++++++++++- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 55debfe35f..7ae03eb1d9 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -613,7 +613,8 @@ export async function updateSite( } export async function startServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { - const site = await getSite( id ); + // Starting only needs the path, which `cli.json` has; the CLI's full details wait for a failure. + const site = await readSite( id ); if ( ! site ) { return; } @@ -632,10 +633,12 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom throw error; } + const details = await getSite( id ).catch( () => undefined ); + // A fatal error in the user's own PHP (theme/plugin) code stops WordPress from booting. // Rather than failing the start, serve the parsed PHP error on the site's port and watch for // the fix so the site self-recovers. This is user code, not a Studio bug, so skip Sentry. - if ( isPhpUserError( error ) ) { + if ( details && isPhpUserError( error ) ) { const processManagerLogs = readProcessManagerLogs( id ); const logContent = [ ...( processManagerLogs.stdout ?? [] ), @@ -644,19 +647,19 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom const errorMessage = parsePhpError( logContent ); try { - await startErrorRecovery( site, errorMessage, readProcessManagerLogs, start ); - console.log( `[PHP Recovery - ${ id }] Serving PHP error page on port ${ site.port }` ); - const url = `http://localhost:${ site.port }`; + await startErrorRecovery( details, errorMessage, readProcessManagerLogs, start ); + console.log( `[PHP Recovery - ${ id }] Serving PHP error page on port ${ details.port }` ); + const url = `http://localhost:${ details.port }`; void sendIpcEventToRenderer( 'site-event', { event: SITE_EVENTS.UPDATED, siteId: id, site: { id, - name: site.name, - path: site.path, - port: site.port, + name: details.name, + path: details.path, + port: details.port, url, - phpVersion: site.phpVersion, + phpVersion: details.phpVersion, }, running: true, } ); @@ -671,11 +674,11 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom const contexts: Record< string, Record< string, unknown > > = { server: { - running: site.running, - phpVersion: site.phpVersion, - port: site.port, - hasCustomDomain: !! site.customDomain, - httpsEnabled: !! site.enableHttps, + running: details?.running, + phpVersion: details?.phpVersion, + port: details?.port, + hasCustomDomain: !! details?.customDomain, + httpsEnabled: !! details?.enableHttps, }, }; diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts index cdd21a8095..70b4737179 100644 --- a/apps/studio/src/tests/ipc-handlers.test.ts +++ b/apps/studio/src/tests/ipc-handlers.test.ts @@ -4,7 +4,8 @@ import { IpcMainInvokeEvent, shell } from 'electron'; import { existsSync } from 'fs'; import { normalize } from 'path'; -import { readSitePath } from '@studio/common/sites/site-path'; +import { startSite } from '@studio/common/sites/lifecycle'; +import { readSite, readSitePath } from '@studio/common/sites/site-path'; import { readFile } from 'atomically'; import { vol } from 'memfs'; import { vi } from 'vitest'; @@ -14,7 +15,9 @@ import { isFullscreen, openURL, readBlueprintFile, + startServer, } from 'src/ipc-handlers'; +import { getSite } from 'src/lib/sites'; import { getMainWindow } from 'src/main-window'; import { createSiteViaCli } from 'src/modules/cli/lib/cli-site-creator'; @@ -31,6 +34,11 @@ vi.mock( '@sentry/electron/main', () => ( { setTag: vi.fn(), } ) ); vi.mock( 'src/lib/sites' ); +vi.mock( 'src/lib/php-error-recovery' ); +vi.mock( import( '@studio/common/sites/lifecycle' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + startSite: vi.fn(), +} ) ); vi.mock( 'src/modules/cli/lib/cli-site-creator' ); vi.mock( '@studio/common/sites/site-path' ); vi.mock( '@studio/common/sites/site-identity' ); @@ -101,6 +109,31 @@ describe( 'createSite', () => { } ); } ); +describe( 'startServer', () => { + beforeEach( () => { + vi.mocked( readSite ).mockResolvedValue( { id: 'site-id', name: 'Test', path: '/test' } ); + vi.mocked( getSite ).mockResolvedValue( { ...mockSiteDetails, id: 'site-id' } ); + } ); + + it( 'starts the site from its path without asking the CLI for its details', async () => { + vi.mocked( startSite ).mockResolvedValue(); + + await startServer( mockIpcMainInvokeEvent, 'site-id' ); + + expect( startSite ).toHaveBeenCalledWith( expect.any( Function ), '/test' ); + expect( getSite ).not.toHaveBeenCalled(); + } ); + + it( "reads the site's details to report a failed start", async () => { + vi.mocked( startSite ).mockRejectedValue( new Error( 'Failed to start site' ) ); + + await expect( startServer( mockIpcMainInvokeEvent, 'site-id' ) ).rejects.toThrow( + 'Failed to start site' + ); + expect( getSite ).toHaveBeenCalledWith( 'site-id' ); + } ); +} ); + describe( 'readBlueprintFile', () => { it( 'deletes the temporary deep-link file after reading it', async () => { const filePath = normalize( '/mock/path/wp-studio-blueprints/blueprint.json' ); From 02023d5e34604a4d665b20e6df6e5e9f38d0d867 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 14:52:40 -0400 Subject: [PATCH 2/3] Drop the startServer unit tests: the startup benchmark measures the change --- apps/studio/src/tests/ipc-handlers.test.ts | 35 +--------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts index 70b4737179..cdd21a8095 100644 --- a/apps/studio/src/tests/ipc-handlers.test.ts +++ b/apps/studio/src/tests/ipc-handlers.test.ts @@ -4,8 +4,7 @@ import { IpcMainInvokeEvent, shell } from 'electron'; import { existsSync } from 'fs'; import { normalize } from 'path'; -import { startSite } from '@studio/common/sites/lifecycle'; -import { readSite, readSitePath } from '@studio/common/sites/site-path'; +import { readSitePath } from '@studio/common/sites/site-path'; import { readFile } from 'atomically'; import { vol } from 'memfs'; import { vi } from 'vitest'; @@ -15,9 +14,7 @@ import { isFullscreen, openURL, readBlueprintFile, - startServer, } from 'src/ipc-handlers'; -import { getSite } from 'src/lib/sites'; import { getMainWindow } from 'src/main-window'; import { createSiteViaCli } from 'src/modules/cli/lib/cli-site-creator'; @@ -34,11 +31,6 @@ vi.mock( '@sentry/electron/main', () => ( { setTag: vi.fn(), } ) ); vi.mock( 'src/lib/sites' ); -vi.mock( 'src/lib/php-error-recovery' ); -vi.mock( import( '@studio/common/sites/lifecycle' ), async ( importOriginal ) => ( { - ...( await importOriginal() ), - startSite: vi.fn(), -} ) ); vi.mock( 'src/modules/cli/lib/cli-site-creator' ); vi.mock( '@studio/common/sites/site-path' ); vi.mock( '@studio/common/sites/site-identity' ); @@ -109,31 +101,6 @@ describe( 'createSite', () => { } ); } ); -describe( 'startServer', () => { - beforeEach( () => { - vi.mocked( readSite ).mockResolvedValue( { id: 'site-id', name: 'Test', path: '/test' } ); - vi.mocked( getSite ).mockResolvedValue( { ...mockSiteDetails, id: 'site-id' } ); - } ); - - it( 'starts the site from its path without asking the CLI for its details', async () => { - vi.mocked( startSite ).mockResolvedValue(); - - await startServer( mockIpcMainInvokeEvent, 'site-id' ); - - expect( startSite ).toHaveBeenCalledWith( expect.any( Function ), '/test' ); - expect( getSite ).not.toHaveBeenCalled(); - } ); - - it( "reads the site's details to report a failed start", async () => { - vi.mocked( startSite ).mockRejectedValue( new Error( 'Failed to start site' ) ); - - await expect( startServer( mockIpcMainInvokeEvent, 'site-id' ) ).rejects.toThrow( - 'Failed to start site' - ); - expect( getSite ).toHaveBeenCalledWith( 'site-id' ); - } ); -} ); - describe( 'readBlueprintFile', () => { it( 'deletes the temporary deep-link file after reading it', async () => { const filePath = normalize( '/mock/path/wp-studio-blueprints/blueprint.json' ); From cbe0e8b9a57ae1fecd5ab68c02df355ae017d91e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 15:01:47 -0400 Subject: [PATCH 3/3] Fail a start for a site cli.json doesn't have instead of doing nothing --- apps/studio/src/ipc-handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 7ae03eb1d9..93e65f1070 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -616,7 +616,7 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom // Starting only needs the path, which `cli.json` has; the CLI's full details wait for a failure. const site = await readSite( id ); if ( ! site ) { - return; + throw new Error( `Site not found: ${ id }` ); } // Release the port held by any active PHP-error recovery before (re)starting the real server,