Skip to content

[CLI] Remove process.exit() from library APIs - #3478

Merged
brandonpayton merged 21 commits into
WordPress:trunkfrom
gcsecsey:gcsecsey/php-error-server-restart
Jul 14, 2026
Merged

[CLI] Remove process.exit() from library APIs#3478
brandonpayton merged 21 commits into
WordPress:trunkfrom
gcsecsey:gcsecsey/php-error-server-restart

Conversation

@gcsecsey

@gcsecsey gcsecsey commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

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.

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:

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:

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.

Known public consumers checked
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.

Testing Instructions (or ideally a Blueprint)

Automated checks run for this change:

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.
@gcsecsey
gcsecsey marked this pull request as ready for review April 10, 2026 14:08
@gcsecsey
gcsecsey requested review from a team, JanJakes and Copilot April 10, 2026 14:08

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 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 in run-cli.ts and worker-thread-v2.ts with 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.

Comment thread packages/playground/cli/src/run-cli.ts Outdated
Comment thread packages/playground/cli/src/run-cli.ts Outdated
Comment thread packages/playground/cli/src/cli.ts
Comment thread packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts Outdated
@brandonpayton

brandonpayton commented Apr 11, 2026

Copy link
Copy Markdown
Member

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.

@brandonpayton
brandonpayton self-requested a review April 11, 2026 05:42
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.
@gcsecsey

Copy link
Copy Markdown
Contributor Author

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.

Thank you for making these changes @brandonpayton! I also addressed the smaller feedbacks from copilot in the meantime.

@JanJakes JanJakes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread packages/playground/cli/src/run-cli.ts Outdated
gcsecsey added 2 commits May 8, 2026 16:12
…erver-restart

# Conflicts:
#	packages/playground/cli/tests/run-cli.spec.ts
gcsecsey added 2 commits May 11, 2026 15:01
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.
gcsecsey added 2 commits May 11, 2026 15:04
…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
@gcsecsey

Copy link
Copy Markdown
Contributor Author

Hey @brandonpayton and @JanJakes, I refreshed the branch against trunk and resolved the conflicts. This should be ready for another review.

@brandonpayton

brandonpayton commented Jul 2, 2026

Copy link
Copy Markdown
Member

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 — gcsecsey/php-error-server-restart

High-effort review of the CLI boot/exit refactor. Three findings survived verification; all in packages/playground/cli.

1. 🔴 Boot-failure cleanup regression — leaks worker threads, consumers hang

packages/playground/cli/src/start-server.ts:91 (root cause also spans start-server.ts:98, run-cli.ts:1719)

Removing runCLI's .catch(process.exit(1)) safety net means a non-debug startup error no longer tears the process down. When boot fails inside onBind, run-cli.ts rethrows without calling disposeCLI (that only ran in the debug branch). startServer's new catch closes only the listening HTTP socket and rethrows — the already-spawned Node worker_threads are never terminated.

Failure scenario: A downstream consumer (Studio / wp-env / Telex) does await runCLI({command:'server', blueprint: <broken>, verbosity: not 'debug'}) and catches the rejection to continue. The orphaned worker threads keep the event loop alive and the process never exits. Previously the removed .catch called process.exit(1) and tore everything down.

Status: ✅ Fixed in e338ce236 — dispose on the non-debug path before rethrowing.

2. 🟠 Breaking change to the exported runCLI('php') contract

packages/playground/cli/src/run-cli.ts:1682

runCLI for command: 'php' now returns a number instead of calling process.exit. This changes the public API's exit contract for programmatic callers.

Failure scenario: A consumer using await using cli = await runCLI({command:'php', ...}) now receives a plain number, which isn't disposable → TypeError: Symbol.asyncDispose is not a function at scope exit. A caller that relied on the process exiting no longer terminates.

Status: Intentional design change (entry point calls process.exit as a hard cutoff to avoid hanging on open handles; covered by a test). Not fixed in code — needs a breaking-change callout in the PR description per the repo's backwards-compat policy.

3. 🟡 Unmanaged-site reset dumps a stack trace over the friendly message

packages/playground/cli/src/run-cli.ts:1912

Replacing process.exit(1) with a plain thrown Error in expandStartCommandArgs routes this expected validation case through parseOptionsAndRunCLI's unexpected-error catch, which does console.error(e) (full stack trace) plus the duplicated bold guidance message.

