diff --git a/packages/docs/site/docs/developers/05-local-development/04-wp-playground-cli.md b/packages/docs/site/docs/developers/05-local-development/04-wp-playground-cli.md index f54c9708d3a..5d984a30cb4 100644 --- a/packages/docs/site/docs/developers/05-local-development/04-wp-playground-cli.md +++ b/packages/docs/site/docs/developers/05-local-development/04-wp-playground-cli.md @@ -262,7 +262,8 @@ The `server` command supports the following optional arguments: - `--xdebug`: Enable Xdebug. Defaults to false. - `--experimental-devtools`: Enable experimental browser development tools. Defaults to false. - `--experimental-unsafe-ide-integration=`: Set up the Xdebug integration on VS Code (`vscode`) and PhpStorm (`phpstorm`). -- `--experimental-multi-worker=`: Enable experimental multi-worker support which requires a `/wordpress` directory backed by a real filesystem. Pass a positive number to specify the number of workers to use. Otherwise, defaults to the number of CPUs minus 1. +- `--workers=`: Number of request-handling worker threads. Pass a positive integer, or `auto` to use one worker per CPU core (minus one). Defaults to `min(6, cpus-1)`. Useful for multi-client workloads (e.g. parallel e2e suites) that need more than 6 in-flight requests. +- `--experimental-multi-worker=`: Deprecated. Use `--workers=` instead. The value of this flag is ignored. :::caution With the flag `--follow-symlinks`, the following symlinks will expose files outside mounted directories to Playground and could be a security risk. diff --git a/packages/playground/cli/README.md b/packages/playground/cli/README.md index 4e374fc27a9..4338c15c5e3 100644 --- a/packages/playground/cli/README.md +++ b/packages/playground/cli/README.md @@ -97,7 +97,8 @@ The `server` command supports the following optional arguments: - `--debug`: Print the PHP error log if an error occurs during boot. - `--follow-symlinks`: Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. ⚠️ Warning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk. -- `--experimental-multi-worker`: Enables experimental multi-worker support. It needs JSPI and a /wordpress directory on a real filesystem. You can pass a positive number to use a specific number of workers, otherwise, it defaults to the number of CPUs minus one. +- `--workers=`: Number of request-handling worker threads. Pass a positive integer, or `auto` to use one worker per CPU core (minus one). Defaults to `min(6, cpus-1)`. Useful for multi-client workloads (e.g. parallel e2e suites) that need more than 6 in-flight requests. +- `--experimental-multi-worker`: Deprecated. Use `--workers=` instead. The value of this flag is ignored. - `--phpmyadmin[=]`: Install phpMyAdmin for database management. The phpMyAdmin URL will be printed after boot. Optionally specify a custom URL path (default: `/phpmyadmin`). - `--internal-cookie-store`: Enables Playground's internal cookie handling. When active, Playground uses an HttpCookieStore to manage and persist cookies across requests. If disabled, cookies are handled externally, like by a browser in Node.js. diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index b991937cf6f..1c29604a4e8 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -343,14 +343,35 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { 'Port to listen on when serving. Defaults to 9400 when available.', type: 'number', }, + workers: { + describe: + 'Number of request-handling worker threads. Accepts a ' + + 'positive integer, or "auto" to use one per CPU core ' + + '(minus one). Defaults to min(6, cpus-1).', + type: 'string', + coerce: (value?: string) => { + if (value === undefined) { + return undefined; + } + if (value === 'auto') { + return 'auto' as const; + } + const n = Number(value); + if (!Number.isInteger(n) || n < 1) { + throw new Error( + `Invalid --workers value "${value}": ` + + 'expected a positive integer or "auto".' + ); + } + return n; + }, + }, 'experimental-multi-worker': { deprecated: - 'This option is not needed. Multiple workers are always used.', + 'Use --workers= instead. The value of this flag is ignored.', describe: - 'Enable experimental multi-worker support which requires ' + - 'a /wordpress directory backed by a real filesystem. ' + - 'Pass a positive number to specify the number of workers to use. ' + - 'Otherwise, default to the number of CPUs minus 1.', + 'Deprecated. Use --workers= to control the ' + + 'number of request-handling worker threads.', type: 'number', }, 'experimental-devtools': { @@ -813,6 +834,24 @@ function getMountForVfsPath( ); } +/** + * Resolve the --workers flag into a concrete worker count. + * + * The Math.max(1, ...) guard covers single-core hosts and restricted + * environments where `os.cpus()` can return an empty array — without it + * the default would drop to 0 and no workers would be spawned. + */ +export function resolveWorkerCount(value: number | 'auto' | undefined): number { + const cpusMinusOne = Math.max(1, os.cpus().length - 1); + if (value === undefined) { + return Math.min(6, cpusMinusOne); + } + if (value === 'auto') { + return cpusMinusOne; + } + return value; +} + export interface RunCLIArgs { /** * `_` holds positional tokens in the order they appeared. @@ -854,6 +893,8 @@ export interface RunCLIArgs { experimentalUnsafeIdeIntegration?: string[]; experimentalDevtools?: boolean; 'experimental-blueprints-v2-runner'?: boolean; + workers?: number | 'auto'; + 'experimental-multi-worker'?: number; wordpressInstallMode?: WordPressInstallMode; /** * PHP string constants defined via --define flag. @@ -1082,6 +1123,13 @@ export async function runCLI(args: RunCLIArgs): Promise { const serverUrl = `http://${host}:${port}`; const siteUrl = args['site-url'] || serverUrl; + if (args['experimental-multi-worker'] !== undefined) { + logger.warn( + '--experimental-multi-worker is deprecated and its value is ignored. ' + + 'Use --workers= instead.' + ); + } + /** * With HTTP 1.1, browsers typically support 6 parallel connections per domain. * > browsers open several connections to each domain, @@ -1089,15 +1137,38 @@ export async function runCLI(args: RunCLIArgs): Promise { * > but this has now increased to a more common use of 6 parallel connections. * https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Connection_management_in_HTTP_1.x#domain_sharding * - * While our HTTP server only supports HTTP 1.1 and while we are trying to limit the - * memory requirements of multiple workers, let's hard-code the number of request-handling - * workers to 6. + * 6 is therefore a sensible default; --workers= lets + * users override this for multi-client workloads (e2e tests, + * load testing) that need more in-flight requests. * - * Going higher than browsers' max concurrent requests seems pointless, - * and going lower may increase the likelihood of deadlock due to workers - * blocking and waiting for file locks. + * Note: going lower may increase the likelihood of deadlock + * due to workers blocking and waiting for file locks. */ - const targetWorkerCount = 6; + const targetWorkerCount = resolveWorkerCount(args.workers); + + if (targetWorkerCount < 6) { + const deadlockNote = + 'Running fewer than 6 workers may increase the ' + + 'likelihood of deadlock due to workers blocking on ' + + 'file locks.'; + if (args.workers === undefined) { + /* + * Default path landed below 6 because the machine has + * fewer than 7 CPUs. Distinct message so users see this + * as a hardware ceiling, not a config mistake. + */ + logger.warn( + `The default worker count has been reduced to ${targetWorkerCount} ` + + `because this machine has only ${os.cpus().length} CPU(s). ` + + deadlockNote + ); + } else { + logger.warn( + `Worker count (${targetWorkerCount}) is below the recommended threshold (6). ` + + deadlockNote + ); + } + } /* * Use a real temp dir as a target for the following Playground paths diff --git a/packages/playground/cli/tests/run-cli.spec.ts b/packages/playground/cli/tests/run-cli.spec.ts index 308878393db..c55e1d6ed9e 100644 --- a/packages/playground/cli/tests/run-cli.spec.ts +++ b/packages/playground/cli/tests/run-cli.spec.ts @@ -5,6 +5,7 @@ import { runCLI, parseOptionsAndRunCLI, internalsKeyForTesting, + resolveWorkerCount, } from '../src/run-cli'; import type { RunCLIArgs, RunCLIServer } from '../src/run-cli'; import type { MockInstance } from 'vitest'; @@ -1854,6 +1855,180 @@ describe('other run-cli behaviors', () => { }); }); + describe('worker count', () => { + async function getWorkerCount(cliArgs: string[]) { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as any); + try { + await using cliResult = await parseOptionsAndRunCLI([ + 'server', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + '--port=0', + ...cliArgs, + ]); + const cliServer = cliResult[internalsKeyForTesting].cliServer; + return cliServer[internalsKeyForTesting].workerThreadCount; + } finally { + exitSpy.mockRestore(); + } + } + + const defaultExpected = Math.min(6, Math.max(1, os.cpus().length - 1)); + const autoExpected = Math.max(1, os.cpus().length - 1); + + test('defaults to min(6, cpus-1) when --workers is not set', async () => { + expect(await getWorkerCount([])).toBe(defaultExpected); + }); + + test('honors an explicit --workers=3', async () => { + expect(await getWorkerCount(['--workers=3'])).toBe(3); + }); + + test('honors --workers=1 (single-worker bootstrap path)', async () => { + expect(await getWorkerCount(['--workers=1'])).toBe(1); + }); + + test('--workers=auto uses max(1, cpus-1)', async () => { + expect(await getWorkerCount(['--workers=auto'])).toBe(autoExpected); + }); + + async function expectInvalidWorkersValue(value: string) { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const errorMessages: string[] = []; + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation((...args: unknown[]) => { + errorMessages.push( + args + .map((a) => + a instanceof Error ? a.message : String(a) + ) + .join(' ') + ); + }); + try { + await parseOptionsAndRunCLI([ + 'server', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + '--port=0', + `--workers=${value}`, + ]); + expect(errorMessages.join('\n')).toContain( + `Invalid --workers value "${value}"` + ); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + } + } + + test('--workers=0 fails with a clear error', async () => { + await expectInvalidWorkersValue('0'); + }); + + test('--workers=abc fails with a clear error', async () => { + await expectInvalidWorkersValue('abc'); + }); + + async function getWarnCallsForWorkersArgs( + cliArgs: string[], + cpuCount: number + ) { + const cpusStub = vi + .spyOn(os, 'cpus') + .mockReturnValue(new Array(cpuCount).fill({}) as os.CpuInfo[]); + const warnSpy = vi + .spyOn(logger, 'warn') + .mockImplementation(() => {}); + try { + await getWorkerCount(cliArgs); + return warnSpy.mock.calls + .map((call) => call.join(' ')) + .join('\n'); + } finally { + warnSpy.mockRestore(); + cpusStub.mockRestore(); + } + } + + test('does not warn when the default worker count is 6 on a large host', async () => { + const warnCalls = await getWarnCallsForWorkersArgs([], 8); + expect(warnCalls).not.toMatch(/below the recommended threshold/); + expect(warnCalls).not.toMatch( + /default worker count has been reduced/ + ); + }); + + test('warns that the default was CPU-reduced on a small host', async () => { + const warnCalls = await getWarnCallsForWorkersArgs([], 4); + expect(warnCalls).toMatch( + /default worker count has been reduced to 3 because this machine has only 4 CPU\(s\)/ + ); + }); + + test('warns when the user explicitly sets --workers below 6', async () => { + const warnCalls = await getWarnCallsForWorkersArgs( + ['--workers=3'], + 8 + ); + expect(warnCalls).toMatch( + /Worker count \(3\) is below the recommended threshold \(6\)/ + ); + expect(warnCalls).not.toMatch( + /default worker count has been reduced/ + ); + }); + + test('warns when --workers=auto resolves below 6 on small hosts', async () => { + const warnCalls = await getWarnCallsForWorkersArgs( + ['--workers=auto'], + 4 + ); + expect(warnCalls).toMatch( + /Worker count \(3\) is below the recommended threshold \(6\)/ + ); + }); + + test('does not warn when --workers is set to 6 or above', async () => { + const warnCalls = await getWarnCallsForWorkersArgs( + ['--workers=6'], + 8 + ); + expect(warnCalls).not.toMatch(/below the recommended threshold/); + expect(warnCalls).not.toMatch( + /default worker count has been reduced/ + ); + }); + + test('--experimental-multi-worker warns and still starts', async () => { + const warnSpy = vi + .spyOn(logger, 'warn') + .mockImplementation(() => {}); + try { + const count = await getWorkerCount([ + '--experimental-multi-worker=4', + ]); + // Value is ignored; default applies. + expect(count).toBe(defaultExpected); + const warnCalls = warnSpy.mock.calls + .map((call) => call.join(' ')) + .join('\n'); + expect(warnCalls).toMatch(/--experimental-multi-worker/); + expect(warnCalls).toMatch(/--workers/); + } finally { + warnSpy.mockRestore(); + } + }); + }); + describe('port in use', () => { test('should error when explicit port is already in use', async () => { const stdoutMessages: string[] = []; @@ -1913,3 +2088,59 @@ describe('other run-cli behaviors', () => { }); }); }); + +describe('resolveWorkerCount', () => { + function withCpus(count: number, fn: () => T): T { + const stub = vi + .spyOn(os, 'cpus') + .mockReturnValue(new Array(count).fill({}) as os.CpuInfo[]); + try { + return fn(); + } finally { + stub.mockRestore(); + } + } + + test('default caps at 6 on large hosts', () => { + withCpus(16, () => { + expect(resolveWorkerCount(undefined)).toBe(6); + }); + }); + + test('default shrinks to cpus-1 on small hosts', () => { + withCpus(4, () => { + expect(resolveWorkerCount(undefined)).toBe(3); + }); + }); + + test('default is at least 1 on single-core hosts', () => { + withCpus(1, () => { + expect(resolveWorkerCount(undefined)).toBe(1); + }); + }); + + test('default is at least 1 when os.cpus() returns an empty array', () => { + withCpus(0, () => { + expect(resolveWorkerCount(undefined)).toBe(1); + }); + }); + + test('auto returns cpus-1 without the 6 cap', () => { + withCpus(16, () => { + expect(resolveWorkerCount('auto')).toBe(15); + }); + }); + + test('auto is at least 1 on single-core hosts', () => { + withCpus(1, () => { + expect(resolveWorkerCount('auto')).toBe(1); + }); + }); + + test('explicit number is honored verbatim', () => { + withCpus(2, () => { + expect(resolveWorkerCount(32)).toBe(32); + expect(resolveWorkerCount(1)).toBe(1); + }); + }); +});