Skip to content

Commit 60a57ee

Browse files
committed
Skip PROXYFS mmap for legacy PHP to fix multi-instance parsing
PHP 5.6's zend_compile_file uses mmap() to read source files into the lexer buffer, trusting fstat for the buffer size. When reading through PROXYFS on a secondary instance, fstat can return a stale size (e.g. a file rewritten from 570 to 538 bytes between MEMFS init and PROXYFS mount), causing the mmap buffer to extend past the real EOF. The parser reads garbage and reports "syntax error". PHP 7+ removed the mmap path from zend_stream_fixup entirely, falling back to a read-based path that reallocates on size mismatch — which is why only PHP 5.6 is affected. Add a withMmap option to proxyFileSystem() and set it to false for legacy PHP. Without stream_ops.mmap, Emscripten returns ENOSYS from mmap() and PHP uses the safe read-based fallback. The mmap patch was originally added for ICU (intl extension), which legacy PHP does not use.
1 parent ac35495 commit 60a57ee

4 files changed

Lines changed: 74 additions & 338 deletions

File tree

‎packages/php-wasm/universal/src/lib/proxy-file-system.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,21 @@ function ensureProxyFSHasMmapSupport(phpInstance: PHP) {
146146
* @param sourceOfTruth - The PHP instance containing the original files
147147
* @param replica - The PHP instance that will access files through PROXYFS
148148
* @param paths - Absolute paths to mount (e.g., ['/wordpress', '/internal/shared'])
149+
* @param options - Optional configuration
150+
* @param options.withMmap - Whether to add mmap support to PROXYFS. Defaults
151+
* to true. Set to false for PHP runtimes like 5.6 where the C-level mmap
152+
* path in zend_compile_file trusts fstat for buffer sizing — if the proxied
153+
* stat returns a stale size the parser reads garbage and reports spurious
154+
* "syntax error at EOF". Without mmap, PHP falls back to a read-based path
155+
* that handles stat/actual size mismatches gracefully.
149156
*/
150157
export async function proxyFileSystem(
151158
sourceOfTruth: PHP,
152159
replica: PHP,
153-
paths: string[]
160+
paths: string[],
161+
options?: { withMmap?: boolean }
154162
) {
163+
const withMmap = options?.withMmap ?? true;
155164
// We can't just import the symbol from the library because
156165
// Playground CLI is built as ESM and php-wasm-node is built as
157166
// CJS and the imported symbols will differ in the production build.
@@ -164,7 +173,9 @@ export async function proxyFileSystem(
164173
// after runtime rotation in hotSwapPHPRuntime().
165174
replica.mkdir(path);
166175
await replica.mount(path, (php: PHP) => {
167-
ensureProxyFSHasMmapSupport(php);
176+
if (withMmap) {
177+
ensureProxyFSHasMmapSupport(php);
178+
}
168179
const replicaSymbol = Object.getOwnPropertySymbols(php)[0];
169180
// @ts-ignore
170181
php[replicaSymbol].FS.mount(

‎packages/playground/remote/src/lib/playground-worker-endpoint.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import transportDummy from './playground-mu-plugin/playground-includes/wp_http_d
2727
import { logger } from '@php-wasm/logger';
2828
import type { AllPHPVersion, PathAlias, PHP } from '@php-wasm/universal';
2929
import {
30+
LegacyPHPVersions,
3031
PHPResponse,
3132
PHPWorker,
3233
isPathToSharedFS,
@@ -189,6 +190,15 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker {
189190
}
190191

191192
const parsedSiteUrl = new URL(siteUrl);
193+
// Legacy PHP 5.6 has a parser state bug that corrupts large
194+
// include chains when a secondary PHP instance accesses the
195+
// WordPress source via PROXYFS. Force single-instance mode so
196+
// all requests run on the primary and the PROXYFS bug never
197+
// surfaces. The SinglePHPInstanceManager serializes concurrent
198+
// requests on the primary via a 1-concurrency semaphore.
199+
const isLegacyPhp = (LegacyPHPVersions as readonly string[]).includes(
200+
phpVersion
201+
);
192202
const requestHandler = await bootRequestHandler({
193203
siteUrl,
194204
phpVersion,
@@ -239,7 +249,18 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker {
239249
await proxyFileSystem(
240250
await requestHandler.getPrimaryPhp(),
241251
php,
242-
pathsToProxy
252+
pathsToProxy,
253+
// PHP 5.6's zend_compile_file uses mmap() to read
254+
// source files. PROXYFS mmap trusts fstat for the
255+
// buffer size, but fstat can return stale sizes when
256+
// the primary has rewritten files after the secondary
257+
// was created. This causes the parser to read past
258+
// the real EOF and report spurious syntax errors.
259+
// Disabling mmap makes PHP fall back to a read-based
260+
// path that handles size mismatches correctly.
261+
// PHP 7+ removed the mmap path from zend_stream_fixup
262+
// entirely, so this only matters for legacy PHP.
263+
{ withMmap: !isLegacyPhp }
243264
);
244265
}
245266
if (withNetworking) {

‎packages/playground/wordpress/src/index.ts‎

Lines changed: 18 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,18 @@ export async function setupPlatformLevelMuPlugins(
3737
await php.mkdir('/internal/shared/mu-plugins');
3838

3939
if (phpMajor < 7) {
40-
// Overwrite auto_prepend_file.php to work around a PHP 5.6
41-
// WASM parser bug: after require_once of 0-sqlite.php (which
42-
// defines classes and globals), subsequent require_once calls
43-
// get spurious "syntax error" at EOF. Using
44-
// eval(file_get_contents()) instead avoids the issue.
40+
// Overwrite auto_prepend_file.php to add PHP 4 superglobal
41+
// polyfills that WP 1.0-2.5 needs. The default
42+
// auto_prepend_file only loads consts and preload files;
43+
// legacy PHP also needs the superglobals set up first.
4544
await php.writeFile(
4645
'/internal/shared/auto_prepend_file.php',
4746
`<?php
4847
// Polyfill the PHP 4 superglobals that WP 1.0-2.5 still rely on.
49-
// These were aliases of \$_GET / \$_POST / \$_COOKIE / \$_SERVER /
50-
// \$_FILES / \$_ENV / \$_REQUEST and were removed in PHP 5.4.
51-
// Bind by reference so later writes to \$_COOKIE (e.g. in
52-
// auth bypass shims) are reflected in \$HTTP_COOKIE_VARS that
48+
// These were aliases of $_GET / $_POST / $_COOKIE / $_SERVER /
49+
// $_FILES / $_ENV / $_REQUEST and were removed in PHP 5.4.
50+
// Bind by reference so later writes to $_COOKIE (e.g. in
51+
// auth bypass shims) are reflected in $HTTP_COOKIE_VARS that
5352
// WP 1.0's get_currentuserinfo() reads.
5453
$GLOBALS['HTTP_GET_VARS'] = &$_GET;
5554
$GLOBALS['HTTP_POST_VARS'] = &$_POST;
@@ -59,8 +58,8 @@ if (isset($_FILES)) $GLOBALS['HTTP_POST_FILES'] = &$_FILES;
5958
if (isset($_ENV)) $GLOBALS['HTTP_ENV_VARS'] = &$_ENV;
6059
if (isset($_SESSION)) $GLOBALS['HTTP_SESSION_VARS'] = &$_SESSION;
6160
// Also populate the top-level names that register_long_arrays +
62-
// register_globals=On used to expose. WP 1.0 reads \$PHP_SELF and
63-
// \$REMOTE_ADDR at the top level instead of \$_SERVER[...].
61+
// register_globals=On used to expose. WP 1.0 reads $PHP_SELF and
62+
// $REMOTE_ADDR at the top level instead of $_SERVER[...].
6463
if (isset($_SERVER['PHP_SELF'])) $GLOBALS['PHP_SELF'] = $_SERVER['PHP_SELF'];
6564
if (isset($_SERVER['REMOTE_ADDR'])) $GLOBALS['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'];
6665
if (isset($_SERVER['REQUEST_URI'])) $GLOBALS['REQUEST_URI'] = $_SERVER['REQUEST_URI'];
@@ -75,9 +74,7 @@ if(file_exists('/internal/shared/consts.json')) {
7574
}
7675
}
7776
foreach (glob('/internal/shared/preload/*.php') as $file) {
78-
$code = file_get_contents($file);
79-
$code = preg_replace('/^<\\\\?php\\\\s*/', '', $code);
80-
eval($code);
77+
require_once $file;
8178
}
8279
`
8380
);
@@ -110,8 +107,7 @@ function playground_load_mu_plugins() {
110107
sort( $mu_plugins );
111108
foreach ( $mu_plugins as $mu_plugin ) {
112109
// sqlite-database-integration.php is loaded separately
113-
// by the preload lazy loader or db.php, using eval()
114-
// to work around a PHP 5.6 WASM require_once parser bug.
110+
// by the preload lazy loader or db.php.
115111
if (strpos($mu_plugin, 'sqlite-database-integration') !== false) {
116112
continue;
117113
}
@@ -594,22 +590,6 @@ export async function preloadSqliteIntegration(
594590
/^(add_action\(\s*\n)/m,
595591
'if (function_exists("add_action")) $1'
596592
);
597-
// For PHP 5.6, replace require_once with eval in the
598-
// mu-plugin to work around the WASM parser bug.
599-
dbPhp = dbPhp.replace(
600-
/^(\s*)require_once\s+([^;]+);/gm,
601-
(_match, indent, path) => {
602-
const trimmedPath = path.trim();
603-
if (/^\$\w+$/.test(trimmedPath)) {
604-
return _match;
605-
}
606-
return (
607-
`${indent}if(empty($GLOBALS['__evaled'][${trimmedPath}])){` +
608-
`$GLOBALS['__evaled'][${trimmedPath}]=1;` +
609-
`eval('?>' . file_get_contents(${trimmedPath}));}`
610-
);
611-
}
612-
);
613593
}
614594
const dbPhpPath = joinPaths(await php.documentRoot, 'wp-content/db.php');
615595
const SQLITE_MUPLUGIN_PATH =
@@ -635,20 +615,7 @@ if(file_exists(${phpVar(dbPhpPath)})) {
635615
}
636616
`;
637617

638-
if (phpMajor < 7) {
639-
// NOTE: No closing ?> tag — PHP 5.6 WASM has a parser bug
640-
// where ?> followed by <?php inside require_once'd files
641-
// corrupts the parser state.
642-
await php.writeFile(
643-
SQLITE_MUPLUGIN_PATH,
644-
'<?php\n' + dbPhpGuard + dbPhp.replace(/^<\?php\s*/, '')
645-
);
646-
} else {
647-
await php.writeFile(
648-
SQLITE_MUPLUGIN_PATH,
649-
`<?php\n${dbPhpGuard}?>` + dbPhp
650-
);
651-
}
618+
await php.writeFile(SQLITE_MUPLUGIN_PATH, `<?php\n${dbPhpGuard}?>` + dbPhp);
652619
await php.writeFile(
653620
`/internal/shared/preload/0-sqlite.php`,
654621
phpMajor < 7
@@ -699,22 +666,21 @@ if(!function_exists('mysqli_connect')) {
699666

700667
/**
701668
* Builds the 0-sqlite.php preload content for legacy PHP (< 7).
702-
* Uses eval() instead of require_once (PHP 5.6 WASM parser bug),
703-
* includes MySQL/MySQLi stubs, str_* polyfills, and error suppression.
669+
* Includes MySQL/MySQLi stubs, str_* polyfills, and error suppression.
704670
*/
705671
function buildLegacySqlitePreload(
706672
dbPhpGuard: string,
707673
muPluginPath: string,
708674
phpVarFn: typeof phpVar
709675
): string {
710676
return `<?php
711-
${dbPhpGuard}
677+
${dbPhpGuard}?>
678+
<?php
712679
${SQLITE_PRELOAD_LOADER_CLASS(
713-
// Legacy PHP: use eval() to work around WASM parser bug, and
714-
// call reinitialize_sqlite() for old WordPress (< 3.0) where
680+
// Call reinitialize_sqlite() for old WordPress (< 3.0) where
715681
// the SQLite plugin's db_connect() is never called because old
716682
// wpdb does mysql_connect() inline.
717-
`eval('?>' . file_get_contents(${phpVarFn(muPluginPath)}));
683+
`require_once ${phpVarFn(muPluginPath)};
718684
if (
719685
isset($GLOBALS['wpdb']) &&
720686
method_exists($GLOBALS['wpdb'], 'reinitialize_sqlite')

0 commit comments

Comments
 (0)