Skip to content

Commit ff00d9c

Browse files
committed
Implement studio site stop CLI command with graceful shutdown
1 parent 47825b3 commit ff00d9c

File tree

8 files changed

+376
-2
lines changed

8 files changed

+376
-2
lines changed

cli/commands/site/stop.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { __ } from '@wordpress/i18n';
2+
import { arePathsEqual } from 'common/lib/fs-utils';
3+
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
4+
import { clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata';
5+
import { connect, disconnect } from 'cli/lib/pm2-manager';
6+
import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager';
7+
import { Logger, LoggerError } from 'cli/logger';
8+
import { StudioArgv } from 'cli/types';
9+
10+
export async function runCommand( siteFolder: string ): Promise< void > {
11+
const logger = new Logger< LoggerAction >();
12+
13+
try {
14+
const appdata = await readAppdata();
15+
const site = appdata.sites.find( ( s ) => arePathsEqual( s.path, siteFolder ) );
16+
17+
if ( ! site ) {
18+
throw new LoggerError( __( 'Could not find Studio site.' ) );
19+
}
20+
21+
await connect();
22+
23+
const runningProcess = await isServerRunning( site.id );
24+
if ( ! runningProcess ) {
25+
logger.reportSuccess( __( 'WordPress site is not running' ) );
26+
return;
27+
}
28+
29+
logger.reportStart( LoggerAction.STOP_SITE, __( 'Stopping WordPress site...' ) );
30+
try {
31+
await stopWordPressServer( site.id );
32+
await clearSiteLatestCliPid( site.id );
33+
logger.reportSuccess( __( 'WordPress site stopped' ) );
34+
} catch ( error ) {
35+
throw new LoggerError( __( 'Failed to stop WordPress server' ), error );
36+
}
37+
} catch ( error ) {
38+
if ( error instanceof LoggerError ) {
39+
logger.reportError( error );
40+
} else {
41+
const loggerError = new LoggerError( __( 'Failed to stop site infrastructure' ), error );
42+
logger.reportError( loggerError );
43+
}
44+
} finally {
45+
disconnect();
46+
}
47+
}
48+
49+
export const registerCommand = ( yargs: StudioArgv ) => {
50+
return yargs.command( {
51+
command: 'stop',
52+
describe: __( 'Stop local site' ),
53+
builder: ( yargs ) => {
54+
return yargs;
55+
},
56+
handler: async ( argv ) => {
57+
await runCommand( argv.path );
58+
},
59+
} );
60+
};
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { SiteData, clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata';
2+
import { connect, disconnect } from 'cli/lib/pm2-manager';
3+
import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager';
4+
import { Logger, LoggerError } from 'cli/logger';
5+
6+
jest.mock( 'cli/lib/appdata', () => ( {
7+
...jest.requireActual( 'cli/lib/appdata' ),
8+
getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ),
9+
readAppdata: jest.fn(),
10+
clearSiteLatestCliPid: jest.fn(),
11+
getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ),
12+
} ) );
13+
jest.mock( 'common/lib/fs-utils', () => ( {
14+
...jest.requireActual( 'common/lib/fs-utils' ),
15+
arePathsEqual: jest.fn( ( a: string, b: string ) => a === b ),
16+
} ) );
17+
jest.mock( 'cli/lib/pm2-manager' );
18+
jest.mock( 'cli/lib/wordpress-server-manager' );
19+
jest.mock( 'cli/logger' );
20+
21+
describe( 'Site Stop Command', () => {
22+
const mockSiteFolder = '/test/site/path';
23+
const mockSiteData: SiteData = {
24+
id: 'test-site-id',
25+
name: 'Test Site',
26+
path: mockSiteFolder,
27+
port: 8881,
28+
adminUsername: 'admin',
29+
adminPassword: 'password123',
30+
running: true,
31+
phpVersion: '8.0',
32+
url: `http://localhost:8881`,
33+
};
34+
35+
const mockProcessDescription = {
36+
name: 'test-site-id',
37+
pmId: 0,
38+
status: 'online',
39+
pid: 12345,
40+
};
41+
42+
let mockLogger: {
43+
reportStart: jest.Mock;
44+
reportSuccess: jest.Mock;
45+
reportError: jest.Mock;
46+
};
47+
48+
beforeEach( () => {
49+
jest.clearAllMocks();
50+
51+
mockLogger = {
52+
reportStart: jest.fn(),
53+
reportSuccess: jest.fn(),
54+
reportError: jest.fn(),
55+
};
56+
57+
( Logger as jest.Mock ).mockReturnValue( mockLogger );
58+
( connect as jest.Mock ).mockResolvedValue( undefined );
59+
( disconnect as jest.Mock ).mockReturnValue( undefined );
60+
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );
61+
( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined );
62+
( clearSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined );
63+
} );
64+
65+
afterEach( () => {
66+
jest.restoreAllMocks();
67+
} );
68+
69+
describe( 'Error Handling', () => {
70+
it( 'should handle site not found error', async () => {
71+
( readAppdata as jest.Mock ).mockResolvedValue( {
72+
sites: [],
73+
snapshots: [],
74+
} );
75+
76+
const { runCommand } = await import( '../stop' );
77+
await runCommand( mockSiteFolder );
78+
79+
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
80+
expect( disconnect ).toHaveBeenCalled();
81+
} );
82+
83+
it( 'should handle PM2 connection failure', async () => {
84+
( readAppdata as jest.Mock ).mockResolvedValue( {
85+
sites: [ mockSiteData ],
86+
snapshots: [],
87+
} );
88+
( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) );
89+
90+
const { runCommand } = await import( '../stop' );
91+
await runCommand( mockSiteFolder );
92+
93+
expect( mockLogger.reportError ).toHaveBeenCalled();
94+
expect( disconnect ).toHaveBeenCalled();
95+
} );
96+
97+
it( 'should handle WordPress server stop failure', async () => {
98+
( readAppdata as jest.Mock ).mockResolvedValue( {
99+
sites: [ mockSiteData ],
100+
snapshots: [],
101+
} );
102+
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );
103+
( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) );
104+
105+
const { runCommand } = await import( '../stop' );
106+
await runCommand( mockSiteFolder );
107+
108+
expect( mockLogger.reportStart ).toHaveBeenCalledWith(
109+
'stopSite',
110+
'Stopping WordPress site...'
111+
);
112+
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
113+
expect( disconnect ).toHaveBeenCalled();
114+
} );
115+
} );
116+
117+
describe( 'Success Cases', () => {
118+
it( 'should report that site is not running if server is not running', async () => {
119+
( readAppdata as jest.Mock ).mockResolvedValue( {
120+
sites: [ mockSiteData ],
121+
snapshots: [],
122+
} );
123+
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );
124+
125+
const { runCommand } = await import( '../stop' );
126+
await runCommand( mockSiteFolder );
127+
128+
expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site is not running' );
129+
expect( stopWordPressServer ).not.toHaveBeenCalled();
130+
expect( clearSiteLatestCliPid ).not.toHaveBeenCalled();
131+
expect( disconnect ).toHaveBeenCalled();
132+
} );
133+
134+
it( 'should stop a running site', async () => {
135+
( readAppdata as jest.Mock ).mockResolvedValue( {
136+
sites: [ mockSiteData ],
137+
snapshots: [],
138+
} );
139+
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );
140+
141+
const { runCommand } = await import( '../stop' );
142+
await runCommand( mockSiteFolder );
143+
144+
expect( connect ).toHaveBeenCalled();
145+
expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id );
146+
expect( mockLogger.reportStart ).toHaveBeenCalledWith(
147+
'stopSite',
148+
'Stopping WordPress site...'
149+
);
150+
expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id );
151+
expect( clearSiteLatestCliPid ).toHaveBeenCalledWith( mockSiteData.id );
152+
expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site stopped' );
153+
expect( disconnect ).toHaveBeenCalled();
154+
} );
155+
} );
156+
157+
describe( 'Cleanup', () => {
158+
it( 'should disconnect from PM2 even on error', async () => {
159+
( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Appdata error' ) );
160+
161+
const { runCommand } = await import( '../stop' );
162+
await runCommand( mockSiteFolder );
163+
164+
expect( disconnect ).toHaveBeenCalled();
165+
} );
166+
167+
it( 'should disconnect from PM2 on success', async () => {
168+
( readAppdata as jest.Mock ).mockResolvedValue( {
169+
sites: [ mockSiteData ],
170+
snapshots: [],
171+
} );
172+
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );
173+
174+
const { runCommand } = await import( '../stop' );
175+
await runCommand( mockSiteFolder );
176+
177+
expect( disconnect ).toHaveBeenCalled();
178+
} );
179+
180+
it( 'should disconnect from PM2 even if server was not running', async () => {
181+
( readAppdata as jest.Mock ).mockResolvedValue( {
182+
sites: [ mockSiteData ],
183+
snapshots: [],
184+
} );
185+
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );
186+
187+
const { runCommand } = await import( '../stop' );
188+
await runCommand( mockSiteFolder );
189+
190+
expect( disconnect ).toHaveBeenCalled();
191+
} );
192+
} );
193+
} );

cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/u
1414
import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create';
1515
import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list';
1616
import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start';
17+
import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop';
1718
import { readAppdata } from 'cli/lib/appdata';
1819
import { loadTranslations } from 'cli/lib/i18n';
1920
import { bumpAggregatedUniqueStat } from 'cli/lib/stats';
@@ -84,6 +85,7 @@ async function main() {
8485
registerSiteCreateCommand( sitesYargs );
8586
registerSiteListCommand( sitesYargs );
8687
registerSiteStartCommand( sitesYargs );
88+
registerSiteStopCommand( sitesYargs );
8789
sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) );
8890
} );
8991
}

cli/lib/appdata.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,20 @@ export async function updateSiteLatestCliPid( siteId: string, pid: number ): Pro
254254
await unlockAppdata();
255255
}
256256
}
257+
258+
export async function clearSiteLatestCliPid( siteId: string ): Promise< void > {
259+
try {
260+
await lockAppdata();
261+
const userData = await readAppdata();
262+
const site = userData.sites.find( ( s ) => s.id === siteId );
263+
264+
if ( ! site ) {
265+
throw new LoggerError( __( 'Site not found' ) );
266+
}
267+
268+
delete site.latestCliPid;
269+
await saveAppdata( userData );
270+
} finally {
271+
await unlockAppdata();
272+
}
273+
}

