Skip to content

fix(cli): propagate runCLI() startup errors instead of swallowing them via process.exit(1) - #3774

Closed
amitraj2203 wants to merge 2 commits into
WordPress:trunkfrom
amitraj2203:fix/runcli-propagate-startup-errors
Closed

fix(cli): propagate runCLI() startup errors instead of swallowing them via process.exit(1)#3774
amitraj2203 wants to merge 2 commits into
WordPress:trunkfrom
amitraj2203:fix/runcli-propagate-startup-errors

Conversation

@amitraj2203

@amitraj2203 amitraj2203 commented Jun 9, 2026

Copy link
Copy Markdown

Motivation for the change, related issues

runCLI() in packages/playground/cli/src/run-cli.ts had a .catch() on the internal startServer() call that swallowed startup errors by printing them and calling process.exit(1):

}).catch((error) => {
    cliOutput.printError(describeError(error));
    process.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 from startServer() propagate naturally through await as a rejected promise from runCLI().

The process.exit(1) behavior for standalone CLI users is preserved unchanged: parseOptionsAndRunCLI() already wraps runCLI() in its own try/catch that formats the error and exits.

The existing test should error when explicit port is already in use was asserting the old broken behavior (error printed to stdout + process.exit called). Updated it to assert the correct behavior: runCLI() rejects with EADDRINUSE and process.exit is 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 from runCLI().

Testing Instructions (or ideally a Blueprint)

  1. Run the CLI test suite and confirm all tests pass:

    nvm use
    npx nx test-playground-cli playground-cli --testFile=run-cli.spec.ts
  2. Confirm the standalone CLI still exits cleanly on a port conflict:

    # Occupy port 9400
    node -e "require('net').createServer().listen(9400)"
    # In another terminal, try to start the CLI on the same port
    npx nx dev playground-cli server --port=9400
    # Should print the EADDRINUSE error and exit with code 1 — same as before
  3. Confirm library callers can now catch errors:

    import { runCLI } from '@wp-playground/cli';
    
    try {
        await runCLI({ command: 'server', port: 9400 }); // port already in use
    } catch (error) {
        console.log('Caught:', error.message); // now reachable
    }
@amitraj2203
amitraj2203 requested review from a team, bgrgicak and Copilot June 9, 2026 14:11
@github-actions github-actions Bot added [Package][@wp-playground] CLI [Type] Bug An existing feature does not function as intended [Aspect] Node.js and removed [Type] Bug An existing feature does not function as intended [Package][@wp-playground] CLI labels Jun 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() in runCLI() so startServer() rejections reject runCLI().
  • Updated the “port in use” test to assert runCLI() rejects with EADDRINUSE and that process.exit is 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.

Comment on lines +2137 to 2139
exitSpy.mockRestore();
blockingServer.close();
}
Comment on lines +2128 to +2133
await expect(
runCLI({
command: 'server',
port,
})
).rejects.toThrow(/EADDRINUSE/);
Comment on lines 1830 to 1832
return response;
},
}).catch((error) => {
cliOutput.printError(describeError(error));
process.exit(1);
});
@adamziel

Copy link
Copy Markdown
Collaborator

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 process.exit(1) or not. Looping in @mho22, who originally worked on #3228, for a second opinion.

@mho22

mho22 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Hey @adamziel, the .catch() in #3238 was only there to print a clean Error: listen EADDRINUSE... line without a stack trace. Your #3468 made parseOptionsAndRunCLI()'s outer catch much smarter but it still has an unconditional console.error(e) in run-cli.ts at line `788 that dumps the full stack before the pretty version.

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 console.error(e) behind --debug or dropping it as part of this PR, then the library/CLI split falls out for free.

@brandonpayton

brandonpayton commented Jul 14, 2026

Copy link
Copy Markdown
Member

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

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 process.exit(1) or not.

and

So just removing the inner catch would regress the CLI UX, the user would see the raw stack trace again.

I believe PR #3478 balances the concerns without making an as-CLI-or-as-lib distinction. It does the following:

  • Moving the process.exit(1) calls into from run-cli.ts to cli.ts
  • Explicitly relaying the exit code to callers for run-and-exit commands like php
  • Continuing to own printed, user-facing error messages in run-cli.ts, including verbosity control via flags like --debug
  • Throwing exceptions for failures, even when it has already printed user-facing messages regarding those failures

Does this sound like a reasonable compromise for now?

@mho22

mho22 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

@brandonpayton LGTM. You can close this pull request when #3478 is merged.

brandonpayton added a commit that referenced this pull request Jul 14, 2026
## 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>
@brandonpayton

Copy link
Copy Markdown
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

5 participants