diff --git a/packages/php-wasm/fs-journal/src/lib/fs-journal.ts b/packages/php-wasm/fs-journal/src/lib/fs-journal.ts index 276201c871..dddfc9bb67 100644 --- a/packages/php-wasm/fs-journal/src/lib/fs-journal.ts +++ b/packages/php-wasm/fs-journal/src/lib/fs-journal.ts @@ -116,61 +116,77 @@ export function journalFSEvents( fsRoot: string, onEntry: (entry: FilesystemOperation) => void = () => {} ) { - fsRoot = normalizePath(fsRoot); - const FS = php[__private__dont__use].FS; - const FSHooks = createFSHooks(FS, (entry: FilesystemOperation) => { - // Only journal entries inside the specified root directory. - if (entry.path.startsWith(fsRoot)) { - onEntry(entry); - } else if ( - entry.operation === 'RENAME' && - entry.toPath.startsWith(fsRoot) - ) { - for (const op of recordExistingPath( - php, - entry.path, - entry.toPath - )) { - onEntry(op); + function bindToCurrentRuntime() { + fsRoot = normalizePath(fsRoot); + const FS = php[__private__dont__use].FS; + const FSHooks = createFSHooks(FS, (entry: FilesystemOperation) => { + // Only journal entries inside the specified root directory. + if (entry.path.startsWith(fsRoot)) { + onEntry(entry); + } else if ( + entry.operation === 'RENAME' && + entry.toPath.startsWith(fsRoot) + ) { + for (const op of recordExistingPath( + php, + entry.path, + entry.toPath + )) { + onEntry(op); + } } - } - }); + }); - /** - * Override the original FS functions with ones running the hooks. - * We could use a Proxy object here if the Emscripten JavaScript module - * did not use hard-coded references to the FS object. - */ - const originalFunctions: Record = {}; - for (const [name] of Object.entries(FSHooks)) { - originalFunctions[name] = FS[name]; - } + /** + * Override the original FS functions with ones running the hooks. + * We could use a Proxy object here if the Emscripten JavaScript module + * did not use hard-coded references to the FS object. + */ + const originalFunctions: Record = {}; + for (const [name] of Object.entries(FSHooks)) { + originalFunctions[name] = FS[name]; + } - // eslint-disable-next-line no-inner-declarations - function bind() { - for (const [name, hook] of Object.entries(FSHooks)) { - FS[name] = function (...args: any[]) { - // @ts-ignore - hook(...args); - return originalFunctions[name].apply(this, args); - }; + // eslint-disable-next-line no-inner-declarations + function bind() { + for (const [name, hook] of Object.entries(FSHooks)) { + FS[name] = function (...args: any[]) { + // @ts-ignore + hook(...args); + return originalFunctions[name].apply(this, args); + }; + } } - } - // eslint-disable-next-line no-inner-declarations - function unbind() { - // Restore the original FS functions. - for (const [name, fn] of Object.entries(originalFunctions)) { - php[__private__dont__use].FS[name] = fn; + // eslint-disable-next-line no-inner-declarations + function unbind() { + // Restore the original FS functions. + for (const [name, fn] of Object.entries(originalFunctions)) { + php[__private__dont__use].FS[name] = fn; + } } + + php[__private__dont__use].journal = { + bind, + unbind, + }; + bind(); + } + php.addEventListener('runtime.initialized', bindToCurrentRuntime); + if (php[__private__dont__use]) { + bindToCurrentRuntime(); } - php[__private__dont__use].journal = { - bind, - unbind, - }; + function unbindFromOldRuntime() { + php[__private__dont__use].journal.unbind(); + delete php[__private__dont__use].journal; + } + php.addEventListener('runtime.beforedestroy', unbindFromOldRuntime); - bind(); - return unbind; + return function unbind() { + php.removeEventListener('runtime.initialized', bindToCurrentRuntime); + php.removeEventListener('runtime.beforedestroy', unbindFromOldRuntime); + return php[__private__dont__use].journal.unbind(); + }; } const createFSHooks = ( @@ -261,7 +277,7 @@ const createFSHooks = ( */ export function replayFSJournal(php: BasePHP, entries: FilesystemOperation[]) { // We need to restore the original functions to the FS object - // before proceeding, or each replayer FS operation will be journaled. + // before proceeding, or each replayed FS operation will be journaled. // // Unfortunately we can't just call the non-journaling versions directly, // because they call other low-level FS functions like `FS.mkdir()` diff --git a/packages/php-wasm/node/src/lib/node-php.ts b/packages/php-wasm/node/src/lib/node-php.ts index ebf82a1915..73a840b9c3 100644 --- a/packages/php-wasm/node/src/lib/node-php.ts +++ b/packages/php-wasm/node/src/lib/node-php.ts @@ -7,7 +7,6 @@ import { rethrowFileSystemError, __private__dont__use, isExitCodeZero, - DataModule, } from '@php-wasm/universal'; import { lstatSync, readdirSync } from 'node:fs'; @@ -17,7 +16,6 @@ import { withNetworking } from './networking/with-networking.js'; export interface PHPLoaderOptions { emscriptenOptions?: EmscriptenOptions; requestHandler?: PHPRequestHandlerConfiguration; - dataModules?: Array>; } export type MountSettings = { @@ -44,20 +42,10 @@ export class NodePHP extends BasePHP { phpVersion: SupportedPHPVersion, options: PHPLoaderOptions = {} ) { - return await NodePHP.loadSync(phpVersion, { - ...options, - emscriptenOptions: { - /** - * Emscripten default behavior is to kill the process when - * the WASM program calls `exit()`. We want to throw an - * exception instead. - */ - quit: function (code, error) { - throw error; - }, - ...(options.emscriptenOptions || {}), - }, - }).phpReady; + return new NodePHP( + await NodePHP.loadRuntime(phpVersion, options), + options.requestHandler + ); } /** @@ -67,29 +55,25 @@ export class NodePHP extends BasePHP { * * @see load */ - static loadSync( + static async loadRuntime( phpVersion: SupportedPHPVersion, options: PHPLoaderOptions = {} ) { - /** - * Keep any changes to the signature of this method in sync with the - * `PHP.load` method in the @php-wasm/node package. - */ - const php = new NodePHP(undefined, options.requestHandler); - - const doLoad = async () => { - const runtimeId = await loadPHPRuntime( - await getPHPLoaderModule(phpVersion), - await withNetworking(options.emscriptenOptions || {}) - ); - php.initializeRuntime(runtimeId); - }; - const asyncData = doLoad(); - - return { - php, - phpReady: asyncData.then(() => php), + const emscriptenOptions: EmscriptenOptions = { + /** + * Emscripten default behavior is to kill the process when + * the WASM program calls `exit()`. We want to throw an + * exception instead. + */ + quit: function (code, error) { + throw error; + }, + ...(options.emscriptenOptions || {}), }; + return await loadPHPRuntime( + await getPHPLoaderModule(phpVersion), + await withNetworking(emscriptenOptions) + ); } /** diff --git a/packages/php-wasm/node/src/test/rotate-php-runtime.spec.ts b/packages/php-wasm/node/src/test/rotate-php-runtime.spec.ts new file mode 100644 index 0000000000..e3b2dd21e8 --- /dev/null +++ b/packages/php-wasm/node/src/test/rotate-php-runtime.spec.ts @@ -0,0 +1,207 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { NodePHP } from '..'; +import { + LatestSupportedPHPVersion, + __private__dont__use, + rotatePHPRuntime, +} from '@php-wasm/universal'; + +const recreateRuntime = async (version: any = LatestSupportedPHPVersion) => + await NodePHP.loadRuntime(version); + +describe('rotatePHPRuntime()', () => { + it('Free up the available PHP memory', async () => { + const freeMemory = (php: NodePHP) => + php[__private__dont__use].HEAPU32.reduce( + (count: number, byte: number) => + byte === 0 ? count + 1 : count, + 0 + ); + + const recreateRuntimeSpy = vitest.fn(recreateRuntime); + // Rotate the PHP runtime + const php = new NodePHP(await recreateRuntime(), { + documentRoot: '/test-root', + }); + rotatePHPRuntime({ + php, + recreateRuntime: recreateRuntimeSpy, + maxRequests: 1000, + }); + const freeInitially = freeMemory(php); + for (let i = 0; i < 1000; i++) { + await php.run({ + code: ` { + const recreateRuntimeSpy = vitest.fn(recreateRuntime); + const php = new NodePHP(await recreateRuntimeSpy(), { + documentRoot: '/test-root', + }); + rotatePHPRuntime({ + php, + recreateRuntime: recreateRuntimeSpy, + maxRequests: 1, + }); + // Rotate the PHP runtime + await php.run({ code: `` }); + expect(recreateRuntimeSpy).toHaveBeenCalledTimes(2); + }, 30_000); + + it('Should stop rotating after the cleanup handler is called', async () => { + const recreateRuntimeSpy = vitest.fn(recreateRuntime); + const php = new NodePHP(await recreateRuntimeSpy(), { + documentRoot: '/test-root', + }); + const cleanup = rotatePHPRuntime({ + php, + recreateRuntime: recreateRuntimeSpy, + maxRequests: 1, + }); + // Rotate the PHP runtime + await php.run({ code: `` }); + expect(recreateRuntimeSpy).toHaveBeenCalledTimes(2); + + cleanup(); + + // No further rotation should happen + await php.run({ code: `` }); + await php.run({ code: `` }); + + expect(recreateRuntimeSpy).toHaveBeenCalledTimes(2); + }, 30_000); + + it('Should hotswap the PHP runtime from 8.2 to 8.3', async () => { + let nbCalls = 0; + const recreateRuntimeSpy = vitest.fn(() => { + if (nbCalls === 0) { + ++nbCalls; + return recreateRuntime('8.2'); + } + return recreateRuntime('8.3'); + }); + const php = new NodePHP(await recreateRuntimeSpy(), { + documentRoot: '/test-root', + }); + rotatePHPRuntime({ + php, + recreateRuntime: recreateRuntimeSpy, + maxRequests: 1, + }); + const version1 = ( + await php.run({ + code: ` { + const php = new NodePHP(await recreateRuntime(), { + documentRoot: '/test-root', + }); + rotatePHPRuntime({ + php, + recreateRuntime, + maxRequests: 1, + }); + php.setSapiName('custom SAPI'); + + // Rotate the PHP runtime + await php.run({ code: `` }); + const result = await php.run({ + code: ` { + const php = new NodePHP(await recreateRuntime(), { + documentRoot: '/test-root', + }); + rotatePHPRuntime({ + php, + recreateRuntime, + maxRequests: 1, + }); + + // Rotate the PHP runtime + await php.run({ code: `` }); + + php.mkdir('/test-root'); + php.writeFile('/test-root/index.php', ' { + const php = new NodePHP(await recreateRuntime(), { + documentRoot: '/test-root', + }); + rotatePHPRuntime({ + php, + recreateRuntime, + maxRequests: 1, + }); + + // Rotate the PHP runtime + await php.run({ code: `` }); + + php.mkdir('/test-root'); + php.writeFile('/test-root/index.php', 'test'); + php.mkdir('/test-root/nodefs'); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-')); + const tempFile = path.join(tempDir, 'file'); + fs.writeFileSync(tempFile, 'playground'); + const date = new Date(); + date.setFullYear(date.getFullYear() - 1); + fs.utimesSync(tempFile, date, date); + try { + php.mount(tempDir, '/test-root/nodefs'); + + // Rotate the PHP runtime + await php.run({ code: `` }); + + // Expect the file to still have the same utime + const stats = fs.statSync(tempFile); + expect(Math.round(stats.atimeMs)).toBe(Math.round(date.getTime())); + + // The MEMFS file should still be there + expect(php.fileExists('/test-root/index.php')).toBe(true); + } finally { + fs.rmSync(tempFile); + fs.rmdirSync(tempDir); + } + }, 30_000); +}); diff --git a/packages/php-wasm/universal/src/lib/base-php.ts b/packages/php-wasm/universal/src/lib/base-php.ts index 4cbce8c855..26e7b35ce4 100644 --- a/packages/php-wasm/universal/src/lib/base-php.ts +++ b/packages/php-wasm/universal/src/lib/base-php.ts @@ -28,7 +28,7 @@ import { improveWASMErrorReporting, UnhandledRejectionsTarget, } from './wasm-error-reporting'; -import { Semaphore, createSpawnHandler } from '@php-wasm/util'; +import { Semaphore, createSpawnHandler, joinPaths } from '@php-wasm/util'; const STRING = 'string'; const NUMBER = 'number'; @@ -45,13 +45,20 @@ export const __private__dont__use = Symbol('__private__dont__use'); export abstract class BasePHP implements IsomorphicLocalPHP { protected [__private__dont__use]: any; #phpIniOverrides: [string, string][] = []; + #phpIniPath?: string; + #sapiName?: string; #webSapiInitialized = false; #wasmErrorsTarget: UnhandledRejectionsTarget | null = null; #serverEntries: Record = {}; #eventListeners: Map> = new Map(); #messageListeners: MessageListener[] = []; requestHandler?: PHPBrowser; - #semaphore: Semaphore; + + /** + * An exclusive lock that prevent multiple requests from running at + * the same time. + */ + semaphore: Semaphore; /** * Initializes a PHP runtime. @@ -64,7 +71,7 @@ export abstract class BasePHP implements IsomorphicLocalPHP { PHPRuntimeId?: PHPRuntimeId, serverOptions?: PHPRequestHandlerConfiguration ) { - this.#semaphore = new Semaphore({ concurrency: 1 }); + this.semaphore = new Semaphore({ concurrency: 1 }); if (PHPRuntimeId !== undefined) { this.initializeRuntime(PHPRuntimeId); } @@ -168,6 +175,9 @@ export abstract class BasePHP implements IsomorphicLocalPHP { }; this.#wasmErrorsTarget = improveWASMErrorReporting(runtime); + this.dispatchEvent({ + type: 'runtime.initialized', + }); } /** @inheritDoc */ @@ -184,6 +194,7 @@ export abstract class BasePHP implements IsomorphicLocalPHP { 'Did you already dispatch any requests?' ); } + this.#sapiName = newName; } /** @inheritDoc */ @@ -191,6 +202,7 @@ export abstract class BasePHP implements IsomorphicLocalPHP { if (this.#webSapiInitialized) { throw new Error('Cannot set PHP ini path after calling run().'); } + this.#phpIniPath = path; this[__private__dont__use].ccall( 'wasm_set_phpini_path', null, @@ -231,7 +243,7 @@ export abstract class BasePHP implements IsomorphicLocalPHP { * requests another PHP file, the second request may * be dispatched before the first one is finished. */ - const release = await this.#semaphore.acquire(); + const release = await this.semaphore.acquire(); try { if (!this.#webSapiInitialized) { this.#initWebRuntime(); @@ -735,8 +747,67 @@ export abstract class BasePHP implements IsomorphicLocalPHP { } } + /** + * Hot-swaps the PHP runtime for a new one without + * interrupting the operations of this PHP instance. + * + * @param runtime + */ + hotSwapPHPRuntime(runtime: number) { + // Once we secure the lock and have the new runtime ready, + // the rest of the swap handler is synchronous to make sure + // no other operations acts on the old runtime or FS. + // If there was await anywhere here, we'd risk applyng + // asynchronous changes to either the filesystem or the + // old PHP runtime without propagating them to the new + // runtime. + const oldFS = this[__private__dont__use].FS; + + // Kill the current runtime + try { + this.exit(); + } catch (e) { + // Ignore the exit-related exception + } + + // Initialize the new runtime + this.initializeRuntime(runtime); + + // Re-apply any set() methods that are not + // request related and result in a one-off + // C function call. + if (this.#phpIniPath) { + this.setPhpIniPath(this.#phpIniPath); + } + + if (this.#sapiName) { + this.setSapiName(this.#sapiName); + } + + // Copy the MEMFS directory structure from the old FS to the new one + if (this.requestHandler) { + const docroot = this.documentRoot; + recreateMemFS(this[__private__dont__use].FS, oldFS, docroot); + } + } + exit(code = 0) { - return this[__private__dont__use]._exit(code); + this.dispatchEvent({ + type: 'runtime.beforedestroy', + }); + try { + this[__private__dont__use]._exit(code); + } catch (e) { + // ignore the exit error + } + + // Clean up any initialized state + this.#webSapiInitialized = false; + + // Delete any links between this PHP instance and the runtime + this.#wasmErrorsTarget = null; + delete this[__private__dont__use]['onMessage']; + delete this[__private__dont__use]; } } @@ -749,3 +820,46 @@ export function normalizeHeaders( } return normalized; } + +type EmscriptenFS = any; + +/** + * Copies the MEMFS directory structure from one FS in another FS. + * Non-MEMFS nodes are ignored. + */ +function recreateMemFS(newFS: EmscriptenFS, oldFS: EmscriptenFS, path: string) { + let oldNode; + try { + oldNode = oldFS.lookupPath(path); + } catch (e) { + return; + } + // MEMFS nodes have a `contents` property. NODEFS nodes don't. + // We only want to copy MEMFS nodes here. + if (!('contents' in oldNode.node)) { + return; + } + + // Let's be extra careful and only proceed if newFs doesn't + // already have a node at the given path. + try { + newFS = newFS.lookupPath(path); + return; + } catch (e) { + // There's no such node in the new FS. Good, + // we may proceed. + } + + if (!oldFS.isDir(oldNode.node.mode)) { + newFS.writeFile(path, oldFS.readFile(path)); + return; + } + + newFS.mkdirTree(path); + const filenames = oldFS + .readdir(path) + .filter((name: string) => name !== '.' && name !== '..'); + for (const filename of filenames) { + recreateMemFS(newFS, oldFS, joinPaths(path, filename)); + } +} diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index 63c5fc6d1a..10cad6d1b8 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -59,6 +59,7 @@ export type { PHPRequestHandlerConfiguration } from './php-request-handler'; export { PHPRequestHandler } from './php-request-handler'; export type { PHPBrowserConfiguration } from './php-browser'; export { PHPBrowser } from './php-browser'; +export { rotatePHPRuntime } from './rotate-php-runtime'; export { DEFAULT_BASE_URL, diff --git a/packages/php-wasm/universal/src/lib/load-php-runtime.ts b/packages/php-wasm/universal/src/lib/load-php-runtime.ts index bc8953e5db..4ba3e48193 100644 --- a/packages/php-wasm/universal/src/lib/load-php-runtime.ts +++ b/packages/php-wasm/universal/src/lib/load-php-runtime.ts @@ -1,5 +1,6 @@ const RuntimeId = Symbol('RuntimeId'); const loadedRuntimes: Map = new Map(); +let lastRuntimeId = 0; /** * Loads the PHP runtime with the given arguments and data dependencies. @@ -148,8 +149,9 @@ export async function loadPHPRuntime( await phpReady; - const id = loadedRuntimes.size; + const id = ++lastRuntimeId; + PHPRuntime.id = id; PHPRuntime.originalExit = PHPRuntime._exit; PHPRuntime._exit = function (code: number) { diff --git a/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts b/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts new file mode 100644 index 0000000000..17542ba824 --- /dev/null +++ b/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts @@ -0,0 +1,48 @@ +import { BasePHP } from './base-php'; + +export interface RotateOptions { + php: T; + recreateRuntime: () => Promise | number; + maxRequests: number; +} + +/** + * Listens to PHP events and swaps the internal PHP Runtime for a fresh one + * after a certain number of run() calls (which are responsible for handling + * HTTP requests). + * + * Why? Because PHP and PHP extension have a memory leak. Each request leaves + * the memory a bit more fragmented and with a bit less available space than + * before. Eventually, new allocations start failing. + * + * Rotating the PHP instance may seem like a workaround, but it's actually + * what PHP-FPM does natively: + * + * https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-tasks + * + * @return cleanup function to restore + */ +export function rotatePHPRuntime({ + php, + recreateRuntime, + maxRequests, +}: RotateOptions) { + let handledCalls = 0; + async function rotateRuntime() { + if (++handledCalls < maxRequests) { + return; + } + handledCalls = 0; + + const release = await php.semaphore.acquire(); + try { + php.hotSwapPHPRuntime(await recreateRuntime()); + } finally { + release(); + } + } + php.addEventListener('request.end', rotateRuntime); + return function () { + php.removeEventListener('request.end', rotateRuntime); + }; +} diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 0b3e84d22a..0c06be89a0 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -2,18 +2,35 @@ import { Remote } from 'comlink'; import { PHPResponse } from './php-response'; /** - * Represents an event related to the PHP filesystem. + * Represents an event related to the PHP request. */ export interface PHPRequestEndEvent { type: 'request.end'; } +/** + * Represents a PHP runtime initialization event. + */ +export interface PHPRuntimeInitializedEvent { + type: 'runtime.initialized'; +} + +/** + * Represents a PHP runtime destruction event. + */ +export interface PHPRuntimeBeforeDestroyEvent { + type: 'runtime.beforedestroy'; +} + /** * Represents an event related to the PHP instance. * This is intentionally not an extension of CustomEvent * to make it isomorphic between different JavaScript runtimes. */ -export type PHPEvent = PHPRequestEndEvent; +export type PHPEvent = + | PHPRequestEndEvent + | PHPRuntimeInitializedEvent + | PHPRuntimeBeforeDestroyEvent; /** * A callback function that handles PHP events. diff --git a/packages/php-wasm/util/src/lib/semaphore.ts b/packages/php-wasm/util/src/lib/semaphore.ts index c8b5addadd..ad670081de 100644 --- a/packages/php-wasm/util/src/lib/semaphore.ts +++ b/packages/php-wasm/util/src/lib/semaphore.ts @@ -40,7 +40,7 @@ export default class Semaphore { } } - async run(fn: () => Promise): Promise { + async run(fn: () => T | Promise): Promise { const release = await this.acquire(); try { return await fn(); diff --git a/packages/php-wasm/web/src/lib/web-php.ts b/packages/php-wasm/web/src/lib/web-php.ts index 27537ae4b6..27b976a236 100644 --- a/packages/php-wasm/web/src/lib/web-php.ts +++ b/packages/php-wasm/web/src/lib/web-php.ts @@ -61,49 +61,27 @@ export class WebPHP extends BasePHP { phpVersion: SupportedPHPVersion, options: PHPWebLoaderOptions = {} ) { - return await WebPHP.loadSync(phpVersion, options).phpReady; + return new WebPHP( + await WebPHP.loadRuntime(phpVersion, options), + options.requestHandler + ); } - /** - * Does what load() does, but synchronously returns - * an object with the PHP instance and a promise that - * resolves when the PHP instance is ready. - * - * @see load - */ - static loadSync( + static async loadRuntime( phpVersion: SupportedPHPVersion, options: PHPWebLoaderOptions = {} ) { - /** - * Keep any changes to the signature of this method in sync with the - * `PHP.load` method in the @php-wasm/node package. - */ - const php = new WebPHP(undefined, options.requestHandler); - // Determine which variant to load based on the requested extensions const variant = options.loadAllExtensions ? 'kitchen-sink' : 'light'; - const doLoad = async () => { - const phpLoaderModule = await getPHPLoaderModule( - phpVersion, - variant - ); - options.downloadMonitor?.expectAssets({ - [phpLoaderModule.dependencyFilename]: - phpLoaderModule.dependenciesTotalSize, - }); - const runtimeId = await loadPHPRuntime(phpLoaderModule, { - ...(options.emscriptenOptions || {}), - ...fakeWebsocket(), - }); - php.initializeRuntime(runtimeId); - }; - const asyncData = doLoad(); - - return { - php, - phpReady: asyncData.then(() => php), - }; + const phpLoaderModule = await getPHPLoaderModule(phpVersion, variant); + options.downloadMonitor?.expectAssets({ + [phpLoaderModule.dependencyFilename]: + phpLoaderModule.dependenciesTotalSize, + }); + return await loadPHPRuntime(phpLoaderModule, { + ...(options.emscriptenOptions || {}), + ...fakeWebsocket(), + }); } } diff --git a/packages/playground/remote/src/lib/opfs/bind-opfs.ts b/packages/playground/remote/src/lib/opfs/bind-opfs.ts index e6dee78079..b1c72014b9 100644 --- a/packages/playground/remote/src/lib/opfs/bind-opfs.ts +++ b/packages/playground/remote/src/lib/opfs/bind-opfs.ts @@ -53,9 +53,9 @@ export async function bindOpfs({ * persisted files. */ try { - if (await php.isDir(docroot)) { - await php.rmdir(docroot, { recursive: true }); - await php.mkdirTree(docroot); + if (php.isDir(docroot)) { + php.rmdir(docroot, { recursive: true }); + php.mkdirTree(docroot); } } catch (e) { // Ignore any errors diff --git a/packages/playground/remote/src/lib/opfs/journal-memfs-to-opfs.ts b/packages/playground/remote/src/lib/opfs/journal-memfs-to-opfs.ts index a85377836c..a3aaee344c 100644 --- a/packages/playground/remote/src/lib/opfs/journal-memfs-to-opfs.ts +++ b/packages/playground/remote/src/lib/opfs/journal-memfs-to-opfs.ts @@ -21,35 +21,28 @@ export function journalFSEventsToOpfs( memfsRoot: string ) { const journal: FilesystemOperation[] = []; - const unbind = journalFSEvents(php, memfsRoot, (entry) => { + const unbindJournal = journalFSEvents(php, memfsRoot, (entry) => { journal.push(entry); }); const rewriter = new OpfsRewriter(php, opfsRoot, memfsRoot); - /** - * Calls the observer with the current delta each time PHP is ran. - * - * Do not do this in external code. This is a private code path that - * will be maintained alongside Playground code and likely removed - * in the future. It is not part of the public API. The goal is to - * allow some time for a few more use-cases to emerge before - * proposing a new public API like php.addEventListener( 'run' ). - */ - const originalRun = php.run; - php.run = async function (...args) { - const response = await originalRun.apply(php, args); - // @TODO This is way too slow in practice, we need to batch the - // changes into groups of parallelizable operations. - while (true) { - const entry = journal.shift(); - if (!entry) { - break; + + async function flushJournal() { + const release = await php.semaphore.acquire(); + try { + // @TODO This is way too slow in practice, we need to batch the + // changes into groups of parallelizable operations. + while (journal.length) { + await rewriter.processEntry(journal.shift()!); } - await rewriter.processEntry(entry); + } finally { + release(); } - return response; + } + php.addEventListener('request.end', flushJournal); + return function () { + unbindJournal(); + php.removeEventListener('request.end', flushJournal); }; - - return unbind; } type JournalEntry = FilesystemOperation; @@ -64,7 +57,6 @@ class OpfsRewriter { memfsRoot: string ) { this.memfsRoot = normalizeMemfsPath(memfsRoot); - this.FS = this.php[__private__dont__use].FS; } private toOpfsPath(path: string) { @@ -105,7 +97,12 @@ class OpfsRewriter { }); } } else if (entry.operation === 'WRITE') { - await overwriteOpfsFile(opfsParent, name, this.FS, entry.path); + await overwriteOpfsFile( + opfsParent, + name, + this.php[__private__dont__use].FS, + entry.path + ); } else if ( entry.operation === 'RENAME' && entry.toPath.startsWith(this.memfsRoot) diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index 61275f9b7f..0b941da637 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -12,6 +12,7 @@ import { SupportedPHPExtension, SupportedPHPVersion, SupportedPHPVersionsList, + rotatePHPRuntime, } from '@php-wasm/universal'; import { FilesystemOperation, @@ -99,16 +100,30 @@ if (!wordPressAvailableInOPFS) { } } -// const wordPressFile = fetch(zipFilename); -const { php, phpReady } = WebPHP.loadSync(phpVersion, { - downloadMonitor: monitor, - requestHandler: { - documentRoot: DOCROOT, - absoluteUrl: scopedSiteUrl, - }, - // We don't yet support loading specific PHP extensions one-by-one. - // Let's just indicate whether we want to load all of them. - loadAllExtensions: phpExtensions?.length > 0, +const php = new WebPHP(undefined, { + documentRoot: DOCROOT, + absoluteUrl: scopedSiteUrl, +}); + +const recreateRuntime = async () => + await WebPHP.loadRuntime(phpVersion, { + downloadMonitor: monitor, + // We don't yet support loading specific PHP extensions one-by-one. + // Let's just indicate whether we want to load all of them. + loadAllExtensions: phpExtensions?.length > 0, + }); + +// Rotate the PHP runtime periodically to avoid memory leak-related crashes. +// @see https://github.com/WordPress/wordpress-playground/pull/990 for more context +rotatePHPRuntime({ + php, + recreateRuntime, + // 400 is an arbitrary number that should trigger a rotation + // way before the memory gets too fragmented. If the memory + // issue returns, let's explore: + // * Lowering this number + // * Adding a memory usage monitor and rotate based on that + maxRequests: 400, }); /** @inheritDoc PHPClient */ @@ -203,7 +218,8 @@ const [setApiReady, setAPIError] = exposeAPI( ); try { - await phpReady; + php.initializeRuntime(await recreateRuntime()); + if (startupOptions.sapiName) { await php.setSapiName(startupOptions.sapiName); }