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 89cdfd13b3..4b28260ce7 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -21,6 +21,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 { spawnWorkerThread } from '../run-cli'; + import type { Mount } from '@php-wasm/cli-util'; export type WorkerBootOptions = { @@ -122,31 +124,22 @@ 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, + wordpressInstallMode, + wordPressZip, + sqliteIntegrationPluginZip, + dataSqlPath, + internalCookieStore, + } = 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 = { @@ -157,27 +150,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 @@ -203,7 +179,10 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { }, cookieStore: internalCookieStore ? undefined : false, dataSqlPath, - spawnHandler: sandboxedSpawnHandlerFactory, + spawnHandler: () => + sandboxedSpawnHandlerFactory(() => + createPHPWorker(options, this.fileLockManager!) + ), async onPHPInstanceCreated(php) { await mountResources(php, mountsBeforeWpInstall); if (wordpressBooted) { @@ -230,64 +209,37 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { } } + async hello() { + return 'hello'; + } + async bootWorker(args: WorkerBootOptions) { 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: sandboxedSpawnHandlerFactory, + spawnHandler: () => + sandboxedSpawnHandlerFactory(() => + createPHPWorker(options, this.fileLockManager!) + ), }); this.__internal_setRequestHandler(requestHandler); @@ -307,6 +259,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); }); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index d77f6cf7aa..6360706307 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -1142,37 +1142,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); } @@ -1188,7 +1165,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 @@ -1202,11 +1182,42 @@ 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); + }); + 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, + }) + ) ); }