Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 86 additions & 44 deletions packages/playground/cli/src/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,33 @@ export async function parseOptionsAndRunCLI() {
],
} as RunCLIArgs;

await runCLI(cliArgs);
const cliServer = await runCLI(cliArgs);
if (cliServer === undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we have explicit result modes for every outcome? undefined means "success, no server", Server instance means there's a server running, an exception means an error. That sounds fine for the initial launch. Do we have any way of reporting errors further down the road other than the logger? If not, that may be a good exploration after this PR.

// No server was started, so we are done with our work.
process.exit(0);
}

const cleanUpCliAndExit = (() => {
// Remember we are already cleaning up to preclude the possibility
Copy link
Collaborator

Choose a reason for hiding this comment

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

❤️

// of multiple, conflicting cleanup attempts.
let promiseToCleanup: Promise<void>;

return async () => {
if (promiseToCleanup !== undefined) {
promiseToCleanup = cliServer[Symbol.asyncDispose]();
}
await promiseToCleanup;
process.exit(0);
};
})();

// Playground CLI server must be killed to exit. From the terminal,
// this may occur via Ctrl+C which sends SIGINT. Let's handle both
// SIGINT and SIGTERM (the default kill signal) to make sure we
// clean up after ourselves even if this process is being killed.
// NOTE: Windows does not support SIGTERM, but Node.js provides some emulation.
process.on('SIGINT', cleanUpCliAndExit);
process.on('SIGTERM', cleanUpCliAndExit);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nice. I just confirmed 'SIGKILL' cannot have a listener installed. Would SIGHUP be interesting for us here as well? Also, this caught my eye:

