From 9964612a86e95756d665d410870363ad08fde5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 9 Dec 2025 17:49:10 +0100 Subject: [PATCH 1/2] OS-based spawn handler in CLI worker --- .../cli/src/blueprints-v1/worker-thread-v1.ts | 202 ++++++++++++++++-- packages/playground/cli/src/run-cli.ts | 58 ++--- 2 files changed, 210 insertions(+), 50 deletions(-) diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index 5aecb031fc..96d2cb2ced 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -7,9 +7,8 @@ import { consumeAPI, consumeAPISync, exposeAPI, - sandboxedSpawnHandlerFactory, } from '@php-wasm/universal'; -import { sprintf } from '@php-wasm/util'; +import { createSpawnHandler, sprintf } from '@php-wasm/util'; import { RecommendedPHPVersion } from '@wp-playground/common'; import { type WordPressInstallMode, @@ -21,6 +20,8 @@ import { jspi } from 'wasm-feature-detect'; import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; import { mountResources } from '../mounts'; import { logger } from '@php-wasm/logger'; +import { spawn } from 'child_process'; +import { type SpawnedWorker, spawnWorkerThread } from '../run-cli'; export interface Mount { hostPath: string; @@ -126,23 +127,24 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { } } - async bootAndSetUpInitialWorker({ - siteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - phpVersion: php = RecommendedPHPVersion, - wordpressInstallMode, - wordPressZip, - sqliteIntegrationPluginZip, - firstProcessId, - processIdSpaceLength, - dataSqlPath, - followSymlinks, - trace, - internalCookieStore, - withXdebug, - nativeInternalDirPath, - }: PrimaryWorkerBootOptions) { + async bootAndSetUpInitialWorker(options: PrimaryWorkerBootOptions) { + const { + siteUrl, + mountsBeforeWpInstall, + mountsAfterWpInstall, + phpVersion: php = RecommendedPHPVersion, + wordpressInstallMode, + wordPressZip, + sqliteIntegrationPluginZip, + firstProcessId, + processIdSpaceLength, + dataSqlPath, + followSymlinks, + trace, + internalCookieStore, + withXdebug, + nativeInternalDirPath, + } = options; if (this.booted) { throw new Error('Playground already booted'); } @@ -192,7 +194,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { ? new File( [sqliteIntegrationPluginZip], 'sqlite-integration-plugin.zip' - ) + ) : undefined, sapiName: 'cli', createFiles: { @@ -207,7 +209,75 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { }, cookieStore: internalCookieStore ? undefined : false, dataSqlPath, - spawnHandler: sandboxedSpawnHandlerFactory, + spawnHandler: () => + createSpawnHandler(async (args, processApi, options) => { + console.log('primary worker', { args }); + processApi.notifySpawn(); + if (args[0] === 'exec') { + args.shift(); + } + + if ( + args[0].endsWith('.php') || + args[0].endsWith('.phar') + ) { + args.unshift('php'); + } + + const binaryName = args[0].split('/').pop(); + if (binaryName !== 'php') { + throw new Error( + `Unsupported binary: ${binaryName}. Only PHP is supported for now.` + ); + } + + const newPhpProcess = spawn(process.argv[0], args, { + ...options, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const subPhpPort = await new Promise( + (resolve, reject) => { + newPhpProcess.addListener( + 'message', + (message: any) => { + if ( + message.command === + 'worker-script-initialized' + ) { + resolve(message.phpPort); + } + } + ); + newPhpProcess.once('error', (e: Error) => { + reject( + new Error( + `Worker failed to initialize: ${e.message}` + ) + ); + }); + } + ); + + const handler = + consumeAPI( + subPhpPort + ); + handler.useFileLockManager(this.fileLockManager as any); + await handler.bootWorker({ + phpVersion: php, + siteUrl, + mountsBeforeWpInstall, + mountsAfterWpInstall, + firstProcessId, + processIdSpaceLength, + followSymlinks, + trace, + nativeInternalDirPath, + }); + + handler.cli(['php', '-v']); + console.log(await handler.hello()); + }), async onPHPInstanceCreated(php) { await mountResources(php, mountsBeforeWpInstall); if (wordpressBooted) { @@ -234,6 +304,10 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { } } + async hello() { + return 'hello'; + } + async bootWorker(args: WorkerBootOptions) { await this.bootRequestHandler(args); } @@ -291,7 +365,89 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { }, sapiName: 'cli', cookieStore: false, - spawnHandler: sandboxedSpawnHandlerFactory, + spawnHandler: () => + createSpawnHandler(async (args, processApi) => { + console.log('secondary worker', { args }); + if (args[0] === 'exec') { + args.shift(); + } + + if ( + args[0].endsWith('.php') || + args[0].endsWith('.phar') + ) { + args.unshift('php'); + } + + const binaryName = args[0].split('/').pop(); + if (binaryName !== 'php') { + throw new Error( + `Unsupported binary: ${binaryName}. Only PHP is supported for now.` + ); + } + + let cliCalled = false; + let spawnedWorker: SpawnedWorker | undefined = + undefined; + try { + spawnedWorker = await spawnWorkerThread('v1', { + onExit: () => { + if (cliCalled) { + // We're already handling the exit code using + // the cliResponse.exitCode promise. + return; + } + // The process died before we could call cli(). + // Let's exit with an error code. + processApi.exit(1); + }, + }); + } catch (e) { + processApi.exit(1); + throw e; + } + + const handler = + consumeAPI( + spawnedWorker.phpPort + ); + handler.useFileLockManager(this.fileLockManager as any); + await handler.bootWorker({ + phpVersion: phpVersion, + siteUrl, + mountsBeforeWpInstall, + mountsAfterWpInstall, + firstProcessId, + processIdSpaceLength, + followSymlinks, + trace, + nativeInternalDirPath, + }); + + processApi.notifySpawn(); + + const cliResponse = await handler.cli(args, { + env: process.env as Record, + }); + cliResponse.stdout.pipeTo( + new WritableStream({ + write(chunk) { + processApi.stdout(chunk); + }, + }) + ); + cliResponse.stderr.pipeTo( + new WritableStream({ + write(chunk) { + processApi.stderr(chunk); + }, + }) + ); + await cliResponse.exitCode.finally(async () => { + processApi.exit(await cliResponse.exitCode); + }); + cliCalled = true; + }), }); this.__internal_setRequestHandler(requestHandler); @@ -330,3 +486,5 @@ parentPort?.postMessage( }, [phpChannel.port2 as any] ); + +console.log('Worker script initialized!'); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index f1ffbb914b..a42a0f516a 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -1134,37 +1134,14 @@ async function spawnWorkerThreads( ): Promise { const promises = []; for (let i = 0; i < count; i++) { - const worker = await spawnWorkerThread(workerType); const onExit: (code: number) => void = (code: number) => { onWorkerExit({ exitCode: code, workerIndex: i, }); }; - promises.push( - new Promise<{ worker: Worker; phpPort: NodeMessagePort }>( - (resolve, reject) => { - worker.once('message', function (message: any) { - // Let the worker confirm it has initialized. - // We could use the 'online' event to detect start of JS execution, - // but that would miss initialization errors. - if (message.command === 'worker-script-initialized') { - resolve({ worker, phpPort: message.phpPort }); - } - }); - worker.once('error', function (e: Error) { - console.error(e); - const error = new Error( - `Worker failed to load worker. ${ - e.message ? `Original error: ${e.message}` : '' - }` - ); - reject(error); - }); - worker.once('exit', onExit); - } - ) - ); + const worker = spawnWorkerThread(workerType, { onExit }); + promises.push(worker); } return Promise.all(promises); } @@ -1180,7 +1157,10 @@ async function spawnWorkerThreads( * @param workerType * @returns */ -async function spawnWorkerThread(workerType: 'v1' | 'v2') { +export function spawnWorkerThread( + workerType: 'v1' | 'v2', + { onExit }: { onExit: (code: number) => void } +) { /** * When running the CLI from source via `node cli.ts`, the Vite-provided * __WORKER_V1_URL__ and __WORKER_V2_URL__ are undefined. Let's set them to @@ -1194,11 +1174,33 @@ async function spawnWorkerThread(workerType: 'v1' | 'v2') { // @ts-expect-error globalThis['__WORKER_V2_URL__'] = './blueprints-v2/worker-thread-v2.ts'; } + let worker: Worker; if (workerType === 'v1') { - return new Worker(new URL(__WORKER_V1_URL__, import.meta.url)); + worker = new Worker(new URL(__WORKER_V1_URL__, import.meta.url)); } else { - return new Worker(new URL(__WORKER_V2_URL__, import.meta.url)); + worker = new Worker(new URL(__WORKER_V2_URL__, import.meta.url)); } + + return new Promise((resolve, reject) => { + worker.once('message', function (message: any) { + // Let the worker confirm it has initialized. + // We could use the 'online' event to detect start of JS execution, + // but that would miss initialization errors. + if (message.command === 'worker-script-initialized') { + resolve({ worker, phpPort: message.phpPort }); + } + }); + worker.once('error', function (e: Error) { + console.error(e); + const error = new Error( + `Worker failed to load worker. ${ + e.message ? `Original error: ${e.message}` : '' + }` + ); + reject(error); + }); + worker.once('exit', onExit); + }); } /** From ad31298029467c2aa20655f56e43e7aef4083ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 9 Dec 2025 19:04:34 +0100 Subject: [PATCH 2/2] [CLI] When PHP is spawned in the spawn handler, create a new OS process --- .../php-wasm/node/src/test/php-part-1.spec.ts | 6 +- .../php-wasm/node/src/test/php-part-2.spec.ts | 6 +- packages/php-wasm/universal/src/lib/index.ts | 1 + .../lib/sandboxed-spawn-handler-factory.ts | 19 +- .../cli/src/blueprints-v1/worker-thread-v1.ts | 334 ++++++------------ packages/playground/cli/src/run-cli.ts | 13 +- packages/playground/wordpress/src/boot.ts | 16 +- 7 files changed, 152 insertions(+), 243 deletions(-) diff --git a/packages/php-wasm/node/src/test/php-part-1.spec.ts b/packages/php-wasm/node/src/test/php-part-1.spec.ts index 12befc4af9..b390abfcf8 100644 --- a/packages/php-wasm/node/src/test/php-part-1.spec.ts +++ b/packages/php-wasm/node/src/test/php-part-1.spec.ts @@ -1850,7 +1850,11 @@ describe('sandboxedSpawnHandlerFactory', () => { 'Hello, world!' ); await php.setSpawnHandler( - sandboxedSpawnHandlerFactory(processManager) + sandboxedSpawnHandlerFactory(() => + processManager.acquirePHPInstance({ + considerPrimary: false, + }) + ) ); return php; }, diff --git a/packages/php-wasm/node/src/test/php-part-2.spec.ts b/packages/php-wasm/node/src/test/php-part-2.spec.ts index b94f35cd16..bb24e7e121 100644 --- a/packages/php-wasm/node/src/test/php-part-2.spec.ts +++ b/packages/php-wasm/node/src/test/php-part-2.spec.ts @@ -1180,7 +1180,11 @@ describe('sandboxedSpawnHandlerFactory', () => { 'Hello, world!' ); await php.setSpawnHandler( - sandboxedSpawnHandlerFactory(processManager) + sandboxedSpawnHandlerFactory(() => + processManager.acquirePHPInstance({ + considerPrimary: false, + }) + ) ); return php; }, diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index fe0b27ff8d..1d37928d36 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -88,3 +88,4 @@ export { sandboxedSpawnHandlerFactory } from './sandboxed-spawn-handler-factory' export * from './api'; export type { WithAPIState as WithIsReady } from './api'; +export type { Remote } from './comlink-sync'; diff --git a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts index de28dcab5e..06eb68154b 100644 --- a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts +++ b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts @@ -1,5 +1,7 @@ import { createSpawnHandler } from '@php-wasm/util'; -import type { PHPProcessManager } from './php-process-manager'; +import type { PHP } from './php'; +import type { PHPWorker } from './php-worker'; +import type { Remote } from './comlink-sync'; /** * An isomorphic proc_open() handler that implements typical shell in TypeScript @@ -12,7 +14,10 @@ import type { PHPProcessManager } from './php-process-manager'; * parser. */ export function sandboxedSpawnHandlerFactory( - processManager: PHPProcessManager + getPHPInstance: () => Promise<{ + php: PHP | Remote; + reap: () => void; + }> ) { return createSpawnHandler(async function (args, processApi, options) { processApi.notifySpawn(); @@ -63,16 +68,14 @@ export function sandboxedSpawnHandlerFactory( return; } - const { php, reap } = await processManager.acquirePHPInstance({ - considerPrimary: false, - }); + const { php, reap } = await getPHPInstance(); try { if (options.cwd) { - php.chdir(options.cwd as string); + await php.chdir(options.cwd as string); } - const cwd = php.cwd(); + const cwd = await php.cwd(); switch (binaryName) { case 'php': { // Figure out more about setting env, putenv(), etc. @@ -105,7 +108,7 @@ export function sandboxedSpawnHandlerFactory( break; } case 'ls': { - const files = php.listFiles(args[1] ?? cwd); + const files = await php.listFiles(args[1] ?? cwd); for (const file of files) { processApi.stdout(file + '\n'); } diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index 96d2cb2ced..1ec156dee7 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -7,8 +7,9 @@ import { consumeAPI, consumeAPISync, exposeAPI, + sandboxedSpawnHandlerFactory, } from '@php-wasm/universal'; -import { createSpawnHandler, sprintf } from '@php-wasm/util'; +import { sprintf } from '@php-wasm/util'; import { RecommendedPHPVersion } from '@wp-playground/common'; import { type WordPressInstallMode, @@ -20,8 +21,7 @@ import { jspi } from 'wasm-feature-detect'; import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; import { mountResources } from '../mounts'; import { logger } from '@php-wasm/logger'; -import { spawn } from 'child_process'; -import { type SpawnedWorker, spawnWorkerThread } from '../run-cli'; +import { spawnWorkerThread } from '../run-cli'; export interface Mount { hostPath: string; @@ -132,27 +132,17 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { siteUrl, mountsBeforeWpInstall, mountsAfterWpInstall, - phpVersion: php = RecommendedPHPVersion, wordpressInstallMode, wordPressZip, sqliteIntegrationPluginZip, - firstProcessId, - processIdSpaceLength, dataSqlPath, - followSymlinks, - trace, internalCookieStore, - withXdebug, - nativeInternalDirPath, } = options; if (this.booted) { throw new Error('Playground already booted'); } this.booted = true; - let nextProcessId = firstProcessId; - const lastProcessId = firstProcessId + processIdSpaceLength - 1; - try { const constants: Record = { @@ -163,27 +153,10 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { let wordpressBooted = false; const requestHandler = await bootWordPressAndRequestHandler({ siteUrl, - createPhpRuntime: async () => { - const processId = nextProcessId; - - if (nextProcessId < lastProcessId) { - nextProcessId++; - } else { - // We've reached the end of the process ID space. Start over. - nextProcessId = firstProcessId; - } - - return await loadNodeRuntime(php, { - emscriptenOptions: { - fileLockManager: this.fileLockManager!, - processId, - trace: trace ? tracePhpWasm : undefined, - phpWasmInitOptions: { nativeInternalDirPath }, - }, - followSymlinks, - withXdebug, - }); - }, + createPhpRuntime: createPhpRuntimeFactory( + options, + this.fileLockManager! + ), wordpressInstallMode, wordPressZip: wordPressZip !== undefined @@ -210,74 +183,9 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { cookieStore: internalCookieStore ? undefined : false, dataSqlPath, spawnHandler: () => - createSpawnHandler(async (args, processApi, options) => { - console.log('primary worker', { args }); - processApi.notifySpawn(); - if (args[0] === 'exec') { - args.shift(); - } - - if ( - args[0].endsWith('.php') || - args[0].endsWith('.phar') - ) { - args.unshift('php'); - } - - const binaryName = args[0].split('/').pop(); - if (binaryName !== 'php') { - throw new Error( - `Unsupported binary: ${binaryName}. Only PHP is supported for now.` - ); - } - - const newPhpProcess = spawn(process.argv[0], args, { - ...options, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const subPhpPort = await new Promise( - (resolve, reject) => { - newPhpProcess.addListener( - 'message', - (message: any) => { - if ( - message.command === - 'worker-script-initialized' - ) { - resolve(message.phpPort); - } - } - ); - newPhpProcess.once('error', (e: Error) => { - reject( - new Error( - `Worker failed to initialize: ${e.message}` - ) - ); - }); - } - ); - - const handler = - consumeAPI( - subPhpPort - ); - handler.useFileLockManager(this.fileLockManager as any); - await handler.bootWorker({ - phpVersion: php, - siteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - firstProcessId, - processIdSpaceLength, - followSymlinks, - trace, - nativeInternalDirPath, - }); - - handler.cli(['php', '-v']); - console.log(await handler.hello()); - }), + sandboxedSpawnHandlerFactory(() => + createPHPWorker(options, this.fileLockManager!) + ), async onPHPInstanceCreated(php) { await mountResources(php, mountsBeforeWpInstall); if (wordpressBooted) { @@ -312,142 +220,29 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { await this.bootRequestHandler(args); } - async bootRequestHandler({ - siteUrl, - followSymlinks, - phpVersion, - firstProcessId, - processIdSpaceLength, - trace, - nativeInternalDirPath, - mountsBeforeWpInstall, - mountsAfterWpInstall, - withXdebug, - }: WorkerBootRequestHandlerOptions) { + async bootRequestHandler(options: WorkerBootRequestHandlerOptions) { if (this.booted) { throw new Error('Playground already booted'); } this.booted = true; - let nextProcessId = firstProcessId; - const lastProcessId = firstProcessId + processIdSpaceLength - 1; - try { const requestHandler = await bootRequestHandler({ - siteUrl, - createPhpRuntime: async () => { - const processId = nextProcessId; - - if (nextProcessId < lastProcessId) { - nextProcessId++; - } else { - // We've reached the end of the process ID space. Start over. - nextProcessId = firstProcessId; - } - - return await loadNodeRuntime(phpVersion, { - emscriptenOptions: { - fileLockManager: this.fileLockManager!, - processId, - trace: trace ? tracePhpWasm : undefined, - ENV: { - DOCROOT: '/wordpress', - }, - phpWasmInitOptions: { nativeInternalDirPath }, - }, - followSymlinks, - withXdebug, - }); - }, + siteUrl: options.siteUrl, + createPhpRuntime: createPhpRuntimeFactory( + options, + this.fileLockManager! + ), onPHPInstanceCreated: async (php) => { - await mountResources(php, mountsBeforeWpInstall); - await mountResources(php, mountsAfterWpInstall); + await mountResources(php, options.mountsBeforeWpInstall); + await mountResources(php, options.mountsAfterWpInstall); }, sapiName: 'cli', cookieStore: false, spawnHandler: () => - createSpawnHandler(async (args, processApi) => { - console.log('secondary worker', { args }); - if (args[0] === 'exec') { - args.shift(); - } - - if ( - args[0].endsWith('.php') || - args[0].endsWith('.phar') - ) { - args.unshift('php'); - } - - const binaryName = args[0].split('/').pop(); - if (binaryName !== 'php') { - throw new Error( - `Unsupported binary: ${binaryName}. Only PHP is supported for now.` - ); - } - - let cliCalled = false; - let spawnedWorker: SpawnedWorker | undefined = - undefined; - try { - spawnedWorker = await spawnWorkerThread('v1', { - onExit: () => { - if (cliCalled) { - // We're already handling the exit code using - // the cliResponse.exitCode promise. - return; - } - // The process died before we could call cli(). - // Let's exit with an error code. - processApi.exit(1); - }, - }); - } catch (e) { - processApi.exit(1); - throw e; - } - - const handler = - consumeAPI( - spawnedWorker.phpPort - ); - handler.useFileLockManager(this.fileLockManager as any); - await handler.bootWorker({ - phpVersion: phpVersion, - siteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - firstProcessId, - processIdSpaceLength, - followSymlinks, - trace, - nativeInternalDirPath, - }); - - processApi.notifySpawn(); - - const cliResponse = await handler.cli(args, { - env: process.env as Record, - }); - cliResponse.stdout.pipeTo( - new WritableStream({ - write(chunk) { - processApi.stdout(chunk); - }, - }) - ); - cliResponse.stderr.pipeTo( - new WritableStream({ - write(chunk) { - processApi.stderr(chunk); - }, - }) - ); - await cliResponse.exitCode.finally(async () => { - processApi.exit(await cliResponse.exitCode); - }); - cliCalled = true; - }), + sandboxedSpawnHandlerFactory(() => + createPHPWorker(options, this.fileLockManager!) + ), }); this.__internal_setRequestHandler(requestHandler); @@ -467,6 +262,91 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { } } +/** + * Returns a factory function that starts a new PHP runtime in the currently + * running process. This is used for rotating the PHP runtime periodically. + */ +function createPhpRuntimeFactory( + options: WorkerBootRequestHandlerOptions, + fileLockManager: FileLockManager | RemoteAPI +) { + let nextProcessId = options.firstProcessId; + const lastProcessId = + options.firstProcessId + options.processIdSpaceLength - 1; + return async () => { + const processId = nextProcessId; + + if (nextProcessId < lastProcessId) { + nextProcessId++; + } else { + // We've reached the end of the process ID space. Start over. + nextProcessId = options.firstProcessId; + } + + return await loadNodeRuntime( + options.phpVersion || RecommendedPHPVersion, + { + emscriptenOptions: { + fileLockManager, + processId, + trace: options.trace ? tracePhpWasm : undefined, + phpWasmInitOptions: { + nativeInternalDirPath: options.nativeInternalDirPath, + }, + }, + followSymlinks: options.followSymlinks, + withXdebug: options.withXdebug, + } + ); + }; +} + +/** + * Spawns a new PHP process to be used in the PHP spawn handler (in proc_open() etc. calls). + * It boots from this worker-thread-v1.ts file, but is a separate process. + * + * We explicitly avoid using PHPProcessManager.acquirePHPInstance() here. + * + * Why? + * + * Because each PHP instance acquires actual OS-level file locks via fcntl() and LockFileEx() + * syscalls. Running multiple PHP instances from the same OS process would allow them to + * acquire overlapping locks. Running every PHP instance in a separate OS process ensures + * any locks that overlap between PHP instances conflict with each other as expected. + * + * @param options - The options for the worker. + * @param fileLockManager - The file lock manager to use. + * @returns A promise that resolves to the PHP worker. + */ +async function createPHPWorker( + options: WorkerBootRequestHandlerOptions, + fileLockManager: FileLockManager | RemoteAPI +) { + const spawnedWorker = await spawnWorkerThread('v1'); + + const handler = consumeAPI( + spawnedWorker.phpPort + ); + handler.useFileLockManager(fileLockManager as any); + await handler.bootWorker(options); + + return { + php: handler, + reap: () => { + try { + handler.dispose(); + } catch { + /** */ + } + try { + spawnedWorker.worker.terminate(); + } catch { + /** */ + } + }, + }; +} + process.on('unhandledRejection', (e: any) => { logger.error('Unhandled rejection:', e); }); @@ -486,5 +366,3 @@ parentPort?.postMessage( }, [phpChannel.port2 as any] ); - -console.log('Worker script initialized!'); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index a42a0f516a..876c32ae03 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -1159,7 +1159,7 @@ async function spawnWorkerThreads( */ export function spawnWorkerThread( workerType: 'v1' | 'v2', - { onExit }: { onExit: (code: number) => void } + { onExit }: { onExit?: (code: number) => void } = {} ) { /** * When running the CLI from source via `node cli.ts`, the Vite-provided @@ -1199,7 +1199,16 @@ export function spawnWorkerThread( ); reject(error); }); - worker.once('exit', onExit); + let spawned = false; + worker.once('spawn', () => { + spawned = true; + }); + worker.once('exit', (code) => { + if (!spawned) { + reject(new Error(`Worker exited before spawning: ${code}`)); + } + onExit?.(code); + }); }); } diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index ecd23f8560..e7ec57aae3 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -3,8 +3,9 @@ import type { FileNotFoundAction, FileNotFoundGetActionCallback, FileTree, - PHPProcessManager, + PHPWorker, SpawnHandler, + Remote, } from '@php-wasm/universal'; import { PHP, @@ -62,7 +63,12 @@ export interface BootRequestHandlerOptions { */ siteUrl: string; documentRoot?: string; - spawnHandler?: (processManager: PHPProcessManager) => SpawnHandler; + spawnHandler?: ( + getPHPInstance: () => Promise<{ + php: PHP | Remote; + reap: () => void; + }> + ) => SpawnHandler; /** * PHP.ini entries to define before running any code. They'll * be used for all requests. @@ -415,7 +421,11 @@ export async function bootRequestHandler(options: BootRequestHandlerOptions) { // `popen()`, `proc_open()` etc. calls. if (spawnHandler) { await php.setSpawnHandler( - spawnHandler(requestHandler.processManager) + spawnHandler(() => + requestHandler.processManager.acquirePHPInstance({ + considerPrimary: false, + }) + ) ); }