cli/lib/types/wordpress-server-ipc.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,15 @@ const managerMessageRunBlueprint = managerMessageBase.extend( {
3535
} ),
3636
} );
3737

38+
const managerMessageStopServer = managerMessageBase.extend( {
39+
topic: z.literal( 'stop-server' ),
40+
data: z.object( {} ),
41+
} );
42+
3843
export const managerMessageSchema = z.discriminatedUnion( 'topic', [
3944
managerMessageStartServer,
4045
managerMessageRunBlueprint,
46+
managerMessageStopServer,
4147
] );
4248
export type ManagerMessage = z.infer< typeof managerMessageSchema >;
4349

cli/lib/wordpress-server-manager.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,84 @@ async function sendMessage(
216216

217217
bus.on( 'process:msg', responseHandler );
218218

219-
sendMessageToProcess( pmId, { ...message, messageId: messageId } ).catch( reject );
219+
sendMessageToProcess( pmId, { ...message, messageId } as ManagerMessage ).catch( reject );
220220
} );
221221
}
222222

223+
const GRACEFUL_STOP_TIMEOUT = 5000;
224+
223225
export async function stopWordPressServer( siteId: string ): Promise< void > {
224226
const processName = getProcessName( siteId );
227+
const runningProcess = await isProcessRunning( processName );
228+
229+
if ( runningProcess ) {
230+
try {
231+
await sendStopMessage( runningProcess.pmId );
232+
} catch {
233+
// Graceful shutdown failed, PM2 delete will handle it
234+
}
235+
}
236+
225237
return stopProcess( processName );
226238
}
227239

240+
/**
241+
* Send stop message to the child process with a timeout
242+
*/
243+
async function sendStopMessage( pmId: number ): Promise< void > {
244+
const bus = await getPm2Bus();
245+
246+
return new Promise( ( resolve, reject ) => {
247+
const messageId = nextMessageId++;
248+
249+
const timeout = setTimeout( () => {
250+
cleanup();
251+
reject( new Error( 'Graceful stop timeout' ) );
252+
}, GRACEFUL_STOP_TIMEOUT );
253+
254+
const cleanup = () => {
255+
clearTimeout( timeout );
256+
bus.off( 'process:msg', responseHandler );
257+
};
258+
259+
const responseHandler = ( packet: unknown ) => {
260+
const validationResult = childMessagePm2Schema.safeParse( packet );
261+
if ( ! validationResult.success ) {
262+
return;
263+
}
264+
265+
const validPacket = validationResult.data;
266+
267+
if ( validPacket.process.pm_id !== pmId ) {
268+
return;
269+
}
270+
271+
if ( validPacket.raw.topic === 'error' && validPacket.raw.originalMessageId === messageId ) {
272+
cleanup();
273+
reject( new Error( validPacket.raw.errorMessage ) );
274+
} else if (
275+
validPacket.raw.topic === 'result' &&
276+
validPacket.raw.originalMessageId === messageId
277+
) {
278+
cleanup();
279+
resolve();
280+
}
281+
};
282+
283+
bus.on( 'process:msg', responseHandler );
284+
285+
const stopMessage = {
286+
topic: 'stop-server' as const,
287+
data: {},
288+
messageId,
289+
};
290+
sendMessageToProcess( pmId, stopMessage ).catch( ( error ) => {
291+
cleanup();
292+
reject( error );
293+
} );
294+
} );
295+
}
296+
228297
/**
229298
* Run a blueprint on a site without starting a server
230299
* 1. Start the PM2 process

0 commit comments

Comments
 (0)