'SIGBUS', 'SIGFPE', 'SIGSEGV', and 'SIGILL', when not raised artificially using kill(2), inherently leave the process in a state from which it is not safe to call JS listeners. Doing so might cause the process to stop responding.

} catch (e) {
if (!(e instanceof Error)) {
throw e;
Expand Down Expand Up @@ -437,7 +463,6 @@ export interface RunCLIArgs {
autoMount?: string;
experimentalMultiWorker?: number;
experimentalTrace?: boolean;
exitOnPrimaryWorkerCrash?: boolean;
internalCookieStore?: boolean;
'additional-blueprint-steps'?: any[];
xdebug?: boolean | { ideKey?: string };
Expand Down Expand Up @@ -492,7 +517,17 @@ const italic = (text: string) =>
const highlight = (text: string) =>
process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text;

export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
// These overloads are declared for convenience so runCLI() can return
Copy link
Collaborator

Choose a reason for hiding this comment

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

good idea, thank you

// different things depending on the CLI command without forcing the
// callers (mostly automated tests) to check return values.
export async function runCLI(
args: RunCLIArgs & { command: 'build-snapshot' | 'run-blueprint' }
): Promise<void>;
export async function runCLI(
args: RunCLIArgs & { command: 'server' }
): Promise<RunCLIServer>;
export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void>;
Comment on lines +523 to +529
Copy link
Member Author

Choose a reason for hiding this comment

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

These overloads are declared for convenience so runCLI() can return different things depending on the CLI command without forcing the callers (mostly automated tests) to check return values.

Copy link
Member Author

Choose a reason for hiding this comment

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

^ Will add this as an inline comment.

export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> {
let loadBalancer: LoadBalancer;
let playground: RemoteAPI<PlaygroundCliWorker>;

Expand Down Expand Up @@ -562,7 +597,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {

return startServer({
port: args['port'] as number,
onBind: async (server: Server, port: number): Promise<RunCLIServer> => {
onBind: async (server: Server, port: number) => {
Copy link
Member Author

Choose a reason for hiding this comment

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

The type is removed here because it is easier if TypeScript to infers it. There should be little risk. What this handler returns becomes the return value of runCLI() which is further type-checked to match the runCLI() return value.

const host = '127.0.0.1';
const serverUrl = `http://${host}:${port}`;
const siteUrl = args['site-url'] || serverUrl;
Expand All @@ -584,10 +619,10 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
* because we don't have to create or maintain multiple copies of the same files.
*/
const tempDirNameDelimiter = '-playground-cli-site-';
const nativeDirPath = await createPlaygroundCliTempDir(
const nativeDir = await createPlaygroundCliTempDir(
tempDirNameDelimiter
);
logger.debug(`Native temp dir for VFS root: ${nativeDirPath}`);
logger.debug(`Native temp dir for VFS root: ${nativeDir.path}`);

const IDEConfigName = 'WP Playground CLI - Listen for Xdebug';

Expand All @@ -602,7 +637,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
// directory and add the new IDE config.
if (args.xdebug && args.experimentalUnsafeIdeIntegration) {
await createPlaygroundCliTempDirSymlink(
nativeDirPath,
nativeDir.path,
symlinkPath,
process.platform
);
Expand Down Expand Up @@ -696,17 +731,15 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {

console.log('');
} catch (error) {
logger.error(
'Could not configure Xdebug:',
(error as Error)?.message
);
process.exit(1);
throw new Error('Could not configure Xdebug', {
cause: error,
});
Copy link
Member Author

Choose a reason for hiding this comment

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

I removed the explicit exit here so the callers can decide whether to exit or not. In automated tests, this case will no longer cause the test runner process (or worker) to exit, and in regular operation parseOptionsAndRunCLI() will catch this error and exit.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm happy as long as we're protected from this scenario:

  • The CLI user starts CLI and asks for XDebug with configuration
  • Configuration failed, there's a faint log line in the CLI
  • The user never noticed it
  • They're scrambling to get Xdebug to work and start thinking "this Playground CLI thing doesn't work"

I've struggled to get Xdebug to work too many times to let an Xdebug-related failure through.

In automated tests, this case will no longer cause the test runner process (or worker) to exit

Oh, it exited the test runner process? Should we run that in a sub-process instead?

}
}

// We do not know the system temp dir,
// but we can try to infer from the location of the current temp dir.
const tempDirRoot = path.dirname(nativeDirPath);
const tempDirRoot = path.dirname(nativeDir.path);

const twoDaysInMillis = 2 * 24 * 60 * 60 * 1000;
const tempDirStaleAgeInMillis = twoDaysInMillis;
Expand All @@ -721,7 +754,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {

// NOTE: We do not add mount declarations for /internal here
// because it will be mounted as part of php-wasm init.
const nativeInternalDirPath = path.join(nativeDirPath, 'internal');
const nativeInternalDirPath = path.join(nativeDir.path, 'internal');
mkdirSync(nativeInternalDirPath);

const userProvidableNativeSubdirs = [
Expand All @@ -746,7 +779,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
// The user hasn't requested mounting a different native dir for this path,
// so let's create a mount from within our native temp dir.
const nativeSubdirPath = path.join(
nativeDirPath,
nativeDir.path,
subdirName
);
mkdirSync(nativeSubdirPath);
Expand Down Expand Up @@ -799,26 +832,48 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
}
}

// Remember whether we are already disposing so we can avoid:
// - we can avoid multiple, conflicting dispose attempts
Comment on lines +835 to +836
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Remember whether we are already disposing so we can avoid:
// - we can avoid multiple, conflicting dispose attempts
// Remember whether we are already disposing so we can avoid:
// - multiple, conflicting dispose attempts

// - logging that a worker exited while the CLI itself is exiting
let disposing = false;
const disposeCLI = async function disposeCLI() {
Copy link
Member Author

Choose a reason for hiding this comment

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

This function is now also used to clean up after run-blueprint and build-snapshot.

if (disposing) {
return;
}

disposing = true;
await Promise.all(
playgroundsToCleanUp.map(async ({ playground, worker }) => {
await playground.dispose();
await worker.terminate();
})
);
if (server) {
await new Promise((resolve) => server.close(resolve));
}
await nativeDir.cleanup();
};

// Kick off worker threads now to save time later.
// There is no need to wait for other async processes to complete.
const promisedWorkers = spawnWorkerThreads(
totalWorkerCount,
handler.getWorkerType(),
({ exitCode, isMain, workerIndex }) => {
if (exitCode === 0) {
({ exitCode, workerIndex }) => {
// We are already disposing, so worker exit is expected
// and does not need to be logged.
if (disposing) {
return;
}

if (exitCode !== 0) {
return;
}

logger.error(
`Worker ${workerIndex} exited with code ${exitCode}\n`
);
// If the primary worker crashes, exit the entire process.
if (!isMain) {
return;
}
if (!args.exitOnPrimaryWorkerCrash) {
return;
}
process.exit(1);
// @TODO: Should we respawn the worker if it exited with an error and the CLI is not shutting down?
Copy link
Member Author

Choose a reason for hiding this comment

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

I removed the explicit exit for primary worker because I believe that runCLI()'s promise will be rejected if the primary worker fails during boot. And I do not believe the primary worker is special any longer.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If the user requested 5 workers then yes, let's keep that amount of workers around. Otherwise we may run out of workers eventually if one crashes every 5 minutes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I love not having a special primary worker btw

Copy link
Collaborator

@adamziel adamziel Nov 3, 2025

Choose a reason for hiding this comment

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

This just happened to me btw. The error details are, sadly, not very useful :

CleanShot 2025-11-03 at 11 12 10@2x

Copy link
Collaborator

@adamziel adamziel Nov 3, 2025

Choose a reason for hiding this comment

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

(if you're curious about that Reddit link, it's hilarious)

Michael Jackson liked to prank call Russel Crowe

An exasperated Crowe revealed: “I never met him, never shook his hand, but he found out the name I stayed in hotels under”.

“Is Mr Wall there? Is Mrs Wall there? Are there any Walls there? Then what’s holding the roof up?!”

Copy link
Member Author

Choose a reason for hiding this comment

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

TIL @adamziel 😂 that is amazing, and I'm still laughing.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm looking into the bug you mentioned. I think it's the same we talked about earlier today where trying to run a non-existing PHP file leads to this issue.

}
);

Expand Down Expand Up @@ -869,10 +924,12 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
if (args.command === 'build-snapshot') {
await zipSite(playground, args.outfile as string);
logger.log(`WordPress exported to ${args.outfile}`);
process.exit(0);
await disposeCLI();
return;
} else if (args.command === 'run-blueprint') {
logger.log(`Blueprint executed`);
process.exit(0);
await disposeCLI();
return;
Comment on lines +927 to +932
Copy link
Member Author

Choose a reason for hiding this comment

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

Here we remove explicit exits from runCLI() and let the callers like parseOptionsAndRunCLI() decide based on whether commands succeed or fail.

}

if (
Expand Down Expand Up @@ -927,17 +984,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
playground,
server,
serverUrl,
[Symbol.asyncDispose]: async function disposeCLI() {
await Promise.all(
playgroundsToCleanUp.map(
async ({ playground, worker }) => {
await playground.dispose();
await worker.terminate();
}
)
);
await new Promise((resolve) => server.close(resolve));
},
[Symbol.asyncDispose]: disposeCLI,
workerThreadCount: totalWorkerCount,
};
} catch (error) {
Expand Down Expand Up @@ -993,19 +1040,14 @@ export type SpawnedWorker = {
async function spawnWorkerThreads(
count: number,
workerType: WorkerType,
onWorkerExit: (options: {
exitCode: number;
isMain: boolean;
workerIndex: number;
}) => void
onWorkerExit: (options: { exitCode: number; workerIndex: number }) => void
): Promise<SpawnedWorker[]> {
const promises = [];
for (let i = 0; i < count; i++) {
const worker = await spawnWorkerThread(workerType);
const onExit: (code: number) => void = (code: number) => {
onWorkerExit({
exitCode: code,
isMain: i === 0,
workerIndex: i,
});
};
Expand Down
4 changes: 2 additions & 2 deletions packages/playground/cli/src/start-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import { logger } from '@php-wasm/logger';

export interface ServerOptions {
port: number;
onBind: (server: Server, port: number) => Promise<RunCLIServer>;
onBind: (server: Server, port: number) => Promise<RunCLIServer | void>;
handleRequest: (request: PHPRequest) => Promise<PHPResponse>;
}

export async function startServer(
options: ServerOptions
): Promise<RunCLIServer> {
): Promise<RunCLIServer | void> {
const app = express();

const server = await new Promise<
Expand Down
28 changes: 13 additions & 15 deletions packages/playground/cli/src/temp-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,25 @@ export async function createPlaygroundCliTempDir(
// Otherwise, we would have to parse the binary name from the full path.
const tempDirPrefix = `${nodeBinaryName}${substrToIdentifyTempDirs}${process.pid}-`;

const nativeDirPath = (
await tmpDir({
prefix: tempDirPrefix,
/*
* Allow recursive cleanup on process exit.
*
* NOTE: I worried about whether this cleanup would follow symlinks
* and delete target files instead of unlinking the symlink,
* but this feature uses rimraf under the hood which respects symlinks:
* https://github.com/raszi/node-tmp/blob/3d2fe387f3f91b13830b9182faa02c3231ea8258/lib/tmp.js#L318
*/
unsafeCleanup: true,
})
).path;
const nativeDir = await tmpDir({
prefix: tempDirPrefix,
/*
* Allow recursive cleanup on process exit.
*
* NOTE: I worried about whether this cleanup would follow symlinks
* and delete target files instead of unlinking the symlink,
* but this feature uses rimraf under the hood which respects symlinks:
* https://github.com/raszi/node-tmp/blob/3d2fe387f3f91b13830b9182faa02c3231ea8258/lib/tmp.js#L318
*/
unsafeCleanup: true,
});

if (autoCleanup) {
// Request graceful cleanup on process exit.
tmpSetGracefulCleanup();
}

return nativeDirPath;
return nativeDir;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/playground/cli/tests/temp-dir-test-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ process.on('message', async (message: any) => {
);
// Add a file to the temp dir to test that cleanup works
// on non-empty dirs.
fs.writeFileSync(path.join(tempDir, 'test.txt'), 'test');
fs.writeFileSync(path.join(tempDir.path, 'test.txt'), 'test');
process.send!({
type: 'temp-dir',
tempDir,
tempDirPath: tempDir.path,
});
} else if (message.type === 'exit') {
process.exit(0);
Expand Down
Loading