Skip to content

Commit e4b3c87

Browse files
authored
[PHP] Restore /internal files and Filesystem mounts after hotswapPhpRuntime is called (#2119)
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 everytime the PHP runtime is rotated. This PR ensures the mounts are re-created and the platform-level plugins preserved. Closes #1596 ## Testing instructions This PR comes with tests, so check the CI logs.
1 parent 7a40bbd commit e4b3c87

File tree

4 files changed

+215
-4
lines changed

4 files changed

+215
-4
lines changed

packages/php-wasm/node/src/lib/node-fs-mount.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export function createNodeFsMountHandler(localPath: string): MountHandler {
44
return async function (php, FS, vfsMountPoint) {
55
FS.mount(FS.filesystems['NODEFS'], { root: localPath }, vfsMountPoint);
66
return () => {
7-
FS!.unmount(localPath);
7+
FS!.unmount(vfsMountPoint);
88
};
99
};
1010
}

packages/php-wasm/node/src/test/rotate-php-runtime.spec.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,182 @@ const recreateRuntime = async (version: any = LatestSupportedPHPVersion) =>
1414
await loadNodeRuntime(version);
1515

1616
describe('rotatePHPRuntime()', () => {
17+
it('Preserves the /internal directory through PHP runtime recreation', async () => {
18+
// Rotate the PHP runtime
19+
const recreateRuntimeSpy = vitest.fn(recreateRuntime);
20+
21+
const php = new PHP(await recreateRuntime());
22+
rotatePHPRuntime({
23+
php,
24+
cwd: '/test-root',
25+
recreateRuntime: recreateRuntimeSpy,
26+
maxRequests: 10,
27+
});
28+
29+
// Create a temporary directory and a file in it
30+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-'));
31+
const tempFile = path.join(tempDir, 'file');
32+
fs.writeFileSync(tempFile, 'playground');
33+
34+
// Mount the temporary directory
35+
php.mkdir('/internal/shared');
36+
php.writeFile('/internal/shared/test', 'playground');
37+
38+
// Confirm the file is there
39+
expect(php.fileExists('/internal/shared/test')).toBe(true);
40+
41+
// Rotate the PHP runtime
42+
for (let i = 0; i < 15; i++) {
43+
await php.run({ code: `` });
44+
}
45+
46+
expect(recreateRuntimeSpy).toHaveBeenCalledTimes(1);
47+
48+
// Confirm the file is still there
49+
expect(php.fileExists('/internal/shared/test')).toBe(true);
50+
expect(php.readFileAsText('/internal/shared/test')).toBe('playground');
51+
});
52+
53+
it('Preserves a single NODEFS mount through PHP runtime recreation', async () => {
54+
// Rotate the PHP runtime
55+
const recreateRuntimeSpy = vitest.fn(recreateRuntime);
56+
57+
const php = new PHP(await recreateRuntime());
58+
rotatePHPRuntime({
59+
php,
60+
cwd: '/test-root',
61+
recreateRuntime: recreateRuntimeSpy,
62+
maxRequests: 10,
63+
});
64+
65+
// Create a temporary directory and a file in it
66+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-'));
67+
const tempFile = path.join(tempDir, 'file');
68+
fs.writeFileSync(tempFile, 'playground');
69+
70+
// Mount the temporary directory
71+
php.mkdir('/test-root');
72+
await php.mount('/test-root', createNodeFsMountHandler(tempDir));
73+
74+
// Confirm the file is still there
75+
expect(php.readFileAsText('/test-root/file')).toBe('playground');
76+
77+
// Rotate the PHP runtime
78+
for (let i = 0; i < 15; i++) {
79+
await php.run({ code: `` });
80+
}
81+
82+
expect(recreateRuntimeSpy).toHaveBeenCalledTimes(1);
83+
84+
// Confirm the local NODEFS mount is lost
85+
expect(php.readFileAsText('/test-root/file')).toBe('playground');
86+
});
87+
88+
it('Preserves 4 WordPress plugin mounts through PHP runtime recreation', async () => {
89+
// Rotate the PHP runtime
90+
const recreateRuntimeSpy = vitest.fn(recreateRuntime);
91+
92+
const php = new PHP(await recreateRuntime());
93+
rotatePHPRuntime({
94+
php,
95+
cwd: '/wordpress',
96+
recreateRuntime: recreateRuntimeSpy,
97+
maxRequests: 10,
98+
});
99+
100+
// Create temporary directories and files for plugins and uploads
101+
const tempDirs = [
102+
fs.mkdtempSync(path.join(os.tmpdir(), 'data-liberation-')),
103+
fs.mkdtempSync(path.join(os.tmpdir(), 'data-liberation-markdown-')),
104+
fs.mkdtempSync(
105+
path.join(os.tmpdir(), 'data-liberation-static-files-editor-')
106+
),
107+
fs.mkdtempSync(path.join(os.tmpdir(), 'static-pages-')),
108+
];
109+
110+
// Add test files to each directory
111+
tempDirs.forEach((dir, i) => {
112+
fs.writeFileSync(path.join(dir, 'test.php'), `plugin-${i}`);
113+
});
114+
115+
// Create WordPress directory structure
116+
php.mkdir('/wordpress/wp-content/plugins/data-liberation');
117+
php.mkdir('/wordpress/wp-content/plugins/z-data-liberation-markdown');
118+
php.mkdir(
119+
'/wordpress/wp-content/plugins/z-data-liberation-static-files-editor'
120+
);
121+
php.mkdir('/wordpress/wp-content/uploads/static-pages');
122+
123+
// Mount the directories using WordPress paths
124+
await php.mount(
125+
'/wordpress/wp-content/plugins/data-liberation',
126+
createNodeFsMountHandler(tempDirs[0])
127+
);
128+
await php.mount(
129+
'/wordpress/wp-content/plugins/z-data-liberation-markdown',
130+
createNodeFsMountHandler(tempDirs[1])
131+
);
132+
await php.mount(
133+
'/wordpress/wp-content/plugins/z-data-liberation-static-files-editor',
134+
createNodeFsMountHandler(tempDirs[2])
135+
);
136+
await php.mount(
137+
'/wordpress/wp-content/uploads/static-pages',
138+
createNodeFsMountHandler(tempDirs[3])
139+
);
140+
141+
// Verify files exist
142+
expect(
143+
php.readFileAsText(
144+
'/wordpress/wp-content/plugins/data-liberation/test.php'
145+
)
146+
).toBe('plugin-0');
147+
expect(
148+
php.readFileAsText(
149+
'/wordpress/wp-content/plugins/z-data-liberation-markdown/test.php'
150+
)
151+
).toBe('plugin-1');
152+
expect(
153+
php.readFileAsText(
154+
'/wordpress/wp-content/plugins/z-data-liberation-static-files-editor/test.php'
155+
)
156+
).toBe('plugin-2');
157+
expect(
158+
php.readFileAsText(
159+
'/wordpress/wp-content/uploads/static-pages/test.php'
160+
)
161+
).toBe('plugin-3');
162+
163+
// Rotate the PHP runtime
164+
for (let i = 0; i < 15; i++) {
165+
await php.run({ code: `` });
166+
}
167+
168+
expect(recreateRuntimeSpy).toHaveBeenCalledTimes(1);
169+
170+
// Verify files still exist after rotation
171+
expect(
172+
php.readFileAsText(
173+
'/wordpress/wp-content/plugins/data-liberation/test.php'
174+
)
175+
).toBe('plugin-0');
176+
expect(
177+
php.readFileAsText(
178+
'/wordpress/wp-content/plugins/z-data-liberation-markdown/test.php'
179+
)
180+
).toBe('plugin-1');
181+
expect(
182+
php.readFileAsText(
183+
'/wordpress/wp-content/plugins/z-data-liberation-static-files-editor/test.php'
184+
)
185+
).toBe('plugin-2');
186+
expect(
187+
php.readFileAsText(
188+
'/wordpress/wp-content/uploads/static-pages/test.php'
189+
)
190+
).toBe('plugin-3');
191+
});
192+
17193
it('Free up the available PHP memory', async () => {
18194
const freeMemory = (php: PHP) =>
19195
php[__private__dont__use].HEAPU32.reduce(

packages/php-wasm/universal/src/lib/php.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export type MountHandler = (
4747
export const PHP_INI_PATH = '/internal/shared/php.ini';
4848
const AUTO_PREPEND_SCRIPT = '/internal/shared/auto_prepend_file.php';
4949

50+
type MountObject = {
51+
mountHandler: MountHandler;
52+
unmount: () => Promise<any>;
53+
};
54+
5055
/**
5156
* An environment-agnostic wrapper around the Emscripten PHP runtime
5257
* that universals the super low-level API and provides a more convenient
@@ -62,6 +67,7 @@ export class PHP implements Disposable {
6267
#wasmErrorsTarget: UnhandledRejectionsTarget | null = null;
6368
#eventListeners: Map<string, Set<PHPEventListener>> = new Map();
6469
#messageListeners: MessageListener[] = [];
70+
#mounts: Record<string, MountObject> = {};
6571
requestHandler?: PHPRequestHandler;
6672

6773
/**
@@ -1000,16 +1006,25 @@ export class PHP implements Disposable {
10001006
* is fully decoupled from the request handler and
10011007
* accepts a constructor-level cwd argument.
10021008
*/
1003-
hotSwapPHPRuntime(runtime: number, cwd?: string) {
1009+
async hotSwapPHPRuntime(runtime: number, cwd?: string) {
10041010
// Once we secure the lock and have the new runtime ready,
10051011
// the rest of the swap handler is synchronous to make sure
10061012
// no other operations acts on the old runtime or FS.
10071013
// If there was await anywhere here, we'd risk applyng
10081014
// asynchronous changes to either the filesystem or the
10091015
// old PHP runtime without propagating them to the new
10101016
// runtime.
1017+
10111018
const oldFS = this[__private__dont__use].FS;
10121019

1020+
// Unmount all the mount handlers
1021+
const mountHandlers: { mountHandler: MountHandler; vfsPath: string }[] =
1022+
[];
1023+
for (const [vfsPath, mount] of Object.entries(this.#mounts)) {
1024+
mountHandlers.push({ mountHandler: mount.mountHandler, vfsPath });
1025+
await mount.unmount();
1026+
}
1027+
10131028
// Kill the current runtime
10141029
try {
10151030
this.exit();
@@ -1024,10 +1039,19 @@ export class PHP implements Disposable {
10241039
this.setSapiName(this.#sapiName);
10251040
}
10261041

1042+
// Copy the old /internal directory to the new filesystem
1043+
copyFS(oldFS, this[__private__dont__use].FS, '/internal');
1044+
10271045
// Copy the MEMFS directory structure from the old FS to the new one
10281046
if (cwd) {
10291047
copyFS(oldFS, this[__private__dont__use].FS, cwd);
10301048
}
1049+
1050+
// Re-mount all the mount handlers
1051+
for (const { mountHandler, vfsPath } of mountHandlers) {
1052+
this.mkdir(vfsPath);
1053+
await this.mount(vfsPath, mountHandler);
1054+
}
10311055
}
10321056

10331057
/**
@@ -1041,11 +1065,22 @@ export class PHP implements Disposable {
10411065
virtualFSPath: string,
10421066
mountHandler: MountHandler
10431067
): Promise<UnmountFunction> {
1044-
return await mountHandler(
1068+
const unmountCallback = await mountHandler(
10451069
this,
10461070
this[__private__dont__use].FS,
10471071
virtualFSPath
10481072
);
1073+
const mountObject = {
1074+
mountHandler,
1075+
unmount: async () => {
1076+
await unmountCallback();
1077+
delete this.#mounts[virtualFSPath];
1078+
},
1079+
};
1080+
this.#mounts[virtualFSPath] = mountObject;
1081+
return () => {
1082+
mountObject.unmount();
1083+
};
10491084
}
10501085

10511086
/**

packages/php-wasm/universal/src/lib/rotate-php-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function rotatePHPRuntime({
4242
async function rotateRuntime() {
4343
const release = await php.semaphore.acquire();
4444
try {
45-
php.hotSwapPHPRuntime(await recreateRuntime(), cwd);
45+
await php.hotSwapPHPRuntime(await recreateRuntime(), cwd);
4646

4747
// A new runtime has handled zero requests.
4848
runtimeRequestCount = 0;

0 commit comments

Comments
 (0)