fix(cli): propagate runCLI() startup errors instead of swallowing them via process.exit(1) - #3774
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR changes runCLI() startup failure behavior so errors from startServer() propagate to library callers (instead of being swallowed via process.exit(1)), while preserving standalone CLI exit behavior via the outer wrapper.
Changes:
- Removed the internal
.catch()inrunCLI()sostartServer()rejections rejectrunCLI(). - Updated the “port in use” test to assert
runCLI()rejects withEADDRINUSEand thatprocess.exitis not called.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/playground/cli/src/run-cli.ts | Stops swallowing startup errors so callers can handle failures instead of an unconditional process exit. |
| packages/playground/cli/tests/run-cli.spec.ts | Adjusts expectations for port-conflict behavior to match the new error propagation semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| exitSpy.mockRestore(); | ||
| blockingServer.close(); | ||
| } |
| await expect( | ||
| runCLI({ | ||
| command: 'server', | ||
| port, | ||
| }) | ||
| ).rejects.toThrow(/EADDRINUSE/); |
| return response; | ||
| }, | ||
| }).catch((error) => { | ||
| cliOutput.printError(describeError(error)); | ||
| process.exit(1); | ||
| }); |
|
That catch clause was added intentionally in #3228 to explain the port collision to the CLI user. It is unfortunate that we use the same code path for running a CLI program (Playground CLI) and as a public API for integrators to call. I'm thinking we should know the context: are we in a direct CLI interaction? Or are we running as a library? And then either call |
|
Hey @adamziel, the So just removing the inner catch would regress the CLI UX, the user would see the raw stack trace again. I'd suggest gating that |
|
It looks like this PR is superseded by #3478, which I think is ready to merge after I have time to do a last manual test in the morning. @adamziel and @mho22, regarding
and
I believe PR #3478 balances the concerns without making an as-CLI-or-as-lib distinction. It does the following:
Does this sound like a reasonable compromise for now? |
|
@brandonpayton LGTM. You can close this pull request when #3478 is merged. |
## Motivation for the change, related issues ### Summary `@wp-playground/cli` can be used in two different ways: 1. As the `wp-playground-cli` command that a person runs in a terminal. 2. As a JavaScript library that another application imports and calls. Those two uses need different error behavior. A standalone command may finish by calling `process.exit()`. An imported library function should not: doing so stops the entire Node.js process, including the application that called the library. The caller cannot catch the error, retry the operation, or finish its own cleanup. This happened in WordPress Studio. Studio imports `runCLI()` and uses it to start Playground in a child process. When Playground startup failed, `runCLI()` called `process.exit(1)` before Studio could handle the failure. This PR separates those responsibilities: - Functions exported as the library API return results or throw errors. - The standalone entry point in `cli.ts` decides when the command-line process should exit. Fixes #3520. Supersedes #3774, which proposes the same core `runCLI()` error-propagation fix but does not include the broader entry-point separation and cleanup work here. Related to Automattic/studio#2805 and its [review discussion](Automattic/studio#2805 (review)). ### Behavior before and after this PR | Situation | Before | After | | --- | --- | --- | | An application calls `runCLI()` and startup fails | Playground prints the error and calls `process.exit(1)`. The application cannot catch it. | `runCLI()` attempts cleanup and rejects its promise (an asynchronous error that normal `try`/`catch` can handle). The application can retry or report it. | | `parseOptionsAndRunCLI()` finishes a one-shot command or reaches an explicitly handled validation exit | It calls `process.exit()`. | It returns `{ exitCode }`, and the caller decides what to do with that code. | | A person runs the standalone CLI and startup fails | The process exits with code 1. | The process still exits with code 1. This responsibility now lives in `cli.ts`. | | A running standalone CLI receives `SIGINT` (for example, Ctrl+C) or `SIGTERM` (a shutdown request) | Signal handling is registered by the library-oriented `run-cli.ts` module. | `cli.ts` disposes the server and then exits. A library consumer controls its own signal policy. | | A library caller runs the `php` command | `runCLI()` calls `process.exit()` with PHP's exit code. | `runCLI()` returns the numeric exit code. The standalone CLI passes that code to `process.exit()`. | For example, a library consumer can now handle a startup failure normally: ```ts try { await using server = await runCLI({ command: 'server', port: 9400, }); // Use the server... } catch (error) { // Report the error, choose another port, retry, etc. } ``` The `await using` declaration automatically disposes the server when execution leaves the `try` block. ## Implementation details ### Keep process termination in the standalone entry point - Removed executable `process.exit()` calls from `run-cli.ts` library paths. - `cli.ts` now translates library results into command-line behavior: - `{ exitCode }` results become the process exit code. - Unexpected errors exit with code 1 after the error is reported. - `SIGINT` and `SIGTERM` dispose a running server before exiting with code 0. - `runCLI({ command: 'php' })` returns PHP's exit code instead of terminating the caller's process. `parseOptionsAndRunCLI()` now returns a `ParseCLIResult`. There are two possible result shapes: - `CLIExitResult`: `{ exitCode: number }` for validation failures and commands that finish without leaving a server running. - `CLIServerResult`: an `AsyncDisposable` handle for a running server. `AsyncDisposable` means the result has an asynchronous cleanup method at `result[Symbol.asyncDispose]()`. Callers may invoke it directly or use `await using` where that syntax is supported. Explicitly handled validation failures use `CLIArgsValidationError`. This prevents an ordinary user mistake from being printed as an unexpected stack trace. `parseOptionsAndRunCLI()` converts the error into `{ exitCode }`. `CLIArgsValidationError` is exported so a direct `runCLI()` caller can identify it with `instanceof` and inspect its `exitCode`. For example, a direct caller may receive this error when `start --reset` targets a site that Playground CLI does not manage. ### Clean up resources before returning an error Removing `process.exit()` exposed an important second problem: a rejected promise is not enough if workers or sockets are still open. Those resources can keep Node.js running even after the caller has handled the error. On each covered boot-failure path, `runCLI()` calls its shared cleanup routine. That routine attempts to: - Terminate spawned worker threads when boot fails, including the normal non-debug path. - Close the HTTP server and all active connections. - Remove the temporary Playground directory even when setup fails before WordPress boots. `startServer()` also closes a socket that has already started listening when `onBind` throws. If cleanup itself fails, the failure is logged without replacing the original startup error; the caller still receives the startup error. Separately, mount failures now include the host path, virtual filesystem path, and root cause. This keeps the message useful to callers that only display `error.message`. ### Keep command-line errors readable For normal CLI use, an unexpected error now prints one concise line, for example: ```text Error: listen EADDRINUSE: address already in use :::12345 ``` The raw error and full debug details remain available with `--debug`. Resetting a site that is not managed by Playground CLI is treated as an expected validation failure. The user sees the existing guidance, while a library caller receives a catchable error with an exit code. ## Breaking changes for library consumers > [!IMPORTANT] > These are public API changes for applications that import > `@wp-playground/cli`. People who only run the `wp-playground-cli` command are > not required to change how they invoke the command. - `runCLI({ command: 'php' })` now resolves to `number` instead of `void`. The number is PHP's exit code. - The general `runCLI()` return type now includes `number`. - A startup failure from `runCLI()` rejects after attempting cleanup instead of terminating the process. - `parseOptionsAndRunCLI()` returns `ParseCLIResult`. Errors that are not converted into explicit exit results are reported and rethrown instead of causing an internal `process.exit()`. - `parseOptionsAndRunCLI()` no longer installs `SIGINT` or `SIGTERM` handlers. A library application that starts a server is responsible for deciding how its process handles signals and when it disposes the server. The successful result from `runCLI({ command: 'server' | 'start' })` is unchanged: it still exposes `.server`, `.serverUrl`, `.playground`, and `[Symbol.asyncDispose]`. ### Compatibility review In July 2026, I searched GitHub for known public consumers of the library API. This is a point-in-time lower bound because private repositories do not appear in public code search. That review did not identify a public consumer expected to break at its currently declared dependency version. <details> <summary>Known public consumers checked</summary> | Consumer | Installed version | Expected effect | | --- | --- | --- | | `Automattic/wp-codebox` | `^3.1.35` | Improvement. It already replaces `process.exit()` with a thrown error. This change makes that workaround unnecessary and allows its `EADDRINUSE` retry logic to work. | | `bgrgicak/wp-tester` | `3.0.15` exactly | Unaffected at its current version. After an upgrade, startup failures become catchable instead of terminating the test process. | | `juanma-wp/users-headless-nextjs-playground` | `1.1.2` exactly | Unaffected at its current version. Its existing `try`/`catch` will work as intended after an upgrade. | | `n3f/link-preview-cards` | `^2.0.4` | Unaffected at its current major version. After an upgrade, an unhandled startup rejection would still end the process with a failure, but callers should add explicit error handling. | | `pfaciana/wp-now-playwright-testing` | `^1.2.2` | Unaffected at its current major version. After an upgrade, it must own graceful signal cleanup if it relies on that behavior. | | `siteorigin/siteorigin-tests-common` | `^2.0.16` | Unaffected at its current major version. Its existing `try`/`finally` structure already expects the call to throw. | All six consumers use the server command, so the `php` return-type change does not affect them. Coordinate separately with private consumers that are not visible through GitHub search. </details> ## Testing Instructions (or ideally a Blueprint) Automated checks run for this change: ```bash npm exec -- nx run playground-cli:test-playground-cli npm exec -- nx run playground-cli:typecheck npm exec -- nx run playground-cli:lint ``` The CLI test target passes all 186 tests, and the PR checks pass on Linux, macOS, and Windows. Coverage includes: - Catchable startup and port-conflict errors. - Structured results for every command type. - Worker termination and socket cleanup after startup failures. - Concise normal error output and debug-only details. - `SIGINT` and `SIGTERM` cleanup in the standalone entry point. Manual checks: 1. Occupy a port, start the standalone CLI on that port, and confirm it prints one concise error and exits with code 1. Repeat with `--debug` and confirm the additional details are shown. 2. Import `runCLI()` from another Node.js program, trigger a startup failure, and confirm the program can catch the error and continue running. 3. Start the standalone CLI, send `SIGINT` or `SIGTERM`, and confirm it cleans up before exiting successfully. --------- Co-authored-by: Brandon Payton <brandon@happycode.net> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hi @amitraj2203, we just merged #3478 which I believe supersedes this PR, so I will close this one. Would you please file an issue if you have any remaining concerns? Thank you for taking a look and contributing a candidate fix. |
Motivation for the change, related issues
runCLI()inpackages/playground/cli/src/run-cli.tshad a.catch()on the internalstartServer()call that swallowed startup errors by printing them and callingprocess.exit(1):This means library callers of
runCLI()— such as WordPress Studio, which spawns it as a daemon — can never catch or handle startup errors. A non-fatal error (e.g. a port conflict, a transient worker exit race) kills the entire process with no opportunity to retry or report it gracefully.Fixes #3520
Implementation details
Remove the
.catch()so rejections fromstartServer()propagate naturally throughawaitas a rejected promise fromrunCLI().The
process.exit(1)behavior for standalone CLI users is preserved unchanged:parseOptionsAndRunCLI()already wrapsrunCLI()in its owntry/catchthat formats the error and exits.The existing test
should error when explicit port is already in usewas asserting the old broken behavior (error printed to stdout +process.exitcalled). Updated it to assert the correct behavior:runCLI()rejects withEADDRINUSEandprocess.exitis not called.Breaking change: library callers that previously relied on
runCLI()never rejecting will now see unhandled rejections on startup failure. This is the correct behavior — callers should handle errors fromrunCLI().Testing Instructions (or ideally a Blueprint)
Run the CLI test suite and confirm all tests pass:
Confirm the standalone CLI still exits cleanly on a port conflict:
Confirm library callers can now catch errors: