From f16ace8c3125b35fef5096c94e342bcac52d828b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 1 Oct 2025 15:17:26 +0200 Subject: [PATCH 1/2] [PHP] Allow removing CWD during runtime rotation --- .../php-wasm/node/src/lib/node-fs-mount.ts | 7 ++ .../node/src/test/rotate-php-runtime.spec.ts | 70 +++++++++++++++---- packages/php-wasm/universal/src/lib/php.ts | 34 ++++++++- .../universal/src/lib/rotate-php-runtime.ts | 2 - packages/playground/wordpress/src/boot.ts | 2 +- 5 files changed, 97 insertions(+), 18 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 b562edd3ec..aafb243be0 100644 --- a/packages/php-wasm/node/src/lib/node-fs-mount.ts +++ b/packages/php-wasm/node/src/lib/node-fs-mount.ts @@ -3,6 +3,7 @@ import { FSHelpers, type MountHandler, } from '@php-wasm/universal'; +import { isParentOf } from '@php-wasm/util'; import { lstatSync } from 'fs'; import { dirname } from 'path'; @@ -53,6 +54,12 @@ export function createNodeFsMountHandler(localPath: string): MountHandler { FS!.unmount(vfsMountPoint); if (removeVfsNode) { if (FS.isDir(lookup.node.mode)) { + if (isParentOf(vfsMountPoint, FS.cwd())) { + throw new Error( + `Cannot remove the VFS directory "${vfsMountPoint}" on umount cleanup – it is a parent of the CWD "${FS.cwd()}". Change CWD before ` + + `unmounting or explicitly disable post-unmount node cleanup with createNodeFsMountHandler(path, {cleanupNodesOnUnmount: false}).` + ); + } FS.rmdir(vfsMountPoint); } else { FS.unlink(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 be8db07657..d9fc34cb2a 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 @@ -51,7 +51,6 @@ describe.each([true, false])( const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 10, }); @@ -88,7 +87,6 @@ describe.each([true, false])( const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 10, }); @@ -121,7 +119,6 @@ describe.each([true, false])( const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/wordpress', recreateRuntime: recreateRuntimeSpy, maxRequests: 10, }); @@ -228,7 +225,6 @@ describe.each([true, false])( // Rotate the PHP runtime const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 1000, }); @@ -257,7 +253,6 @@ describe.each([true, false])( const recreateRuntimeSpy = vitest.fn(recreateRuntime); const php = new PHP(await recreateRuntimeSpy()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 1, }); @@ -271,7 +266,6 @@ describe.each([true, false])( const recreateRuntimeSpy = vitest.fn(recreateRuntime); const php = new PHP(await recreateRuntimeSpy()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 1234, }); @@ -289,7 +283,6 @@ describe.each([true, false])( const recreateRuntimeSpy = vitest.fn(recreateRuntime); const php = new PHP(await recreateRuntimeSpy()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 1234, }); @@ -318,7 +311,6 @@ describe.each([true, false])( }); const php = new PHP(await recreateRuntimeSpy()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime: recreateRuntimeSpy, maxRequests: 1, }); @@ -339,7 +331,6 @@ describe.each([true, false])( it('Should preserve the custom SAPI name', async () => { const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime, maxRequests: 1, }); @@ -356,7 +347,6 @@ describe.each([true, false])( it('Should preserve the MEMFS files', async () => { const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime, maxRequests: 1, }); @@ -379,7 +369,6 @@ describe.each([true, false])( it('Should not overwrite the NODEFS files', async () => { const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime, maxRequests: 1, }); @@ -424,7 +413,6 @@ describe.each([true, false])( it('Should preserve the spawn handler through PHP runtime recreation', async () => { const php = new PHP(await recreateRuntime()); php.enableRuntimeRotation({ - cwd: '/test-root', recreateRuntime, maxRequests: 1, }); @@ -465,5 +453,63 @@ describe.each([true, false])( expect(result2.text).toBe('Hello Again'); expect(spawnHandlerCallCount).toBe(2); }, 30_000); + + it('Should preserve NODEFS mount when CWD is the same as mount point', async () => { + const php = new PHP(await recreateRuntime()); + php.enableRuntimeRotation({ + recreateRuntime, + maxRequests: 1, + }); + + // Create a temporary directory to mount + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'nodefs-mount-') + ); + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'Hello from NODEFS'); + + try { + // Mount the temp directory at /wordpress + await php.mount( + '/wordpress', + createNodeFsMountHandler(tempDir) + ); + + // Set CWD to the mount point + php.chdir('/wordpress'); + expect(php.cwd()).toBe('/wordpress'); + + // Verify the mounted file is accessible + expect(php.fileExists('/wordpress/test.txt')).toBe(true); + expect(php.readFileAsText('/wordpress/test.txt')).toBe( + 'Hello from NODEFS' + ); + + // Trigger runtime rotation by making a request + await php.run({ code: ` 0, @@ -1315,14 +1314,12 @@ export class PHP implements Disposable { enableRuntimeRotation(options: { recreateRuntime: () => Promise | number; maxRequests?: number; - cwd?: string; }) { this.#rotationOptions = { ...this.#rotationOptions, enabled: true, recreateRuntime: options.recreateRuntime, maxRequests: options.maxRequests ?? 400, - cwd: options.cwd, }; } @@ -1358,6 +1355,27 @@ export class PHP implements Disposable { const oldRootLevelPaths = this.listFiles('/').map((file) => `/${file}`); const oldSpawnProcess = this[__private__dont__use].spawnProcess; + // Temporarily set CWD to / and restore it at the end of this method. + // + // There's a chance cleaning up old mounts via mount.unmount() + // will attempt removing the CWD. Normally, this would throw + // FS.ErrnoError(10) EBUSY and interrupt the PHP runtime rotation, + // leaving us in a broken state. + // + // Even though removing the CWD directory is not allowed by the + // filesystem, we don't care that much here – we're merely freeing + // all the resources allocated by the old filesystem before it's + // garbage collected. We are about to recreate the same filesystem + // structure and mounts in another PHP runtime. + // + // Therefore, let's suspend the strict EBUSY check by setting the CWD + // to / for the cleanup purposes. We'll attempt to restore the original + // CWD on the new runtime once we re-apply all the mounts there. We'll + // only have a real reason to throw an error if the CWD path does not + // exist in the new filesystem after the rotation. + const oldCWD = oldFS.cwd(); + oldFS.chdir('/'); + // Unmount all the mount handlers const mountHandlers: { mountHandler: MountHandler; vfsPath: string }[] = []; @@ -1406,6 +1424,16 @@ export class PHP implements Disposable { this.mkdir(vfsPath); await this.mount(vfsPath, mountHandler); } + try { + newFs.chdir(oldCWD); + } catch (e) { + throw new Error( + `Failed to restore CWD to ${oldCWD} after PHP runtime rotation.`, + { + cause: e, + } + ); + } } /** 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 39351dd083..020e7be443 100644 --- a/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts +++ b/packages/php-wasm/universal/src/lib/rotate-php-runtime.ts @@ -15,13 +15,11 @@ export interface RotateOptions { */ export function rotatePHPRuntime({ php, - cwd, recreateRuntime, maxRequests = 400, }: RotateOptions) { return php.enableRuntimeRotation({ recreateRuntime, maxRequests, - cwd, }); } diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index e960486d7c..746db771fd 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -316,10 +316,10 @@ export async function bootRequestHandler(options: BootRequestHandlerOptions) { // Rotate the PHP runtime periodically to avoid memory leak-related crashes. // @see https://github.com/WordPress/wordpress-playground/pull/990 for more context php.enableRuntimeRotation({ - cwd: requestHandler.documentRoot, recreateRuntime: options.createPhpRuntime, maxRequests: 400, }); + php.chdir(requestHandler.documentRoot); if (options.onPHPInstanceCreated) { await options.onPHPInstanceCreated(php, { isPrimary }); From dd9e00bd580605aec16d06c1ff3adefd40720504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 1 Oct 2025 15:22:45 +0200 Subject: [PATCH 2/2] Don't set cwd in bootRequestHandler --- packages/playground/wordpress/src/boot.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index 746db771fd..db42c8a6d8 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -319,7 +319,6 @@ export async function bootRequestHandler(options: BootRequestHandlerOptions) { recreateRuntime: options.createPhpRuntime, maxRequests: 400, }); - php.chdir(requestHandler.documentRoot); if (options.onPHPInstanceCreated) { await options.onPHPInstanceCreated(php, { isPrimary });