Failure scenario: playground start --reset against an externally-mounted (non-~/.wordpress-playground/sites) directory previously showed a clean red guidance block + exit 1. Now the guidance is buried under a raw stack trace.

Status: ✅ Fixed in cc5271fc6 — throw CLIArgsValidationError(1) so the clean-exit path handles it.


Refuted during verification (5)

For transparency, these candidates were raised by finders but rejected by the verify pass: SIGINT/SIGTERM dangling-listener concerns in cli.ts (×2), the worker-thread-v2.ts mountResources throw concerns (×2), and a claimed close-snippet duplication in start-server.ts.


gcsecsey added 4 commits July 7, 2026 16:18
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.
…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).
@gcsecsey

gcsecsey commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@brandonpayton Thanks for flagging these. I took a closer look, and got Claude to reimplement the two fixes locally:

  1. 🔴 Worker-thread leak on boot failure — valid, and dropping runCLI’s .catch(process.exit(1)) was the right call. It exposed that onBind’s non-debug catch rethrew without calling disposeCLI(). So the spawned worker_threads were never terminated and a library caller that catches the rejection keeps its event loop alive. Fixed by always disposing before rethrowing. Added a regression test that fails boot after the worker spawns and asserts terminate() runs — confirmed it fails without the fix. → 5d83b44

  2. 🟠 runCLI no longer calls process.exit — this is intentional, no code change needed. I'll add a callout to the PR description.

  3. 🟡 Stack trace over the friendly reset message — valid, and I think this is the approach you weren’t sure about. Throwing CLIArgsValidationError does fix the CLI output (clean exit, no stack), but the wrinkle is that sentinel was documented as “never exposed to callers,” and expandStartCommandArgs runs inside the public runCLI() export — so a direct library caller doing runCLI({command:'start', reset:true}) would get it, with an empty message. So I gave the sentinel a real message and relaxed its doc: the CLI still exits cleanly, and a library caller now gets a meaningful error instead of an empty one. → 7da57c5

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?

@JanJakes

Copy link
Copy Markdown
Member

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>
@brandonpayton

Copy link
Copy Markdown
Member

Pushed a follow-up commit addressing review feedback:

  • Clean single-line error output for ordinary failures (port in use, missing blueprint, bad mount); full stack trace retained behind --debug.
  • Fixed a temp-dir leak on pre-boot failures (temp-dir setup now runs under disposeCLI()).
  • Exported CLIArgsValidationError so library callers can instanceof-check it and read exitCode.
  • A disposeCLI() failure during teardown no longer masks the original boot error.
  • Tightened the error-path test assertions to specific messages.

Also refreshed the PR description: dropped the stale worker-thread-v2.ts reference, documented the four bundled hardening changes, and reworked the Breaking-changes section — it's technically breaking (function signatures), but a survey of the known public library-API consumers found none break in practice (five are pinned below 3.x; the one whose caret would pull it in, wp-codebox, actually benefits — the change fixes its EADDRINUSE retry loop).

playground-cli: 180/180 tests pass; lint + typecheck clean.

brandonpayton and others added 3 commits July 11, 2026 13:24
…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 brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I took a look and left some notes for Codex to address. This is close.

Comment thread packages/playground/cli/tests/run-cli.spec.ts
Comment thread packages/playground/cli/tests/run-cli.spec.ts Outdated
Comment thread packages/playground/cli/src/run-cli.ts
Comment thread packages/playground/cli/src/run-cli.ts Outdated

@brandonpayton brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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. 🫡

@brandonpayton

Copy link
Copy Markdown
Member

I tested the server, start, and php commands locally, and they seem to be working well. Let's merge. Thanks again, @gcsecsey!

@brandonpayton

Copy link
Copy Markdown
Member

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.

@brandonpayton brandonpayton changed the title [CLI] Replace process.exit() with thrown errors in library code paths Jul 14, 2026
@brandonpayton brandonpayton changed the title [CLI] Make library errors catchable and clean up failed starts Jul 14, 2026
@brandonpayton
brandonpayton merged commit 82214b4 into WordPress:trunk Jul 14, 2026
53 checks passed
mho22 added a commit that referenced this pull request Jul 14, 2026
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>
@gcsecsey

gcsecsey commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

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!

@brandonpayton

Copy link
Copy Markdown
Member

@gcsecsey Glad to help! We had some hiccups with the release, but there is now a new release that should contain these changes.

gcsecsey added a commit to Automattic/studio that referenced this pull request Jul 31, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment