[CLI] Remove process.exit() from library APIs - #3478
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 the CLI error-handling behavior so that library consumers of runCLI() receive thrown errors instead of having their process terminated via process.exit(1).
Changes:
- Replace
process.exit(1)calls inrun-cli.tsandworker-thread-v2.tswith thrown errors. - Move final
process.exit(1)behavior to the CLI entrypoint (cli.ts). - Update the “port already in use” test to assert a rejected promise instead of mocking
process.exit.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/playground/cli/src/run-cli.ts | Converts error exits to thrown errors in parseOptionsAndRunCLI()/runCLI() and replaces an exit-on-reset path with a thrown error. |
| packages/playground/cli/src/cli.ts | Ensures standalone CLI still exits with code 1 by catching parseOptionsAndRunCLI() rejection. |
| packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts | Converts mount failure from stderr+exit to a thrown error with cause. |
| packages/playground/cli/tests/run-cli.spec.ts | Updates port-in-use test from exit-mocking to rejection assertion; small formatting change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for submitting this PR, @gcsecsey! This change makes sense to me, and I think it would be good to go further to remove all process.exit() calls from run-cli.ts exports. That way, the run-cli module exports are consistent, and we can know that all process.exit() calls are handled by cli.ts instead. In addition, I think it is better to explicitly return nonzero exit codes, where possible, instead of throwing in the case of all errors. This way, control flow remains explicit, and the return types reflect more of the possible results. Where possible, exceptions should be exceptional. They are not meant to be an implicit result type. So I had Claude make the above changes and add tests for the different return value possibilities. I pushed the changes but will have to finish reviewing them tomorrow or on Monday. |
The catch in runCLI() was printing the error via cliOutput.printError() before re-throwing. parseOptionsAndRunCLI() already formats and prints errors in its own catch block, so this resulted in duplicate output.
The wrapped error stored the original error as `cause`, but callers that only read `.message` would lose the root cause (e.g. ENOENT, EACCES). Include a short summary in the message itself so the default output remains actionable.
Thank you for making these changes @brandonpayton! I also addressed the smaller feedbacks from copilot in the meantime. |
JanJakes
left a comment
There was a problem hiding this comment.
This is looking good overall. The process.exit() removal and explicit result-returning shape make sense, and the existing CLI lint/typecheck/tests are green.
Before approval, please check the inline server-cleanup issue and rebase this PR onto the latest trunk.
…erver-restart # Conflicts: # packages/playground/cli/tests/run-cli.spec.ts
… into gcsecsey/php-error-server-restart
When the server callback (resolveBlueprint, PHP startup, etc.) throws
after the Express server has started listening, the socket would stay
open and keep library callers' processes alive. Close it inside
startServer so the rejection is the only thing callers need to handle.
Also drop the no-op `.catch(e => { throw e })` in run-cli.ts that Jan
flagged on PR WordPress#3478.
…erver-restart # Conflicts: # CHANGELOG.md # packages/docs/site/docs/main/changelog.md
…erver-restart # Conflicts: # CHANGELOG.md # packages/docs/site/docs/main/changelog.md # packages/docs/site/docs/main/guides/php-code-snippets.md # packages/docs/site/i18n/fr/docusaurus-plugin-content-docs/current/blueprints/08-examples.md # packages/docs/site/i18n/fr/docusaurus-plugin-content-docs/current/main/about/index.md # packages/docs/site/i18n/fr/docusaurus-plugin-content-docs/current/main/intro.md # packages/docs/site/i18n/gu/docusaurus-plugin-content-docs/current/blueprints/01-index.md # packages/docs/site/i18n/gu/docusaurus-plugin-content-docs/current/blueprints/intro.md # packages/docs/site/i18n/pt-BR/docusaurus-plugin-content-docs/current/blueprints/02-using-blueprints.md # packages/php-wasm/compile/build.js # packages/php-wasm/node-builds/7-4/jspi/php_7_4.js # packages/php-wasm/node-builds/8-0/jspi/php_8_0.js # packages/php-wasm/node-builds/8-1/jspi/php_8_1.js # packages/php-wasm/node-builds/8-2/jspi/php_8_2.js # packages/php-wasm/node-builds/8-3/jspi/php_8_3.js # packages/php-wasm/node-builds/8-4/jspi/php_8_4.js # packages/php-wasm/node-builds/8-5/jspi/php_8_5.js
|
Hey @brandonpayton and @JanJakes, I refreshed the branch against trunk and resolved the conflicts. This should be ready for another review. |
|
Claude had some concerns about this. I had it create fixes for the two bugs, but I'm not convinced the fixes use tge right approach. It needs a closer look. For now here are the concerns: -- Code review —
|
runCLI() no longer calls process.exit() on startup failure, so a failed boot now surfaces to library callers (Studio, wp-env, Telex) as a rejection. The onBind boot catch only ran disposeCLI() in the debug branch; the non-debug path rethrew without terminating the worker threads it had already spawned. Those worker_threads keep the caller's event loop alive, so a consumer that catches the rejection to continue would hang or leak. Always read the PHP error log first (debug only), then disposeCLI(), then rethrow — so workers, the server, and the temp dir are torn down on every boot-failure path.
`playground start --reset` against an externally-mounted (non-managed) /wordpress directory printed friendly red guidance and then threw a plain Error. parseOptionsAndRunCLI treats plain Errors as unexpected, so the guidance got buried under a full stack trace plus a duplicated bold message — for what is an expected validation case. Throw CLIArgsValidationError instead so the clean-exit path handles it (exit 1, no stack trace). The sentinel now carries a message: the `start` path runs inside the public runCLI() export, so a direct library caller can receive this error and needs it to be meaningful rather than the previous empty-message form.
… into gcsecsey/php-error-server-restart
…erver-restart Conflict resolutions: - blueprints-v2/worker-thread-v2.ts: trunk deduplicated mountResources into the shared ../mounts module. Adopted trunk's version and ported this branch's root-cause mount-error message (f65ea7c) into the shared mountResources so that improvement isn't lost. - run-cli.spec.ts imports: kept both Worker (worker-dispose test) and ChildProcess (trunk) imports. - run-cli.spec.ts getConstants: took trunk's --workers=1 but dropped the now-inert process.exit spy, since this branch removed process.exit. Also reconciled six trunk-added tests that asserted the old process.exit(1) behavior to this branch's process.exit-free model: five now expect the thrown validation error; the `start --mode` test expects a clean CLIExitResult (exit 1). Ran npm install for trunk's new isomorphic-git / sha.js deps (submodule replaced by a local adapter in WordPress#3841).
|
@brandonpayton Thanks for flagging these. I took a closer look, and got Claude to reimplement the two fixes locally:
For #3, I went with reusing the sentinel + message, rather than introducing a dedicated user-facing error type. Start is really CLI-oriented so the exposure risk is low, but happy to switch to a distinct error type if you’d prefer it be more explicit. WDYT? |
|
Cleaning up my PRs, I figured out #2880 may depend on this one. |
Review follow-ups on top of the process.exit() removal: - Route unexpected errors in parseOptionsAndRunCLI through cliOutput.printError() so ordinary failures (port in use, missing blueprint, bad mount) print one clean line; keep the raw dump and full stack trace behind --debug. - Widen the onBind try so temp-dir setup (symlinks, Xdebug config, blueprint resolution) runs under disposeCLI(); a pre-boot failure no longer leaks the temp dir for long-lived library callers. - Export CLIArgsValidationError so library callers can instanceof-check it and read exitCode. - Never let a disposeCLI() failure mask the original boot error; log a warning and rethrow the cause. - Tighten weakened test assertions to specific error messages and suppress the clean error output via a process.stderr spy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed a follow-up commit addressing review feedback:
Also refreshed the PR description: dropped the stale
|
…erver-restart
Resolved conflicts in packages/playground/cli:
- run-cli.ts: kept the PR's process.exit removal + disposeCLI/try
restructuring; incorporated trunk's removal of the
--experimental-blueprints-v2-runner flag (auto-merged elsewhere).
- run-cli.spec.ts: adopted trunk's rewritten v2-mode validation tests
(new names, scenarios, and messages from validateAndNormalizeBlueprintsV2Args)
and adapted their assertions to the PR's throw/return behavior
(rejects.toThrow(message) + stderr suppression instead of process.exit
mocking). Adapted the retired-flag test to expect a { exitCode } result.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brandonpayton
left a comment
There was a problem hiding this comment.
I took a look and left some notes for Codex to address. This is close.
brandonpayton
left a comment
There was a problem hiding this comment.
I reviewed these changes manually, and the CI tests are passing. This should be ready to go.
I'm planning to run some brief manual tests in the morning to double-check and then merge. If anybody else wants to do the same and merge before then, please feel free. 🫡
|
I tested the |
|
I just noticed that the title and description could be updated to be clearer. I think the latest there is probably from an agent update I requested. Will request more readability and clarity and see what it does. |
Trunk removed process.exit() from the CLI library APIs (#3478): parseOptionsAndRunCLI now returns CLIExitResult | CLIServerResult and runCLI can return a bare exit code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thank you for bringing it over the finish line @brandonpayton, and for updating the description. To accommodate these changes in Studio, could you also help us cut a new release that includes them? Thanks! |
|
@gcsecsey Glad to help! We had some hiccups with the release, but there is now a new release that should contain these changes. |
## Related issues - Related to STU-1370 - Ships the upstream fix WordPress/wordpress-playground#3478, released in Playground v3.1.45 ## How AI was used in this PR I used Claude to trace STU-1370 to the upstream `runCLI()` `process.exit()` behavior, confirm the fix shipped in Playground 3.1.45, bump the dependencies, and regenerate the lockfile. I reviewed the diff and verified single-copy resolution of the Playground and PHP-WASM packages myself. Claude also diagnosed and fixed the Windows-only CLI E2E failure the bump surfaced (see below), reproducing it locally on Windows. ## Proposed Changes Studio runs each site's Playground server in a child process that imports `runCLI()` from `@wp-playground/cli`. When a site hit a PHP error during startup, `runCLI()` called `process.exit(1)` before the error could propagate, which killed the child process, so Studio's error handling never ran and the real error was lost. The upstream fix (WordPress/wordpress-playground#3478, in 3.1.45) removes `process.exit()` from the Playground CLI's library APIs, so `runCLI()` now throws instead of exiting. This PR: - Bumps Playground and PHP-WASM to 3.1.45 to pick up that fix - This is the prerequisite that lets Studio catch PHP-error start failures at all. On its own it makes the failure report the real PHP error (via the existing #3813 handling) instead of tearing the child process down. The user-facing STU-1370 behavior (the site starting and serving the error page) is a follow-up PR that depends on this bump. - Lockfile note: alongside the version bumps, npm deduped `fast-xml-parser` (a nested duplicate collapsed into a single top-level copy, within existing ranges) and added `zstddec`, a new dependency of `@wp-playground/wordpress@3.1.45`. - Windows import fix: on Windows, importing a backup into a running site failed to restart the server with `Error connecting to the SQLite database`. `wp sqlite import` leaves the imported database in WAL journal mode, and 3.1.45 boots the site through the WASM SQLite driver, which cannot reopen a WAL-mode database on Windows (WAL needs shared memory the WASM filesystem can't provide there). After importing, Studio now converts the database back to rollback (DELETE) journal mode — using Node's native `node:sqlite`, since PHP-WASM itself can't touch the WAL database — so Playground can reliably reopen it. ## Testing Instructions Because 3.1.45 is still inside the npm cooldown window, install locally with the cooldown bypassed. A plain `npm install` works from 2026-07-18 onward. - Check out this branch and run `npm install --min-release-age=0` - Confirm the lockfile resolves a single 3.1.45 copy of every `@wp-playground/*` / `@php-wasm/*` package, with no nested duplicates - `npm run typecheck` passes - `npm run cli:build && node apps/cli/dist/cli/main.mjs --version` builds and runs - Smoke test: create and start a local site, confirm it loads and runs normally — i.e. no regression from the runtime bump - On Windows, confirm the import fix: `npm run cli:build` then `npm test -- apps/cli/commands/tests/import.e2e.test.ts --tagsFilter='e2e' --no-file-parallelism -t 'imports a backup into a running site'` ## Pre-merge Checklist - [ ] Have you checked for TypeScript, React or other console errors? - [ ] Confirm the merge date is on or after 2026-07-18 (around 04:13 UTC) so CI can install 3.1.45 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Motivation for the change, related issues
Summary
@wp-playground/clican be used in two different ways:wp-playground-clicommand that a person runs in a terminal.Those two uses need different error behavior. A standalone command may finish
by calling
process.exit(). An imported library function should not: doing sostops 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 tostart Playground in a child process. When Playground startup failed,
runCLI()calledprocess.exit(1)before Studio could handle the failure.This PR separates those responsibilities:
cli.tsdecides when the command-line processshould exit.
Fixes #3520.
Supersedes #3774, which proposes the same core
runCLI()error-propagation fixbut does not include the broader entry-point separation and cleanup work here.
Related to Automattic/studio#2805 and its
review discussion.
Behavior before and after this PR
runCLI()and startup failsprocess.exit(1). The application cannot catch it.runCLI()attempts cleanup and rejects its promise (an asynchronous error that normaltry/catchcan handle). The application can retry or report it.parseOptionsAndRunCLI()finishes a one-shot command or reaches an explicitly handled validation exitprocess.exit().{ exitCode }, and the caller decides what to do with that code.cli.ts.SIGINT(for example, Ctrl+C) orSIGTERM(a shutdown request)run-cli.tsmodule.cli.tsdisposes the server and then exits. A library consumer controls its own signal policy.phpcommandrunCLI()callsprocess.exit()with PHP's exit code.runCLI()returns the numeric exit code. The standalone CLI passes that code toprocess.exit().For example, a library consumer can now handle a startup failure normally:
The
await usingdeclaration automatically disposes the server when executionleaves the
tryblock.Implementation details
Keep process termination in the standalone entry point
process.exit()calls fromrun-cli.tslibrary paths.cli.tsnow translates library results into command-line behavior:{ exitCode }results become the process exit code.SIGINTandSIGTERMdispose a running server before exiting with code 0.runCLI({ command: 'php' })returns PHP's exit code instead of terminatingthe caller's process.
parseOptionsAndRunCLI()now returns aParseCLIResult. There are two possibleresult shapes:
CLIExitResult:{ exitCode: number }for validation failures and commandsthat finish without leaving a server running.
CLIServerResult: anAsyncDisposablehandle for a running server.AsyncDisposablemeans the result has an asynchronous cleanup method atresult[Symbol.asyncDispose](). Callers may invoke it directly or useawait usingwhere that syntax is supported.Explicitly handled validation failures use
CLIArgsValidationError. Thisprevents an ordinary user mistake from being printed as an unexpected stack trace.
parseOptionsAndRunCLI()converts the error into{ exitCode }.CLIArgsValidationErroris exported so a directrunCLI()caller can identifyit with
instanceofand inspect itsexitCode. For example, a direct callermay receive this error when
start --resettargets a site that Playground CLIdoes not manage.
Clean up resources before returning an error
Removing
process.exit()exposed an important second problem: a rejectedpromise 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:
non-debug path.
WordPress boots.
startServer()also closes a socket that has already started listening whenonBindthrows. If cleanup itself fails, the failure is logged withoutreplacing 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:
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 thewp-playground-clicommand arenot required to change how they invoke the command.
runCLI({ command: 'php' })now resolves tonumberinstead ofvoid. Thenumber is PHP's exit code.
runCLI()return type now includesnumber.runCLI()rejects after attempting cleanup instead ofterminating the process.
parseOptionsAndRunCLI()returnsParseCLIResult. Errors that are notconverted into explicit exit results are reported and rethrown instead of
causing an internal
process.exit().parseOptionsAndRunCLI()no longer installsSIGINTorSIGTERMhandlers.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' })isunchanged: 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.
Known public consumers checked
Automattic/wp-codebox^3.1.35process.exit()with a thrown error. This change makes that workaround unnecessary and allows itsEADDRINUSEretry logic to work.bgrgicak/wp-tester3.0.15exactlyjuanma-wp/users-headless-nextjs-playground1.1.2exactlytry/catchwill work as intended after an upgrade.n3f/link-preview-cards^2.0.4pfaciana/wp-now-playwright-testing^1.2.2siteorigin/siteorigin-tests-common^2.0.16try/finallystructure already expects the call to throw.All six consumers use the server command, so the
phpreturn-type change doesnot affect them. Coordinate separately with private consumers that are not
visible through GitHub search.
Testing Instructions (or ideally a Blueprint)
Automated checks run for this change:
The CLI test target passes all 186 tests, and the PR checks pass on Linux,
macOS, and Windows. Coverage includes:
SIGINTandSIGTERMcleanup in the standalone entry point.Manual checks:
one concise error and exits with code 1. Repeat with
--debugand confirmthe additional details are shown.
runCLI()from another Node.js program, trigger a startup failure,and confirm the program can catch the error and continue running.
SIGINTorSIGTERM, and confirm it cleansup before exiting successfully.