diff --git a/cli/commands/site/list.ts b/cli/commands/site/list.ts index d1423c56e1..8b707c94de 100644 --- a/cli/commands/site/list.ts +++ b/cli/commands/site/list.ts @@ -1,4 +1,3 @@ -import os from 'node:os'; import { __, _n, sprintf } from '@wordpress/i18n'; import Table from 'cli-table3'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; diff --git a/cli/commands/site/stop.ts b/cli/commands/site/stop.ts new file mode 100644 index 0000000000..2c1e2059b3 --- /dev/null +++ b/cli/commands/site/stop.ts @@ -0,0 +1,56 @@ +import { __ } from '@wordpress/i18n'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; +import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( siteFolder: string ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + const site = await getSiteByFolder( siteFolder, false ); + + await connect(); + + const runningProcess = await isServerRunning( site.id ); + if ( ! runningProcess ) { + logger.reportSuccess( __( 'WordPress site is not running' ) ); + return; + } + + logger.reportStart( LoggerAction.STOP_SITE, __( 'Stopping WordPress site...' ) ); + try { + await stopWordPressServer( site.id ); + await clearSiteLatestCliPid( site.id ); + logger.reportSuccess( __( 'WordPress site stopped' ) ); + await stopProxyIfNoSitesNeedIt( site.id, logger ); + } catch ( error ) { + throw new LoggerError( __( 'Failed to stop WordPress server' ), error ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to stop site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'stop', + describe: __( 'Stop local site' ), + builder: ( yargs ) => { + return yargs; + }, + handler: async ( argv ) => { + await runCommand( argv.path ); + }, + } ); +}; diff --git a/cli/commands/site/tests/stop.test.ts b/cli/commands/site/tests/stop.test.ts new file mode 100644 index 0000000000..7b2975602e --- /dev/null +++ b/cli/commands/site/tests/stop.test.ts @@ -0,0 +1,191 @@ +import { SiteData, clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; +import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), + getSiteByFolder: jest.fn(), + clearSiteLatestCliPid: jest.fn(), + getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/site-utils' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'cli/logger' ); + +describe( 'Site Stop Command', () => { + const mockSiteFolder = '/test/site/path'; + const mockSiteData: SiteData = { + id: 'test-site-id', + name: 'Test Site', + path: mockSiteFolder, + port: 8881, + adminUsername: 'admin', + adminPassword: 'password123', + running: true, + phpVersion: '8.0', + url: `http://localhost:8881`, + }; + + const mockProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + ( connect as jest.Mock ).mockResolvedValue( undefined ); + ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); + ( clearSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); + ( stopProxyIfNoSitesNeedIt as jest.Mock ).mockResolvedValue( undefined ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + describe( 'Error Handling', () => { + it( 'should handle site not found error', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle PM2 connection failure', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle WordPress server stop failure', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'stopSite', + 'Stopping WordPress site...' + ); + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should report that site is not running if server is not running', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site is not running' ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( clearSiteLatestCliPid ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should stop a running site', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( connect ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'stopSite', + 'Stopping WordPress site...' + ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( clearSiteLatestCliPid ).toHaveBeenCalledWith( mockSiteData.id ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site stopped' ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should call stopProxyIfNoSitesNeedIt after stopping a site', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( stopProxyIfNoSitesNeedIt ).toHaveBeenCalledWith( mockSiteData.id, mockLogger ); + } ); + + it( 'should not call stopProxyIfNoSitesNeedIt if site is not running', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( stopProxyIfNoSitesNeedIt ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'Cleanup', () => { + it( 'should disconnect from PM2 even on error', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site error' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disconnect from PM2 on success', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disconnect from PM2 even if server was not running', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index 7ea9589914..4f520eb4d0 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -15,6 +15,7 @@ import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/ import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start'; import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status'; +import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop'; import { readAppdata } from 'cli/lib/appdata'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; @@ -86,6 +87,7 @@ async function main() { registerSiteCreateCommand( sitesYargs ); registerSiteListCommand( sitesYargs ); registerSiteStartCommand( sitesYargs ); + registerSiteStopCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ); } diff --git a/cli/lib/appdata.ts b/cli/lib/appdata.ts index e48caa0795..f4a31337f6 100644 --- a/cli/lib/appdata.ts +++ b/cli/lib/appdata.ts @@ -261,3 +261,20 @@ export async function updateSiteLatestCliPid( siteId: string, pid: number ): Pro await unlockAppdata(); } } + +export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { + try { + await lockAppdata(); + const userData = await readAppdata(); + const site = userData.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + delete site.latestCliPid; + await saveAppdata( userData ); + } finally { + await unlockAppdata(); + } +} diff --git a/cli/lib/pm2-manager.ts b/cli/lib/pm2-manager.ts index 1384a3f35f..7205577326 100644 --- a/cli/lib/pm2-manager.ts +++ b/cli/lib/pm2-manager.ts @@ -165,6 +165,10 @@ export async function isProxyProcessRunning(): Promise< ProcessDescription | und return isProcessRunning( PROXY_PROCESS_NAME ); } +export async function stopProxyProcess(): Promise< void > { + return stopProcess( PROXY_PROCESS_NAME ); +} + export async function isProcessRunning( processName: string ): Promise< ProcessDescription | undefined > { diff --git a/cli/lib/site-utils.ts b/cli/lib/site-utils.ts index c219d04134..3e641e5d0f 100644 --- a/cli/lib/site-utils.ts +++ b/cli/lib/site-utils.ts @@ -1,10 +1,11 @@ import { __ } from '@wordpress/i18n'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { getSiteUrl, SiteData } from 'cli/lib/appdata'; +import { getSiteUrl, readAppdata, SiteData } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; import { addDomainToHosts } from 'cli/lib/hosts-file'; -import { isProxyProcessRunning, startProxyProcess } from 'cli/lib/pm2-manager'; +import { isProxyProcessRunning, startProxyProcess, stopProxyProcess } from 'cli/lib/pm2-manager'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; /** @@ -74,3 +75,31 @@ export async function setupCustomDomain( throw new LoggerError( __( 'Failed to add domain to hosts file' ), error ); } } + +/** + * Stops the HTTP proxy server if no remaining running sites need it. + * A site needs the proxy if it has a custom domain configured. + * + * @param stoppedSiteId - The ID of the site that was just stopped (to exclude from the check) + */ +export async function stopProxyIfNoSitesNeedIt( + stoppedSiteId: string, + logger: Logger< LoggerAction > +): Promise< void > { + const proxyProcess = await isProxyProcessRunning(); + if ( ! proxyProcess ) { + return; + } + + const appdata = await readAppdata(); + + for ( const site of appdata.sites ) { + if ( site.id !== stoppedSiteId && site.customDomain && ( await isServerRunning( site.id ) ) ) { + return; + } + } + + logger.reportStart( LoggerAction.STOP_PROXY, __( 'Stopping HTTP proxy server...' ) ); + await stopProxyProcess(); + logger.reportSuccess( __( 'HTTP proxy server stopped' ) ); +} diff --git a/cli/lib/tests/site-utils.test.ts b/cli/lib/tests/site-utils.test.ts new file mode 100644 index 0000000000..aff4e13c49 --- /dev/null +++ b/cli/lib/tests/site-utils.test.ts @@ -0,0 +1,142 @@ +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { SiteData, readAppdata } from 'cli/lib/appdata'; +import { isProxyProcessRunning, stopProxyProcess } from 'cli/lib/pm2-manager'; +import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; +import { Logger } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), + readAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); + +describe( 'stopProxyIfNoSitesNeedIt', () => { + const mockProcessDescription = { + name: 'studio-proxy', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: Logger< LoggerAction >; + + const createSiteData = ( overrides: Partial< SiteData > = {} ): SiteData => ( { + id: 'site-1', + name: 'Test Site', + path: '/test/site', + port: 8881, + phpVersion: '8.0', + ...overrides, + } ); + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + } as unknown as Logger< LoggerAction >; + + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( undefined ); + ( stopProxyProcess as jest.Mock ).mockResolvedValue( undefined ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + ( readAppdata as jest.Mock ).mockResolvedValue( { sites: [], snapshots: [] } ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + it( 'should do nothing if proxy is not running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( undefined ); + + await stopProxyIfNoSitesNeedIt( 'site-1', mockLogger ); + + expect( readAppdata ).not.toHaveBeenCalled(); + expect( stopProxyProcess ).not.toHaveBeenCalled(); + } ); + + it( 'should stop proxy if no other sites exist', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ createSiteData( { id: 'stopped-site' } ) ], + snapshots: [], + } ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'stopProxy', + 'Stopping HTTP proxy server...' + ); + expect( stopProxyProcess ).toHaveBeenCalled(); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'HTTP proxy server stopped' ); + } ); + + it( 'should stop proxy if other sites exist but none have custom domains', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ + createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), + createSiteData( { id: 'other-site-1' } ), + createSiteData( { id: 'other-site-2' } ), + ], + snapshots: [], + } ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( stopProxyProcess ).toHaveBeenCalled(); + } ); + + it( 'should stop proxy if other sites have custom domains but are not running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ + createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), + createSiteData( { id: 'other-site', customDomain: 'other.local' } ), + ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( isServerRunning ).toHaveBeenCalledWith( 'other-site' ); + expect( stopProxyProcess ).toHaveBeenCalled(); + } ); + + it( 'should not stop proxy if another site with custom domain is running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ + createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), + createSiteData( { id: 'running-site', customDomain: 'running.local' } ), + ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( isServerRunning ).toHaveBeenCalledWith( 'running-site' ); + expect( stopProxyProcess ).not.toHaveBeenCalled(); + } ); + + it( 'should not check if the stopped site is running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ) ], + snapshots: [], + } ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( isServerRunning ).not.toHaveBeenCalledWith( 'stopped-site' ); + expect( stopProxyProcess ).toHaveBeenCalled(); + } ); +} ); diff --git a/cli/lib/types/wordpress-server-ipc.ts b/cli/lib/types/wordpress-server-ipc.ts index b4a674b6a8..d0e3a46b33 100644 --- a/cli/lib/types/wordpress-server-ipc.ts +++ b/cli/lib/types/wordpress-server-ipc.ts @@ -1,10 +1,6 @@ import { z } from 'zod'; // Zod schemas for validating IPC messages from wordpress-server-manager -const managerMessageBase = z.object( { - messageId: z.number(), -} ); - const serverConfig = z.object( { siteId: z.string(), sitePath: z.string(), @@ -21,23 +17,36 @@ const serverConfig = z.object( { export type ServerConfig = z.infer< typeof serverConfig >; -const managerMessageStartServer = managerMessageBase.extend( { +const managerMessageStartServer = z.object( { topic: z.literal( 'start-server' ), data: z.object( { config: serverConfig, } ), } ); -const managerMessageRunBlueprint = managerMessageBase.extend( { +const managerMessageRunBlueprint = z.object( { topic: z.literal( 'run-blueprint' ), data: z.object( { config: serverConfig, } ), } ); -export const managerMessageSchema = z.discriminatedUnion( 'topic', [ +const managerMessageStopServer = z.object( { + topic: z.literal( 'stop-server' ), +} ); + +const _managerMessagePayloadSchema = z.discriminatedUnion( 'topic', [ managerMessageStartServer, managerMessageRunBlueprint, + managerMessageStopServer, +] ); +export type ManagerMessagePayload = z.infer< typeof _managerMessagePayloadSchema >; + +const managerMessageBase = z.object( { messageId: z.number() } ); +export const managerMessageSchema = z.discriminatedUnion( 'topic', [ + managerMessageBase.merge( managerMessageStartServer ), + managerMessageBase.merge( managerMessageRunBlueprint ), + managerMessageBase.merge( managerMessageStopServer ), ] ); export type ManagerMessage = z.infer< typeof managerMessageSchema >; diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 57349e43a9..c04a1e45b9 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -22,7 +22,7 @@ import { ProcessDescription } from 'cli/lib/types/pm2'; import { ServerConfig, childMessagePm2Schema, - ManagerMessage, + ManagerMessagePayload, } from 'cli/lib/types/wordpress-server-ipc'; function getProcessName( siteId: string ): string { @@ -137,7 +137,8 @@ const messageActivityTrackers = new Map< async function sendMessage( pmId: number, - message: Omit< ManagerMessage, 'messageId' > + message: ManagerMessagePayload, + maxTotalElapsedTime = PLAYGROUND_CLI_MAX_TIMEOUT ): Promise< unknown > { const bus = await getPm2Bus(); @@ -162,12 +163,12 @@ async function sendMessage( if ( timeSinceLastActivity > PLAYGROUND_CLI_INACTIVITY_TIMEOUT || - totalElapsedTime > PLAYGROUND_CLI_MAX_TIMEOUT + totalElapsedTime > maxTotalElapsedTime ) { cleanup(); const timeoutReason = - totalElapsedTime > PLAYGROUND_CLI_MAX_TIMEOUT - ? `Maximum timeout of ${ PLAYGROUND_CLI_MAX_TIMEOUT / 1000 }s exceeded` + totalElapsedTime > maxTotalElapsedTime + ? `Maximum timeout of ${ maxTotalElapsedTime / 1000 }s exceeded` : `No activity for ${ PLAYGROUND_CLI_INACTIVITY_TIMEOUT / 1000 }s`; reject( new Error( `Timeout waiting for response to message ${ messageId }: ${ timeoutReason }` ) @@ -213,12 +214,24 @@ async function sendMessage( bus.on( 'process:msg', responseHandler ); - sendMessageToProcess( pmId, { ...message, messageId: messageId } ).catch( reject ); + sendMessageToProcess( pmId, { ...message, messageId } ).catch( reject ); } ); } +const GRACEFUL_STOP_TIMEOUT = 5000; + export async function stopWordPressServer( siteId: string ): Promise< void > { const processName = getProcessName( siteId ); + const runningProcess = await isProcessRunning( processName ); + + if ( runningProcess ) { + try { + await sendMessage( runningProcess.pmId, { topic: 'stop-server' }, GRACEFUL_STOP_TIMEOUT ); + } catch { + // Graceful shutdown failed, PM2 delete will handle it + } + } + return stopProcess( processName ); } diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index 93352d6e96..91c64b94f0 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -139,7 +139,7 @@ async function startServer( config: ServerConfig ): Promise< void > { try { const args = await getBaseRunCLIArgs( 'server', config ); - const server = await runCLI( args ); + server = await runCLI( args ); if ( config.adminPassword ) { await setAdminPassword( server, config.adminPassword ); @@ -151,6 +151,29 @@ async function startServer( config: ServerConfig ): Promise< void > { } } +const STOP_SERVER_TIMEOUT = 5000; + +async function stopServer(): Promise< void > { + if ( ! server ) { + logToConsole( 'No server running, nothing to stop' ); + return; + } + + const serverToDispose = server; + server = null; + + try { + const disposalTimeout = new Promise< void >( ( _, reject ) => + setTimeout( () => reject( new Error( 'Server disposal timeout' ) ), STOP_SERVER_TIMEOUT ) + ); + + await Promise.race( [ serverToDispose[ Symbol.asyncDispose ](), disposalTimeout ] ); + logToConsole( 'Server stopped gracefully' ); + } catch ( error ) { + errorToConsole( 'Error during server disposal:', error ); + } +} + async function runBlueprint( config: ServerConfig ): Promise< void > { try { const args = await getBaseRunCLIArgs( 'run-blueprint', config ); @@ -197,6 +220,9 @@ async function ipcMessageHandler( packet: unknown ) { case 'run-blueprint': result = await runBlueprint( validMessage.data.config ); break; + case 'stop-server': + result = await stopServer(); + break; default: throw new Error( `Unknown message.` ); } diff --git a/common/lib/cache-function-ttl.ts b/common/lib/cache-function-ttl.ts index 5d4d191b2e..a227345d0f 100644 --- a/common/lib/cache-function-ttl.ts +++ b/common/lib/cache-function-ttl.ts @@ -31,7 +31,7 @@ export function cacheFunctionTTL< Args extends unknown[], Return >( } function pruneCache(): void { - for ( const [ fn, cachedResults ] of cache ) { + for ( const [ _, cachedResults ] of cache ) { for ( const cachedResult of cachedResults ) { if ( Date.now() - cachedResult.timestamp >= cachedResult.ttl ) { cachedResults.delete( cachedResult ); diff --git a/common/logger-actions.ts b/common/logger-actions.ts index 45c9a47870..9dcc3ff413 100644 --- a/common/logger-actions.ts +++ b/common/logger-actions.ts @@ -21,9 +21,11 @@ export enum SiteCommandLoggerAction { START_DAEMON = 'startDaemon', LOAD_SITES = 'loadSites', START_PROXY = 'startProxy', + STOP_PROXY = 'stopProxy', GENERATE_CERT = 'generateCert', ADD_DOMAIN_TO_HOSTS = 'addDomainToHosts', START_SITE = 'startSite', + STOP_SITE = 'stopSite', VALIDATE = 'validate', CREATE_DIRECTORY = 'createDirectory', INSTALL_SQLITE = 'installSqlite',