From aee9d82d1deecb8c9252e7c447e06aedbb95450d Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 18 Mar 2026 09:10:21 +0100 Subject: [PATCH 01/18] Message topic instead of ID in error message --- apps/cli/lib/wordpress-server-manager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index e74249d887..c11aab8f1f 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -209,7 +209,9 @@ export async function sendMessage( ? `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 }` ) + new Error( + `Timeout waiting for response to message ${ message.topic }: ${ timeoutReason }` + ) ); } }, PLAYGROUND_CLI_ACTIVITY_CHECK_INTERVAL ); From 4556244bc07e21630651a8f219af7099d794f726 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 18 Mar 2026 15:42:36 +0100 Subject: [PATCH 02/18] Unset `stack` prop on `CliCommandError` --- apps/studio/src/modules/cli/lib/execute-command.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/studio/src/modules/cli/lib/execute-command.ts b/apps/studio/src/modules/cli/lib/execute-command.ts index 9e4ad4c68a..950aba44ff 100644 --- a/apps/studio/src/modules/cli/lib/execute-command.ts +++ b/apps/studio/src/modules/cli/lib/execute-command.ts @@ -29,6 +29,9 @@ class CliCommandError extends Error { this.exitCode = options.exitCode; this.signal = options.signal; this.name = 'CliCommandError'; + // The stack trace for this error is misleading, because it's not actually thrown where the error + // happened - it's just a representation of an error that happened in a different process. + this.stack = undefined; } get message(): string { From 4201c6259e0c1baaed7af3ffc2d716a50da5dc44 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 18 Mar 2026 15:43:02 +0100 Subject: [PATCH 03/18] Timestamp Playground CLI process log lines --- apps/cli/process-manager-daemon.ts | 38 +++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/cli/process-manager-daemon.ts b/apps/cli/process-manager-daemon.ts index 95290a7e44..e54e248958 100644 --- a/apps/cli/process-manager-daemon.ts +++ b/apps/cli/process-manager-daemon.ts @@ -2,6 +2,7 @@ import { ChildProcess, spawn } from 'child_process'; import fs, { createWriteStream, WriteStream } from 'fs'; import net from 'net'; import path from 'path'; +import readline from 'readline'; import semver from 'semver'; import { PROCESS_MANAGER_LOGS_DIR, @@ -51,6 +52,19 @@ function getProcessLogPaths( processName: string ) { }; } +function timestampLogLine( line: string ): string { + return `${ new Date().toISOString() } ${ line }\n`; +} + +function writeTimestampedLines( target: WriteStream, content: string ) { + const normalizedContent = content.split( '\r\n' ).join( '\n' ); + const lines = normalizedContent.trimEnd().split( '\n' ); + + lines.forEach( ( line ) => { + target.write( timestampLogLine( line ) ); + } ); +} + export class ProcessManagerDaemon { private readonly controlServer = new SocketServer( PROCESS_MANAGER_CONTROL_SOCKET_PATH, @@ -210,8 +224,8 @@ export class ProcessManagerDaemon { this.managedProcesses.set( pmId, managedProcess ); - child.stdout?.pipe( stdoutStream ); - child.stderr?.pipe( stderrStream ); + this.pipeOutputWithTimestamp( child.stdout, stdoutStream ); + this.pipeOutputWithTimestamp( child.stderr, stderrStream ); child.on( 'message', ( raw ) => { const event = daemonEventSchema.safeParse( { @@ -228,7 +242,7 @@ export class ProcessManagerDaemon { } ); child.on( 'error', ( error ) => { - void stderrStream.write( `${ error.stack ?? error.message }\n` ); + writeTimestampedLines( stderrStream, error.stack ?? error.message ); void this.handleProcessExit( managedProcess ); } ); @@ -320,6 +334,24 @@ export class ProcessManagerDaemon { this.eventsServer.broadcast( event ); } + private pipeOutputWithTimestamp( + input: NodeJS.ReadableStream | null, + target: WriteStream + ): void { + if ( ! input ) { + return; + } + + const lineReader = readline.createInterface( { + input, + crlfDelay: Infinity, + } ); + + lineReader.on( 'line', ( line ) => { + void target.write( timestampLogLine( line ) ); + } ); + } + private toProcessDescription( managedProcess: ManagedProcess ): ProcessDescription { if ( managedProcess.status === 'stopped' ) { return { From dd5f31b44cf302500658dc58400ff441e5190774 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 18 Mar 2026 15:43:35 +0100 Subject: [PATCH 04/18] Log received messages in wordpress-server-child --- apps/cli/wordpress-server-child.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index fda5a5821c..750ef82cee 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -457,13 +457,14 @@ async function ipcMessageHandler( packet: unknown ) { } const abortController = abortControllers[ validMessage.messageId ]; + logToConsole( `Received ${ validMessage.topic } message` ); + try { let result: unknown; switch ( validMessage.topic ) { case 'abort': abortController?.abort(); - delete abortControllers[ validMessage.messageId ]; return; case 'start-server': result = await startServer( validMessage.data.config, abortController.signal ); @@ -497,6 +498,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 b7f1d42722c80bea2a060ae733e7fa171ffc4f24 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 18 Mar 2026 15:52:29 +0100 Subject: [PATCH 05/18] Wait for `startingPromise` to resolve in `stopServer` --- apps/cli/wordpress-server-child.ts | 97 ++++++++++++++++-------------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 750ef82cee..67ed163a9e 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -260,74 +260,84 @@ async function getBaseRunCLIArgs( args.xdebug = true; } + lastCliArgs = sanitizeRunCLIArgs( args ); return args; } -function wrapWithStartingPromise< Args extends unknown[], Return extends void >( - callback: ( ...args: Args ) => Promise< Return > -) { - return async ( ...args: Args ) => { - startingPromise = callback( ...args ); - return startingPromise; - }; -} +async function startServer( config: ServerConfig, signal: AbortSignal ): Promise< void > { + if ( server ) { + logToConsole( `Server already running for site ${ config.siteId }` ); + return; + } -const startServer = wrapWithStartingPromise( - async ( config: ServerConfig, signal: AbortSignal ): Promise< void > => { - if ( server ) { - logToConsole( `Server already running for site ${ config.siteId }` ); - return; - } + if ( startingPromise ) { + logToConsole( `Server startup already in progress for site ${ config.siteId }, waiting…` ); + await startingPromise; + return; + } - try { - signal.addEventListener( - 'abort', - () => { - throw new Error( 'Operation aborted' ); - }, - { once: true } + signal.addEventListener( + 'abort', + () => { + errorToConsole( `Failed to start server: Operation aborted` ); + throw new Error( 'Operation aborted' ); + }, + { once: true } + ); + + startingPromise = ( async () => { + const args = await getBaseRunCLIArgs( 'server', config ); + server = await runCLI( args ); + + if ( config.adminPassword || config.adminUsername || config.adminEmail ) { + await setAdminCredentials( + server, + config.adminPassword, + config.adminUsername, + config.adminEmail ); - - 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 - ); - } - } catch ( error ) { - server = null; - errorToConsole( `Failed to start server:`, error ); - throw error; } + } )(); + + try { + await startingPromise; + } catch ( error ) { + server = null; + errorToConsole( `Failed to start server:`, error ); + throw error; + } finally { + startingPromise = null; } -); +} const STOP_SERVER_TIMEOUT = 5000; async function stopServer(): Promise< void > { + if ( startingPromise ) { + logToConsole( 'Server startup in progress, waiting before stop' ); + try { + await startingPromise; + } catch ( error ) { + errorToConsole( 'Startup failed while waiting to stop server:', error ); + } + } + 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 ] ); + await Promise.race( [ server[ Symbol.asyncDispose ](), disposalTimeout ] ); logToConsole( 'Server stopped gracefully' ); } catch ( error ) { errorToConsole( 'Error during server disposal:', error ); + } finally { + server = null; } } @@ -342,7 +352,6 @@ async function runBlueprint( config: ServerConfig, signal: AbortSignal ): Promis ); const args = await getBaseRunCLIArgs( 'run-blueprint', config ); - lastCliArgs = sanitizeRunCLIArgs( args ); await runCLI( args ); logToConsole( `Blueprint applied successfully for site ${ config.siteId }` ); From 9104bcd1d39e2ed6c60c5ae5114c80d9955b5777 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 08:26:39 +0100 Subject: [PATCH 06/18] Improve abort handling around startup --- apps/cli/wordpress-server-child.ts | 135 ++++++++++++++++------------- 1 file changed, 77 insertions(+), 58 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 67ed163a9e..a124804a5c 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -38,7 +38,8 @@ import { } from 'cli/lib/types/wordpress-server-ipc'; let server: RunCLIServer | null = null; -let startingPromise: Promise< void > | null = null; +let startingPromise: Promise< void > = Promise.resolve(); +let startupAbortController: AbortController | null = null; let lastCliArgs: Record< string, unknown > | null = null; // Intercept and prefix all console output from playground-cli @@ -264,67 +265,83 @@ async function getBaseRunCLIArgs( return args; } -async function startServer( config: ServerConfig, signal: AbortSignal ): Promise< void > { - if ( server ) { - logToConsole( `Server already running for site ${ config.siteId }` ); - return; - } - - if ( startingPromise ) { - logToConsole( `Server startup already in progress for site ${ config.siteId }, waiting…` ); - await startingPromise; - return; - } +function wrapWithStartingPromise< Args extends unknown[], Return extends void >( + callback: ( ...args: Args ) => Promise< Return > +) { + return async ( ...args: Args ) => { + // Intentionally don't recover from errors, because we want the process to die if `startServer` fails + const promise = startingPromise.then( () => callback( ...args ) ); + startingPromise = promise; + return await promise; + }; +} - signal.addEventListener( - 'abort', - () => { - errorToConsole( `Failed to start server: Operation aborted` ); - throw new Error( 'Operation aborted' ); - }, - { once: true } - ); - - startingPromise = ( async () => { - const args = await getBaseRunCLIArgs( 'server', config ); - server = await runCLI( args ); - - if ( config.adminPassword || config.adminUsername || config.adminEmail ) { - await setAdminCredentials( - server, - config.adminPassword, - config.adminUsername, - config.adminEmail - ); +const startServer = wrapWithStartingPromise( + async ( config: ServerConfig, signal: AbortSignal ): Promise< void > => { + if ( server ) { + logToConsole( `Server already running for site ${ config.siteId }` ); + return; } - } )(); - - try { - await startingPromise; - } catch ( error ) { - server = null; - errorToConsole( `Failed to start server:`, error ); - throw error; - } finally { - startingPromise = null; - } -} -const STOP_SERVER_TIMEOUT = 5000; + startupAbortController = new AbortController(); + const stopSignal = AbortSignal.any( [ signal, startupAbortController.signal ] ); -async function stopServer(): Promise< void > { - if ( startingPromise ) { - logToConsole( 'Server startup in progress, waiting before stop' ); try { - await startingPromise; + stopSignal.throwIfAborted(); + + const args = await getBaseRunCLIArgs( 'server', config ); + server = await runCLI( args ); + + stopSignal.throwIfAborted(); + + if ( config.adminPassword || config.adminUsername || config.adminEmail ) { + await setAdminCredentials( + server, + config.adminPassword, + config.adminUsername, + config.adminEmail + ); + } + + stopSignal.throwIfAborted(); } catch ( error ) { - errorToConsole( 'Startup failed while waiting to stop server:', error ); + if ( server ) { + await server[ Symbol.asyncDispose ](); + server = null; + } + + if ( stopSignal.aborted ) { + logToConsole( `Aborted start server operation:`, error ); + } else { + errorToConsole( `Failed to start server:`, error ); + throw error; + } + } finally { + startupAbortController = null; } } +); + +const STOP_SERVER_TIMEOUT = 5000; + +enum StopServerResult { + ABORTED_STARTUP, + ALREADY_STOPPED, + STOPPED, + ERROR, +} + +async function stopServer(): Promise< StopServerResult > { + // If there's a startup in progress, abort and return gracefully + if ( startupAbortController ) { + logToConsole( 'Startup operation in progress. Aborting it to stop the server…' ); + startupAbortController.abort(); + return StopServerResult.ABORTED_STARTUP; + } if ( ! server ) { logToConsole( 'No server running, nothing to stop' ); - return; + return StopServerResult.ALREADY_STOPPED; } try { @@ -334,8 +351,10 @@ async function stopServer(): Promise< void > { await Promise.race( [ server[ Symbol.asyncDispose ](), disposalTimeout ] ); logToConsole( 'Server stopped gracefully' ); + return StopServerResult.STOPPED; } catch ( error ) { errorToConsole( 'Error during server disposal:', error ); + return StopServerResult.ERROR; } finally { server = null; } @@ -343,17 +362,13 @@ async function stopServer(): Promise< void > { async function runBlueprint( config: ServerConfig, signal: AbortSignal ): Promise< void > { try { - signal.addEventListener( - 'abort', - () => { - throw new Error( 'Operation aborted' ); - }, - { once: true } - ); + signal.throwIfAborted(); const args = await getBaseRunCLIArgs( 'run-blueprint', config ); await runCLI( args ); + signal.throwIfAborted(); + logToConsole( `Blueprint applied successfully for site ${ config.siteId }` ); } catch ( error ) { errorToConsole( `Failed to run Blueprint:`, error ); @@ -503,6 +518,10 @@ async function ipcMessageHandler( packet: unknown ) { result, }; process.send!( response ); + + if ( validMessage.topic === 'stop-server' && result === StopServerResult.ERROR ) { + process.exit( 1 ); + } } catch ( error ) { errorToConsole( `Error handling message ${ validMessage.topic }:`, error ); sendErrorMessage( validMessage.messageId, error ); From c63f54f629517c65972ec12f8407b22fb0d9c2ab Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 08:40:33 +0100 Subject: [PATCH 07/18] Clarify wrapWithStartingPromise --- apps/cli/wordpress-server-child.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index a124804a5c..a9b952f05c 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -38,8 +38,6 @@ import { } from 'cli/lib/types/wordpress-server-ipc'; let server: RunCLIServer | null = null; -let startingPromise: Promise< void > = Promise.resolve(); -let startupAbortController: AbortController | null = null; let lastCliArgs: Record< string, unknown > | null = null; // Intercept and prefix all console output from playground-cli @@ -265,14 +263,21 @@ async function getBaseRunCLIArgs( return args; } +let startupAbortController: AbortController | null = null; +let startingPromise: Promise< void > | null = null; + +// We allow a single `startServer` call per process. If that call throws, we expect +// `ipcMessageHandler` to kill the process. function wrapWithStartingPromise< Args extends unknown[], Return extends void >( callback: ( ...args: Args ) => Promise< Return > ) { return async ( ...args: Args ) => { - // Intentionally don't recover from errors, because we want the process to die if `startServer` fails - const promise = startingPromise.then( () => callback( ...args ) ); - startingPromise = promise; - return await promise; + if ( startingPromise ) { + return startingPromise; + } + + startingPromise = callback( ...args ); + return startingPromise; }; } From f35cd8495096efd1125de8fd435662f062e16203 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 08:53:55 +0100 Subject: [PATCH 08/18] Return enum from startServer --- apps/cli/wordpress-server-child.ts | 34 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index a9b952f05c..65593e5a5f 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -263,12 +263,18 @@ async function getBaseRunCLIArgs( return args; } +enum StartServerResult { + ALREADY_STARTED = 'ALREADY_STARTED', + ABORTED = 'ABORTED', + OK = 'OK', +} + let startupAbortController: AbortController | null = null; -let startingPromise: Promise< void > | null = null; +let startingPromise: Promise< StartServerResult > | null = null; // We allow a single `startServer` call per process. If that call throws, we expect // `ipcMessageHandler` to kill the process. -function wrapWithStartingPromise< Args extends unknown[], Return extends void >( +function wrapWithStartingPromise< Args extends unknown[], Return extends StartServerResult >( callback: ( ...args: Args ) => Promise< Return > ) { return async ( ...args: Args ) => { @@ -282,10 +288,10 @@ function wrapWithStartingPromise< Args extends unknown[], Return extends void >( } const startServer = wrapWithStartingPromise( - async ( config: ServerConfig, signal: AbortSignal ): Promise< void > => { + async ( config: ServerConfig, signal: AbortSignal ): Promise< StartServerResult > => { if ( server ) { logToConsole( `Server already running for site ${ config.siteId }` ); - return; + return StartServerResult.ALREADY_STARTED; } startupAbortController = new AbortController(); @@ -309,14 +315,17 @@ const startServer = wrapWithStartingPromise( } stopSignal.throwIfAborted(); + + return StartServerResult.OK; } catch ( error ) { if ( server ) { await server[ Symbol.asyncDispose ](); server = null; } - if ( stopSignal.aborted ) { + if ( error instanceof Error && error.name === 'AbortError' ) { logToConsole( `Aborted start server operation:`, error ); + return StartServerResult.ABORTED; } else { errorToConsole( `Failed to start server:`, error ); throw error; @@ -330,10 +339,9 @@ const startServer = wrapWithStartingPromise( const STOP_SERVER_TIMEOUT = 5000; enum StopServerResult { - ABORTED_STARTUP, - ALREADY_STOPPED, - STOPPED, - ERROR, + ABORTED_STARTUP = 'ABORTED_STARTUP', + ALREADY_STOPPED = 'ALREADY_STOPPED', + OK = 'OK', } async function stopServer(): Promise< StopServerResult > { @@ -356,10 +364,10 @@ async function stopServer(): Promise< StopServerResult > { await Promise.race( [ server[ Symbol.asyncDispose ](), disposalTimeout ] ); logToConsole( 'Server stopped gracefully' ); - return StopServerResult.STOPPED; + return StopServerResult.OK; } catch ( error ) { errorToConsole( 'Error during server disposal:', error ); - return StopServerResult.ERROR; + throw error; } finally { server = null; } @@ -523,10 +531,6 @@ async function ipcMessageHandler( packet: unknown ) { result, }; process.send!( response ); - - if ( validMessage.topic === 'stop-server' && result === StopServerResult.ERROR ) { - process.exit( 1 ); - } } catch ( error ) { errorToConsole( `Error handling message ${ validMessage.topic }:`, error ); sendErrorMessage( validMessage.messageId, error ); From 8b30b005dfe1a13a7cfe39293a4ae2bb0b489282 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 08:54:45 +0100 Subject: [PATCH 09/18] Remove enums --- apps/cli/wordpress-server-child.ts | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 65593e5a5f..3168ff0a4f 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -263,18 +263,12 @@ async function getBaseRunCLIArgs( return args; } -enum StartServerResult { - ALREADY_STARTED = 'ALREADY_STARTED', - ABORTED = 'ABORTED', - OK = 'OK', -} - let startupAbortController: AbortController | null = null; -let startingPromise: Promise< StartServerResult > | null = null; +let startingPromise: Promise< void > | null = null; // We allow a single `startServer` call per process. If that call throws, we expect // `ipcMessageHandler` to kill the process. -function wrapWithStartingPromise< Args extends unknown[], Return extends StartServerResult >( +function wrapWithStartingPromise< Args extends unknown[], Return extends void >( callback: ( ...args: Args ) => Promise< Return > ) { return async ( ...args: Args ) => { @@ -288,10 +282,10 @@ function wrapWithStartingPromise< Args extends unknown[], Return extends StartSe } const startServer = wrapWithStartingPromise( - async ( config: ServerConfig, signal: AbortSignal ): Promise< StartServerResult > => { + async ( config: ServerConfig, signal: AbortSignal ): Promise< void > => { if ( server ) { logToConsole( `Server already running for site ${ config.siteId }` ); - return StartServerResult.ALREADY_STARTED; + return; } startupAbortController = new AbortController(); @@ -315,8 +309,6 @@ const startServer = wrapWithStartingPromise( } stopSignal.throwIfAborted(); - - return StartServerResult.OK; } catch ( error ) { if ( server ) { await server[ Symbol.asyncDispose ](); @@ -325,7 +317,6 @@ const startServer = wrapWithStartingPromise( if ( error instanceof Error && error.name === 'AbortError' ) { logToConsole( `Aborted start server operation:`, error ); - return StartServerResult.ABORTED; } else { errorToConsole( `Failed to start server:`, error ); throw error; @@ -338,23 +329,17 @@ const startServer = wrapWithStartingPromise( const STOP_SERVER_TIMEOUT = 5000; -enum StopServerResult { - ABORTED_STARTUP = 'ABORTED_STARTUP', - ALREADY_STOPPED = 'ALREADY_STOPPED', - OK = 'OK', -} - -async function stopServer(): Promise< StopServerResult > { +async function stopServer(): Promise< void > { // If there's a startup in progress, abort and return gracefully if ( startupAbortController ) { logToConsole( 'Startup operation in progress. Aborting it to stop the server…' ); startupAbortController.abort(); - return StopServerResult.ABORTED_STARTUP; + return; } if ( ! server ) { logToConsole( 'No server running, nothing to stop' ); - return StopServerResult.ALREADY_STOPPED; + return; } try { @@ -364,7 +349,6 @@ async function stopServer(): Promise< StopServerResult > { await Promise.race( [ server[ Symbol.asyncDispose ](), disposalTimeout ] ); logToConsole( 'Server stopped gracefully' ); - return StopServerResult.OK; } catch ( error ) { errorToConsole( 'Error during server disposal:', error ); throw error; From b2e5c7945d2bcb257a1a67779eff737e447f0561 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 08:58:13 +0100 Subject: [PATCH 10/18] Throw abort errors, too --- apps/cli/wordpress-server-child.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 3168ff0a4f..f08abbdcb0 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -319,8 +319,9 @@ const startServer = wrapWithStartingPromise( logToConsole( `Aborted start server operation:`, error ); } else { errorToConsole( `Failed to start server:`, error ); - throw error; } + + throw error; } finally { startupAbortController = null; } @@ -330,7 +331,9 @@ const startServer = wrapWithStartingPromise( const STOP_SERVER_TIMEOUT = 5000; async function stopServer(): Promise< void > { - // If there's a startup in progress, abort and return gracefully + // If there's a startup in progress, abort and return gracefully. The `startServer` function will + // throw because of the aborted signal, leading `ipcMessageHandler` to return an error IPC + // response and killing the process. if ( startupAbortController ) { logToConsole( 'Startup operation in progress. Aborting it to stop the server…' ); startupAbortController.abort(); From afd94daf609e5c6b9e44f38c590aee3e3ab6c372 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 09:03:38 +0100 Subject: [PATCH 11/18] Await sendErrorMessage --- apps/cli/wordpress-server-child.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index f08abbdcb0..2d08fb40c9 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -448,15 +448,19 @@ function parsePhpError( error: unknown ): string { return message; } -function sendErrorMessage( messageId: string, error: unknown ) { - const errorResponse: ChildMessageRaw = { - originalMessageId: messageId, - topic: 'error', - errorMessage: parsePhpError( error ), - errorStack: error instanceof Error ? error.stack : undefined, - cliArgs: lastCliArgs ?? undefined, - }; - process.send!( errorResponse ); +function sendErrorMessage( messageId: string, error: unknown ): Promise< void > { + return new Promise( ( resolve ) => { + const errorResponse: ChildMessageRaw = { + originalMessageId: messageId, + topic: 'error', + errorMessage: parsePhpError( error ), + errorStack: error instanceof Error ? error.stack : undefined, + cliArgs: lastCliArgs ?? undefined, + }; + process.send!( errorResponse, () => { + resolve(); + } ); + } ); } const abortControllers: Record< string, AbortController > = {}; @@ -470,7 +474,7 @@ async function ipcMessageHandler( packet: unknown ) { const minimalMessageSchema = z.object( { id: z.string() } ); const minimalMessage = minimalMessageSchema.safeParse( packet ); if ( minimalMessage.success ) { - sendErrorMessage( minimalMessage.data.id, messageResult.error ); + await sendErrorMessage( minimalMessage.data.id, messageResult.error ); } return; } @@ -504,7 +508,7 @@ async function ipcMessageHandler( packet: unknown ) { result = await runWpCliCommand( validMessage.data.args, abortController.signal ); } catch ( wpCliError ) { errorToConsole( `WP-CLI error:`, wpCliError ); - sendErrorMessage( validMessage.messageId, wpCliError ); + await sendErrorMessage( validMessage.messageId, wpCliError ); return; // Don't crash, just return error to caller } break; @@ -520,7 +524,7 @@ async function ipcMessageHandler( packet: unknown ) { process.send!( response ); } catch ( error ) { errorToConsole( `Error handling message ${ validMessage.topic }:`, error ); - sendErrorMessage( validMessage.messageId, error ); + await sendErrorMessage( validMessage.messageId, error ); process.exit( 1 ); } finally { delete abortControllers[ validMessage.messageId ]; From 8f43258619ef4044fee90a8dfd05d641076bb30a Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 09:11:30 +0100 Subject: [PATCH 12/18] Comments --- apps/cli/wordpress-server-child.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 2d08fb40c9..11abc61c58 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -321,6 +321,7 @@ const startServer = wrapWithStartingPromise( errorToConsole( `Failed to start server:`, error ); } + // Rethrowing the error so that `ipcMessageHandler` returns an error IPC response and kills the process throw error; } finally { startupAbortController = null; @@ -354,6 +355,7 @@ async function stopServer(): Promise< void > { logToConsole( 'Server stopped gracefully' ); } catch ( error ) { errorToConsole( 'Error during server disposal:', error ); + // Rethrowing the error so that `ipcMessageHandler` returns an error IPC response and kills the process throw error; } finally { server = null; From e10b946f7eb186f9c01dbe66914961c0984b64a8 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 09:14:31 +0100 Subject: [PATCH 13/18] Avoid double timestamps --- apps/cli/wordpress-server-child.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 11abc61c58..29846d3f57 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -79,11 +79,11 @@ process.stderr.write = function ( ...args: Parameters< typeof originalStderrWrit } as typeof process.stderr.write; function logToConsole( ...args: Parameters< typeof console.log > ) { - originalConsoleLog( new Date().toISOString(), `[WordPress Server Child]`, ...args ); + originalConsoleLog( `[WordPress Server Child]`, ...args ); } function errorToConsole( ...args: Parameters< typeof console.error > ) { - originalConsoleError( new Date().toISOString(), `[WordPress Server Child]`, ...args ); + originalConsoleError( `[WordPress Server Child]`, ...args ); } function escapePhpString( str: string ): string { From 014807fab396e4d67f9549d97b9bb85267ec45e9 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 09:20:29 +0100 Subject: [PATCH 14/18] Log WP-CLI command details --- apps/cli/wordpress-server-child.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 29846d3f57..1c8fef79e5 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -389,6 +389,8 @@ const runWpCliCommand = sequential( throw new Error( `Failed to run WP CLI command because server is not running` ); } + logToConsole( `Running WP-CLI command:`, args ); + signal.addEventListener( 'abort', () => { From 6d1ef694f3590cf9a56c834c54cd430195c6ffab Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 11:49:55 +0100 Subject: [PATCH 15/18] More graceful behavior in `stopWordPressServer` --- apps/cli/lib/daemon-client.ts | 15 ++++---- apps/cli/lib/wordpress-server-manager.ts | 44 +++++++++++++++++------- apps/cli/wordpress-server-child.ts | 1 + 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 0c4a5055c7..de3517d324 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -246,12 +246,13 @@ const daemonListProcessesSuccessResponseSchema = z.object( { // Cache the process list returned from the process manager for a very short time to make multiple // calls in quick succession more efficient -const listProcesses = cacheFunctionTTL( async () => { +async function listProcesses() { + await connectToDaemon(); const response = await sendDaemonRequest( { type: 'list-processes', } ); return daemonListProcessesSuccessResponseSchema.parse( response ).processes; -} ); +} export async function getDaemonBus(): Promise< DaemonBus > { if ( ! daemonBus ) { @@ -289,10 +290,6 @@ export async function isProcessRunning( processName: string ): Promise< ProcessDescription | undefined > { try { - if ( ! isConnected ) { - return undefined; - } - const processes = await listProcesses(); return processes.find( ( p ) => p.name === processName && p.status === 'online' ); } catch ( error ) { @@ -322,6 +319,12 @@ export async function startProcess( } export async function stopProcess( processName: string ): Promise< void > { + const runningProcess = await isProcessRunning( processName ); + + if ( ! runningProcess ) { + return; + } + await connectToDaemon(); await sendDaemonRequest( { type: 'stop-process', diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index c11aab8f1f..3d84ed5cbd 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -281,20 +281,40 @@ export async function stopWordPressServer( siteId: string ): Promise< void > { const processName = getProcessName( siteId ); const runningProcess = await isProcessRunning( processName ); - if ( runningProcess ) { - try { - await sendMessage( - runningProcess.pmId, - processName, - { topic: 'stop-server', data: {} }, - { maxTotalElapsedTime: GRACEFUL_STOP_TIMEOUT } - ); - } catch { - // Graceful shutdown failed, `stopProcess()` will handle it - } + if ( ! runningProcess ) { + return; } - return stopProcess( processName ); + try { + const exitPromise = new Promise< void >( ( resolve, reject ) => { + getDaemonBus() + .then( ( bus ) => { + bus.on( 'process-event', ( event ) => { + if ( event.process.name === processName && event.event === 'exit' ) { + console.log( 'received exit even in `stopWordPressServer`' ); + resolve(); + } + } ); + } ) + .catch( reject ); + } ); + + await sendMessage( + runningProcess.pmId, + processName, + { topic: 'stop-server', data: {} }, + { maxTotalElapsedTime: GRACEFUL_STOP_TIMEOUT } + ); + + // Allow 5 seconds (arbitrary number) of cleanup time for the child process before throwing an + // exception and telling the process manager to send a SIGKILL signal. + await Promise.race( [ + exitPromise, + new Promise( ( resolve, reject ) => setTimeout( reject, 5000 ) ), + ] ); + } catch { + return stopProcess( processName ); + } } export interface RunBlueprintOptions { diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 1c8fef79e5..e19db124a0 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -529,6 +529,7 @@ async function ipcMessageHandler( packet: unknown ) { } catch ( error ) { errorToConsole( `Error handling message ${ validMessage.topic }:`, error ); await sendErrorMessage( validMessage.messageId, error ); + originalConsoleLog( 'Killing process because of', error ); process.exit( 1 ); } finally { delete abortControllers[ validMessage.messageId ]; From 48e994efd11a7784fdb85613f0fc498d39c02517 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 12:02:41 +0100 Subject: [PATCH 16/18] Close IPC channel, remove event listeners --- apps/cli/lib/wordpress-server-manager.ts | 24 +++++++++++++----------- apps/cli/wordpress-server-child.ts | 19 ++++++++++++++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 3d84ed5cbd..5df3dfd8bc 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -286,17 +286,19 @@ export async function stopWordPressServer( siteId: string ): Promise< void > { } try { - const exitPromise = new Promise< void >( ( resolve, reject ) => { - getDaemonBus() - .then( ( bus ) => { - bus.on( 'process-event', ( event ) => { - if ( event.process.name === processName && event.event === 'exit' ) { - console.log( 'received exit even in `stopWordPressServer`' ); - resolve(); - } - } ); - } ) - .catch( reject ); + const bus = await getDaemonBus(); + let busExitEventListener: ( event: DaemonBusEventMap[ 'process-event' ] ) => void; + + const exitPromise = new Promise< void >( ( resolve ) => { + busExitEventListener = ( event: DaemonBusEventMap[ 'process-event' ] ) => { + if ( event.process.name === processName && event.event === 'exit' ) { + resolve(); + } + }; + + bus.on( 'process-event', busExitEventListener ); + } ).finally( () => { + bus.off( 'process-event', busExitEventListener ); } ); await sendMessage( diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index e19db124a0..5ad6cc987c 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -331,19 +331,25 @@ const startServer = wrapWithStartingPromise( const STOP_SERVER_TIMEOUT = 5000; -async function stopServer(): Promise< void > { +enum StopServerResult { + ABORTED_STARTUP = 'ABORTED_STARTUP', + ALREADY_STOPPED = 'ALREADY_STOPPED', + OK = 'OK', +} + +async function stopServer(): Promise< StopServerResult > { // If there's a startup in progress, abort and return gracefully. The `startServer` function will // throw because of the aborted signal, leading `ipcMessageHandler` to return an error IPC // response and killing the process. if ( startupAbortController ) { logToConsole( 'Startup operation in progress. Aborting it to stop the server…' ); startupAbortController.abort(); - return; + return StopServerResult.ABORTED_STARTUP; } if ( ! server ) { logToConsole( 'No server running, nothing to stop' ); - return; + return StopServerResult.ALREADY_STOPPED; } try { @@ -353,6 +359,7 @@ async function stopServer(): Promise< void > { await Promise.race( [ server[ Symbol.asyncDispose ](), disposalTimeout ] ); logToConsole( 'Server stopped gracefully' ); + return StopServerResult.OK; } catch ( error ) { errorToConsole( 'Error during server disposal:', error ); // Rethrowing the error so that `ipcMessageHandler` returns an error IPC response and kills the process @@ -526,6 +533,12 @@ async function ipcMessageHandler( packet: unknown ) { result, }; process.send!( response ); + + // If the `stopServer` function ran successfully, the last open handle should be the IPC channel. + // Disconnect so that the process can exit cleanly. + if ( validMessage.topic === 'stop-server' && result === StopServerResult.OK ) { + process.disconnect(); + } } catch ( error ) { errorToConsole( `Error handling message ${ validMessage.topic }:`, error ); await sendErrorMessage( validMessage.messageId, error ); From a2248e96fc67b3a02a2367eb0dfb4c9ec5f5c122 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 12:06:00 +0100 Subject: [PATCH 17/18] Remove StopServerResult.ALREADY_STOPPED --- 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 5ad6cc987c..92ca09bcd4 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -333,7 +333,6 @@ const STOP_SERVER_TIMEOUT = 5000; enum StopServerResult { ABORTED_STARTUP = 'ABORTED_STARTUP', - ALREADY_STOPPED = 'ALREADY_STOPPED', OK = 'OK', } @@ -347,9 +346,12 @@ async function stopServer(): Promise< StopServerResult > { return StopServerResult.ABORTED_STARTUP; } + // If there's no `startupAbortController` and no `server` instance, then it's likely the client + // never sent a `start-server` message. Return gracefully so `ipcMessageHandler` can disconnect + // IPC and allow the process to (hopefully) exit cleanly. if ( ! server ) { logToConsole( 'No server running, nothing to stop' ); - return StopServerResult.ALREADY_STOPPED; + return StopServerResult.OK; } try { From 10dc943728a98ae4d8a1a89c0f7279c8918df28c Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 19 Mar 2026 16:37:28 +0100 Subject: [PATCH 18/18] Fix tests --- .../tests/wordpress-server-manager.test.ts | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index 8264441009..442d642c33 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -46,6 +46,7 @@ describe( 'WordPress Server Manager', () => { vi.mocked( daemonClient.startProcess ).mockResolvedValue( mockProcessDescription ); vi.mocked( daemonClient.stopProcess ).mockResolvedValue( undefined ); vi.mocked( daemonClient.getDaemonBus ).mockResolvedValue( mockBus as DaemonBus ); + vi.mocked( daemonClient.sendMessageToProcess ).mockReturnValue( Promise.resolve() ); } ); afterEach( () => { @@ -133,14 +134,51 @@ describe( 'WordPress Server Manager', () => { describe( 'stopWordPressServer', () => { it( 'should stop WordPress server with correct process name', async () => { - await stopWordPressServer( 'test-site-id' ); + vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( { + name: 'studio-site-test-site-id', + pmId: 1, + status: 'online', + pid: 1234, + } ); - expect( vi.mocked( daemonClient.stopProcess ) ).toHaveBeenCalledWith( - 'studio-site-test-site-id' - ); + vi.mocked( daemonClient.sendMessageToProcess ).mockImplementation( ( processId, message ) => { + setImmediate( () => { + mockBus.emit( 'process-message', { + process: { name: 'studio-site-test-site-id', pm_id: 1 }, + raw: { + topic: 'result', + originalMessageId: message.messageId, + }, + } ); + } ); + + return Promise.resolve(); + } ); + + const promise = stopWordPressServer( 'test-site-id' ); + + setTimeout( () => { + mockBus.emit( 'process-event', { + process: { name: 'studio-site-test-site-id', pm_id: 1 }, + event: 'exit', + } ); + }, 500 ); + + await promise; + + expect( vi.mocked( daemonClient.stopProcess ) ).not.toHaveBeenCalled(); } ); - it( 'should propagate stopProcess errors', async () => { + it( 'should propagate errors from fallback `stopProcess` call', async () => { + vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( { + name: 'studio-site-test-site-id', + pmId: 1, + status: 'online', + pid: 1234, + } ); + vi.mocked( daemonClient.sendMessageToProcess ).mockRejectedValue( + new Error( 'Failed to send stop message' ) + ); vi.mocked( daemonClient.stopProcess ).mockRejectedValue( new Error( 'Failed to stop process' ) );