From a37872e8312ed4442f562aee5b24cd1ae8a26fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Jan 2025 13:18:47 +0100 Subject: [PATCH] [PHP] Remount NODEFS and other mounts after hotswapPhpRuntime is called Re-create the mounts destroyed during the hotSwapPHPRuntime call. Closes #1596 ## Context Without this PR, `hotSwapPHPRuntime()` does not: * Recreate the filesystem mounts * Restore the platform-level plugins stored in the /internal (e.g the SQLite integration plugin) Which results in WordPress losing access to local files and the database connectivity. This PR ensures the mounts are re-created and the platform-level plugins preserved. ## Testing instructions This PR comes with tests, so check the CI logs. --- .../php-wasm/node/src/lib/node-fs-mount.ts | 2 +- .../node/src/test/rotate-php-runtime.spec.ts | 176 ++++++++++++++++++ packages/php-wasm/universal/src/lib/php.ts | 39 +++- .../universal/src/lib/rotate-php-runtime.ts | 2 +- 4 files changed, 215 insertions(+), 4 deletions(-) diff --git a/packages/php-wasm/node/src/lib/node-fs-mount.ts b/packages/php-wasm/node/src/lib/node-fs-mount.ts index 778c928714..4460393ea0 100644 --- a/packages/php-wasm/node/src/lib/node-fs-mount.ts +++ b/packages/php-wasm/node/src/lib/node-fs-mount.ts @@ -4,7 +4,7 @@ export function createNodeFsMountHandler(localPath: string): MountHandler { return async function (php, FS, vfsMountPoint) { FS.mount(FS.filesystems['NODEFS'], { root: localPath }, vfsMountPoint); return () => { - FS!.unmount(localPath); + FS!.unmount(vfsMountPoint); }; }; } 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 index 1688fec8e4..a0615fcb3e 100644 --- a/packages/php-wasm/node/src/test/rotate-php-runtime.spec.ts +++ b/packages/php-wasm/node/src/test/rotate-php-runtime.spec.ts @@ -14,6 +14,182 @@ const recreateRuntime = async (version: any = LatestSupportedPHPVersion) => await loadNodeRuntime(version); describe('rotatePHPRuntime()', () => { + it('Preserves the /internal directory through PHP runtime recreation', async () => { + // Rotate the PHP runtime + const recreateRuntimeSpy = vitest.fn(recreateRuntime); + + const php = new PHP(await recreateRuntime()); + rotatePHPRuntime({ + php, + cwd: '/test-root', + recreateRuntime: recreateRuntimeSpy, + maxRequests: 10, + }); + + // Create a temporary directory and a file in it + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-')); + const tempFile = path.join(tempDir, 'file'); + fs.writeFileSync(tempFile, 'playground'); + + // Mount the temporary directory + php.mkdir('/internal/shared'); + php.writeFile('/internal/shared/test', 'playground'); + + // Confirm the file is there + expect(php.fileExists('/internal/shared/test')).toBe(true); + + // Rotate the PHP runtime + for (let i = 0; i < 15; i++) { + await php.run({ code: `` }); + } + + expect(recreateRuntimeSpy).toHaveBeenCalledTimes(1); + + // Confirm the file is still there + expect(php.fileExists('/internal/shared/test')).toBe(true); + expect(php.readFileAsText('/internal/shared/test')).toBe('playground'); + }); + + it('Preserves a single NODEFS mount through PHP runtime recreation', async () => { + // Rotate the PHP runtime + const recreateRuntimeSpy = vitest.fn(recreateRuntime); + + const php = new PHP(await recreateRuntime()); + rotatePHPRuntime({ + php, + cwd: '/test-root', + recreateRuntime: recreateRuntimeSpy, + maxRequests: 10, + }); + + // Create a temporary directory and a file in it + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-')); + const tempFile = path.join(tempDir, 'file'); + fs.writeFileSync(tempFile, 'playground'); + + // Mount the temporary directory + php.mkdir('/test-root'); + await php.mount('/test-root', createNodeFsMountHandler(tempDir)); + + // Confirm the file is still there + expect(php.readFileAsText('/test-root/file')).toBe('playground'); + + // Rotate the PHP runtime + for (let i = 0; i < 15; i++) { + await php.run({ code: `` }); + } + + expect(recreateRuntimeSpy).toHaveBeenCalledTimes(1); + + // Confirm the local NODEFS mount is lost + expect(php.readFileAsText('/test-root/file')).toBe('playground'); + }); + + it('Preserves 4 WordPress plugin mounts through PHP runtime recreation', async () => { + // Rotate the PHP runtime + const recreateRuntimeSpy = vitest.fn(recreateRuntime); + + const php = new PHP(await recreateRuntime()); + rotatePHPRuntime({ + php, + cwd: '/wordpress', + recreateRuntime: recreateRuntimeSpy, + maxRequests: 10, + }); + + // Create temporary directories and files for plugins and uploads + const tempDirs = [ + fs.mkdtempSync(path.join(os.tmpdir(), 'data-liberation-')), + fs.mkdtempSync(path.join(os.tmpdir(), 'data-liberation-markdown-')), + fs.mkdtempSync( + path.join(os.tmpdir(), 'data-liberation-static-files-editor-') + ), + fs.mkdtempSync(path.join(os.tmpdir(), 'static-pages-')), + ]; + + // Add test files to each directory + tempDirs.forEach((dir, i) => { + fs.writeFileSync(path.join(dir, 'test.php'), `plugin-${i}`); + }); + + // Create WordPress directory structure + php.mkdir('/wordpress/wp-content/plugins/data-liberation'); + php.mkdir('/wordpress/wp-content/plugins/z-data-liberation-markdown'); + php.mkdir( + '/wordpress/wp-content/plugins/z-data-liberation-static-files-editor' + ); + php.mkdir('/wordpress/wp-content/uploads/static-pages'); + + // Mount the directories using WordPress paths + await php.mount( + '/wordpress/wp-content/plugins/data-liberation', + createNodeFsMountHandler(tempDirs[0]) + ); + await php.mount( + '/wordpress/wp-content/plugins/z-data-liberation-markdown', + createNodeFsMountHandler(tempDirs[1]) + ); + await php.mount( + '/wordpress/wp-content/plugins/z-data-liberation-static-files-editor', + createNodeFsMountHandler(tempDirs[2]) + ); + await php.mount( + '/wordpress/wp-content/uploads/static-pages', + createNodeFsMountHandler(tempDirs[3]) + ); + + // Verify files exist + expect( + php.readFileAsText( + '/wordpress/wp-content/plugins/data-liberation/test.php' + ) + ).toBe('plugin-0'); + expect( + php.readFileAsText( + '/wordpress/wp-content/plugins/z-data-liberation-markdown/test.php' + ) + ).toBe('plugin-1'); + expect( + php.readFileAsText( + '/wordpress/wp-content/plugins/z-data-liberation-static-files-editor/test.php' + ) + ).toBe('plugin-2'); + expect( + php.readFileAsText( + '/wordpress/wp-content/uploads/static-pages/test.php' + ) + ).toBe('plugin-3'); + + // Rotate the PHP runtime + for (let i = 0; i < 15; i++) { + await php.run({ code: `` }); + } + + expect(recreateRuntimeSpy).toHaveBeenCalledTimes(1); + + // Verify files still exist after rotation + expect( + php.readFileAsText( + '/wordpress/wp-content/plugins/data-liberation/test.php' + ) + ).toBe('plugin-0'); + expect( + php.readFileAsText( + '/wordpress/wp-content/plugins/z-data-liberation-markdown/test.php' + ) + ).toBe('plugin-1'); + expect( + php.readFileAsText( + '/wordpress/wp-content/plugins/z-data-liberation-static-files-editor/test.php' + ) + ).toBe('plugin-2'); + expect( + php.readFileAsText( + '/wordpress/wp-content/uploads/static-pages/test.php' + ) + ).toBe('plugin-3'); + }); + it('Free up the available PHP memory', async () => { const freeMemory = (php: PHP) => php[__private__dont__use].HEAPU32.reduce( diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index 16ef240570..fe0508d011 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -47,6 +47,11 @@ export type MountHandler = ( export const PHP_INI_PATH = '/internal/shared/php.ini'; const AUTO_PREPEND_SCRIPT = '/internal/shared/auto_prepend_file.php'; +type MountObject = { + mountHandler: MountHandler; + unmount: () => Promise; +}; + /** * An environment-agnostic wrapper around the Emscripten PHP runtime * that universals the super low-level API and provides a more convenient @@ -62,6 +67,7 @@ export class PHP implements Disposable { #wasmErrorsTarget: UnhandledRejectionsTarget | null = null; #eventListeners: Map> = new Map(); #messageListeners: MessageListener[] = []; + #mounts: Record = {}; requestHandler?: PHPRequestHandler; /** @@ -1000,7 +1006,7 @@ export class PHP implements Disposable { * is fully decoupled from the request handler and * accepts a constructor-level cwd argument. */ - hotSwapPHPRuntime(runtime: number, cwd?: string) { + async hotSwapPHPRuntime(runtime: number, cwd?: string) { // 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. @@ -1008,8 +1014,17 @@ export class PHP implements Disposable { // 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; + // Unmount all the mount handlers + const mountHandlers: { mountHandler: MountHandler; vfsPath: string }[] = + []; + for (const [vfsPath, mount] of Object.entries(this.#mounts)) { + mountHandlers.push({ mountHandler: mount.mountHandler, vfsPath }); + await mount.unmount(); + } + // Kill the current runtime try { this.exit(); @@ -1024,10 +1039,19 @@ export class PHP implements Disposable { this.setSapiName(this.#sapiName); } + // Copy the old /internal directory to the new filesystem + copyFS(oldFS, this[__private__dont__use].FS, '/internal'); + // Copy the MEMFS directory structure from the old FS to the new one if (cwd) { copyFS(oldFS, this[__private__dont__use].FS, cwd); } + + // Re-mount all the mount handlers + for (const { mountHandler, vfsPath } of mountHandlers) { + this.mkdir(vfsPath); + await this.mount(vfsPath, mountHandler); + } } /** @@ -1041,11 +1065,22 @@ export class PHP implements Disposable { virtualFSPath: string, mountHandler: MountHandler ): Promise { - return await mountHandler( + const unmountCallback = await mountHandler( this, this[__private__dont__use].FS, virtualFSPath ); + const mountObject = { + mountHandler, + unmount: async () => { + await unmountCallback(); + delete this.#mounts[virtualFSPath]; + }, + }; + this.#mounts[virtualFSPath] = mountObject; + return () => { + mountObject.unmount(); + }; } /** diff --git a/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts b/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts index 8f159cbc80..5d706cbb05 100644 --- a/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts +++ b/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts @@ -42,7 +42,7 @@ export function rotatePHPRuntime({ async function rotateRuntime() { const release = await php.semaphore.acquire(); try { - php.hotSwapPHPRuntime(await recreateRuntime(), cwd); + await php.hotSwapPHPRuntime(await recreateRuntime(), cwd); // A new runtime has handled zero requests. runtimeRequestCount = 0;