Skip to content

Commit 0da1f4f

Browse files
committed
Integrate PHP 5.2 support in web worker, CLI, and mu-plugins
Web worker: select pre-patched SQLite v2.2.22-php52 for legacy PHP, force single-instance mode for legacy PHP (PROXYFS parser state bug), disable WP_DEBUG for legacy WP (missing WP_DEBUG_DISPLAY). CLI: pass phpVersion through boot chain, select correct SQLite version, disable extensions for legacy PHP, add pre-built WordPress lookup. Mu-plugins: add PHP 5.2-compatible stub (named functions instead of closures), guard function_exists checks for WP < 3.0, use array() syntax for PHP 5.3 compatibility, handle Requests class variations. Skip update checks prefetch for legacy WordPress.
1 parent f9fe148 commit 0da1f4f

12 files changed

Lines changed: 230 additions & 50 deletions

File tree

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import type { PHP } from './php';
22

3+
/**
4+
* Reads the major PHP version from an instance's Emscripten runtime and
5+
* returns true for anything older than PHP 7. Used to decide whether the
6+
* PROXYFS mmap patch should apply — see {@link proxyFileSystem} for the
7+
* legacy-PHP rationale. Falls back to `false` when the runtime hasn't
8+
* populated phpVersion yet, matching the pre-refactor default behaviour.
9+
*/
10+
function isLegacyPhpInstance(phpInstance: PHP): boolean {
11+
const __private__symbol = Object.getOwnPropertySymbols(phpInstance)[0];
12+
// @ts-ignore
13+
const runtime = phpInstance[__private__symbol];
14+
const major: number | undefined = runtime?.phpVersion?.major;
15+
return typeof major === 'number' && major < 7;
16+
}
17+
318
/**
419
* Adds mmap support to PROXYFS for memory-mapping files across PHP instances.
520
*
@@ -136,8 +151,12 @@ function ensureProxyFSHasMmapSupport(phpInstance: PHP) {
136151
* For example, mounting /wordpress from the parent instance into a child worker allows
137152
* both to access the same WordPress installation without copying the entire directory.
138153
*
139-
* The function automatically patches PROXYFS with mmap support before mounting, ensuring
140-
* libraries like ICU can memory-map data files through the proxied filesystem.
154+
* The function automatically patches PROXYFS with mmap support before mounting on
155+
* PHP 7+, so libraries like ICU can memory-map data files through the proxied
156+
* filesystem. Legacy PHP (< 7) skips the mmap patch: its `zend_compile_file` trusts
157+
* stale fstat sizes on mmap'd streams and reads past the real EOF when the primary
158+
* has rewritten files after the secondary was created. PHP 7+ removed the mmap path
159+
* from `zend_stream_fixup` entirely so the patch is only needed there.
141160
*
142161
* Mounts are registered via php.mount() so they survive runtime rotation.
143162
* When the replica's WASM module is hot-swapped, hotSwapPHPRuntime()
@@ -152,6 +171,7 @@ export async function proxyFileSystem(
152171
replica: PHP,
153172
paths: string[]
154173
) {
174+
const replicaIsLegacy = isLegacyPhpInstance(replica);
155175
// We can't just import the symbol from the library because
156176
// Playground CLI is built as ESM and php-wasm-node is built as
157177
// CJS and the imported symbols will differ in the production build.
@@ -164,7 +184,9 @@ export async function proxyFileSystem(
164184
// after runtime rotation in hotSwapPHPRuntime().
165185
replica.mkdir(path);
166186
await replica.mount(path, (php: PHP) => {
167-
ensureProxyFSHasMmapSupport(php);
187+
if (!replicaIsLegacy) {
188+
ensureProxyFSHasMmapSupport(php);
189+
}
168190
const replicaSymbol = Object.getOwnPropertySymbols(php)[0];
169191
// @ts-ignore
170192
php[replicaSymbol].FS.mount(

‎packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { logger } from '@php-wasm/logger';
22
import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress';
33
import {
44
consumeAPI,
5+
isLegacyPHPVersion,
56
type Pooled,
67
type UniversalPHP,
78
} from '@php-wasm/universal';
@@ -116,7 +117,13 @@ export class BlueprintsV1Handler {
116117
sqliteIntegrationPluginZip = undefined;
117118
} else {
118119
this.cliOutput.updateProgress('Preparing SQLite database');
119-
sqliteIntegrationPluginZip = await fetchSqliteIntegration();
120+
// Use pre-patched v2.2.22 for legacy PHP (closures replaced
121+
// with named functions, PHP 5.2 polyfills added offline).
122+
const phpVersion = this.args.php || RecommendedPHPVersion;
123+
const isLegacyPhp = isLegacyPHPVersion(phpVersion);
124+
const sqliteVersion = isLegacyPhp ? 'v2.2.22-php52' : 'trunk';
125+
sqliteIntegrationPluginZip =
126+
await fetchSqliteIntegration(sqliteVersion);
120127
}
121128

122129
this.cliOutput.updateProgress('Booting WordPress');
@@ -130,6 +137,7 @@ export class BlueprintsV1Handler {
130137
playground as unknown as PlaygroundCliBlueprintV1Worker
131138
).bootWordPress(
132139
{
140+
phpVersion: runtimeConfiguration.phpVersion,
133141
wpVersion: runtimeConfiguration.wpVersion,
134142
siteUrl: this.siteUrl,
135143
wordpressInstallMode:

‎packages/playground/cli/src/blueprints-v1/download.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import path, { basename } from 'path';
66

77
export const CACHE_FOLDER = path.join(os.homedir(), '.wordpress-playground');
88

9-
export async function fetchSqliteIntegration(): Promise<File> {
9+
export async function fetchSqliteIntegration(
10+
version: 'trunk' | 'v2.1.16' | 'v2.2.22' | 'v2.2.22-php52' = 'trunk'
11+
): Promise<File> {
1012
// Production builds: the ZIP sits next to the bundled JS.
1113
const dir =
1214
typeof __dirname !== 'undefined' ? __dirname : import.meta.dirname;
@@ -22,7 +24,7 @@ export async function fetchSqliteIntegration(): Promise<File> {
2224
wpBuildsDir,
2325
'src',
2426
'sqlite-database-integration',
25-
'sqlite-database-integration-trunk.zip'
27+
`sqlite-database-integration-${version}.zip`
2628
);
2729
}
2830

‎packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { FileLockManager } from '@php-wasm/universal';
22
import { loadNodeRuntime } from '@php-wasm/node';
33
import { EmscriptenDownloadMonitor } from '@php-wasm/progress';
4-
import type { PathAlias, SupportedPHPVersion } from '@php-wasm/universal';
4+
import type { AllPHPVersion, PathAlias } from '@php-wasm/universal';
55
import {
66
PHPWorker,
77
releaseApiProxy,
@@ -27,6 +27,7 @@ import type { Mount } from '@php-wasm/cli-util';
2727

2828
export type WorkerBootWordPressOptions = {
2929
siteUrl: string;
30+
phpVersion?: string;
3031
wpVersion?: string;
3132
wordpressInstallMode: WordPressInstallMode;
3233
wordPressZip?: ArrayBuffer;
@@ -40,7 +41,7 @@ export type WorkerBootWordPressOptions = {
4041

4142
interface WorkerBootRequestHandlerOptions {
4243
siteUrl: string;
43-
phpVersion: SupportedPHPVersion;
44+
phpVersion: AllPHPVersion;
4445
processId: number;
4546
trace: boolean;
4647
nativeInternalDirPath: string;
@@ -102,6 +103,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker {
102103
this.bootedWordPress = true;
103104
const {
104105
siteUrl,
106+
phpVersion,
105107
wordpressInstallMode,
106108
wordPressZip,
107109
sqliteIntegrationPluginZip,
@@ -112,6 +114,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker {
112114
try {
113115
await bootWordPress(this.__internal_getRequestHandler()!, {
114116
siteUrl,
117+
phpVersion,
115118
wordpressInstallMode,
116119
wordPressZip:
117120
wordPressZip !== undefined
@@ -161,6 +164,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker {
161164
try {
162165
const requestHandler = await bootRequestHandler({
163166
siteUrl: options.siteUrl,
167+
phpVersion: options.phpVersion,
164168
maxPhpInstances: 1,
165169
createPhpRuntime: createPhpRuntimeFactory(
166170
options,

‎packages/playground/cli/src/run-cli.ts‎

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type PHPRequest,
77
type PathAlias,
88
type RemoteAPI,
9-
type SupportedPHPVersion,
9+
type AllPHPVersion,
1010
} from '@php-wasm/universal';
1111
import {
1212
PHPResponse,
@@ -46,6 +46,8 @@ import type { PlaygroundCliBlueprintV2Worker } from './blueprints-v2/worker-thre
4646
import type { XdebugOptions } from '@php-wasm/node';
4747
/* eslint-disable no-console */
4848
import {
49+
AllPHPVersions,
50+
isLegacyPHPVersion,
4951
SupportedPHPVersions,
5052
FileLockManagerInMemory,
5153
} from '@php-wasm/universal';
@@ -118,7 +120,7 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
118120
describe: 'PHP version to use.',
119121
type: 'string',
120122
default: RecommendedPHPVersion,
121-
choices: SupportedPHPVersions,
123+
choices: AllPHPVersions,
122124
},
123125
wp: {
124126
describe: 'WordPress version to use.',
@@ -373,7 +375,7 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
373375
describe: 'PHP version to use.',
374376
type: 'string',
375377
default: RecommendedPHPVersion,
376-
choices: SupportedPHPVersions,
378+
choices: AllPHPVersions,
377379
},
378380
wp: {
379381
describe: 'WordPress version to use.',
@@ -662,14 +664,24 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
662664
const hasDebugDefine = (name: string) => {
663665
return name in define || name in defineBool || name in defineNumber;
664666
};
665-
if (!hasDebugDefine('WP_DEBUG')) {
666-
define['WP_DEBUG'] = 'true';
667-
}
668-
if (!hasDebugDefine('WP_DEBUG_LOG')) {
669-
define['WP_DEBUG_LOG'] = 'true';
670-
}
671-
if (!hasDebugDefine('WP_DEBUG_DISPLAY')) {
672-
define['WP_DEBUG_DISPLAY'] = 'false';
667+
// Don't default WP_DEBUG* on for legacy PHP: old WordPress
668+
// (pre-2.3) prints E_NOTICE output before headers are sent,
669+
// which corrupts redirects and breaks the installer. The web
670+
// worker path applies the same gate in
671+
// @wp-playground/client/src/blueprints-v1-handler.ts.
672+
const phpVersionForDebug = (args['php'] ||
673+
RecommendedPHPVersion) as AllPHPVersion;
674+
const isLegacyPhpForDebug = isLegacyPHPVersion(phpVersionForDebug);
675+
if (!isLegacyPhpForDebug) {
676+
if (!hasDebugDefine('WP_DEBUG')) {
677+
define['WP_DEBUG'] = 'true';
678+
}
679+
if (!hasDebugDefine('WP_DEBUG_LOG')) {
680+
define['WP_DEBUG_LOG'] = 'true';
681+
}
682+
if (!hasDebugDefine('WP_DEBUG_DISPLAY')) {
683+
define['WP_DEBUG_DISPLAY'] = 'false';
684+
}
673685
}
674686

675687
const cliArgs = {
@@ -736,9 +748,7 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
736748
currentError = currentError.cause as Error;
737749
} while (currentError instanceof Error);
738750
console.error(
739-
'\x1b[1m' +
740-
messageChain.join(' caused by: ') +
741-
'\x1b[0m'
751+
'\x1b[1m' + messageChain.join(' caused by: ') + '\x1b[0m'
742752
);
743753
}
744754
} else {
@@ -811,7 +821,7 @@ export interface RunCLIArgs {
811821
mount?: Mount[];
812822
'mount-before-install'?: Mount[];
813823
outfile?: string;
814-
php?: SupportedPHPVersion;
824+
php?: AllPHPVersion;
815825
port?: number;
816826
'site-url'?: string;
817827
quiet?: boolean;
@@ -990,6 +1000,15 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> {
9901000
args.memcached = await jspi();
9911001
}
9921002

1003+
// Disable all extensions for legacy PHP versions — they're not available.
1004+
const isLegacyPhp = isLegacyPHPVersion(args.php || RecommendedPHPVersion);
1005+
if (isLegacyPhp) {
1006+
args.intl = false;
1007+
args.redis = false;
1008+
args.memcached = false;
1009+
args.xdebug = false;
1010+
}
1011+
9931012
// Setup phpMyAdmin if enabled.
9941013
if (args.phpmyadmin) {
9951014
if (true === args.phpmyadmin) {
@@ -1098,10 +1117,11 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> {
10981117
vfsPath: '/',
10991118
};
11001119

1120+
const phpVer = args.php || RecommendedPHPVersion;
11011121
const isPHP85orHigher =
1102-
SupportedPHPVersions.indexOf(
1103-
args.php || RecommendedPHPVersion
1104-
) <= SupportedPHPVersions.indexOf('8.5');
1122+
SupportedPHPVersions.includes(phpVer as any) &&
1123+
SupportedPHPVersions.indexOf(phpVer as any) <=
1124+
SupportedPHPVersions.indexOf('8.5');
11051125

11061126
// And, if PHP >= 8.5, add the new Xdebug config.
11071127
if (isPHP85orHigher) {

‎packages/playground/cli/tests/run-cli.spec.ts‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,11 @@ describe.each(blueprintVersions)(
171171
test('should set WordPress version', async () => {
172172
const { MinifiedWordPressVersionsList } =
173173
await import('@wp-playground/wordpress-builds');
174-
const oldestSupportedVersion =
175-
MinifiedWordPressVersionsList[
176-
MinifiedWordPressVersionsList.length - 1
177-
];
174+
// Use the oldest non-legacy version. Legacy versions
175+
// (< 5.0) require legacy PHP and can't boot on modern PHP.
176+
const oldestSupportedVersion = MinifiedWordPressVersionsList.filter(
177+
(v) => parseFloat(v) >= 5
178+
).pop()!;
178179
await using cliServer = await runCLI({
179180
...suiteCliArgs,
180181
command: 'server',
@@ -339,10 +340,7 @@ describe.each(blueprintVersions)(
339340

340341
const mounts = [];
341342
for (let i = 0; i < 5; i++) {
342-
const hostSubDir = path.join(
343-
hostTmpDir,
344-
`migration-${i}`
345-
);
343+
const hostSubDir = path.join(hostTmpDir, `migration-${i}`);
346344
mkdirSync(hostSubDir, { recursive: true });
347345
const hostFilePath = path.join(
348346
hostSubDir,

‎packages/playground/client/src/blueprints-v1-handler.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,15 @@ export class BlueprintsV1Handler {
8787

8888
/**
8989
* Pre-fetch WordPress update checks to speed up the initial wp-admin load.
90+
* Skip for old WordPress versions — the functions called by prefetch
91+
* (wp_check_php_version, wp_update_plugins, etc.) don't exist or crash
92+
* on legacy WP, and the resulting PHP errors create noise.
9093
*
9194
* @see https://github.com/WordPress/wordpress-playground/pull/2295
9295
*/
93-
if (runtimeConfiguration.networking) {
96+
const wpMajor = parseFloat(runtimeConfiguration.wpVersion);
97+
const isLegacyWpVersion = Number.isFinite(wpMajor) && wpMajor < 5;
98+
if (runtimeConfiguration.networking && !isLegacyWpVersion) {
9499
await playground.prefetchUpdateChecks();
95100
}
96101

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
/**
3+
* PHP 5.2-compatible minimal version of 0-playground.php.
4+
*
5+
* PHP 5.2 does not support anonymous functions (closures), so this
6+
* file replaces the full 0-playground.php for PHP 5.2 only. It
7+
* provides the essential HTTP transport setup and WP Cron disable
8+
* using named functions throughout.
9+
*/
10+
11+
// WordPress < 3.0 can't handle hook callbacks via add_action/add_filter
12+
// without an initialized $wp_filter. Check it's safe to register hooks.
13+
if (!function_exists('add_action') || !function_exists('add_filter')) {
14+
return;
15+
}
16+
17+
/**
18+
* Set up the HTTP transport. On PHP 5.2 we still need to register a
19+
* Fetch or Dummy transport so that wp_remote_*() calls don't crash.
20+
*/
21+
// PHP 5.2 always uses the Dummy transport. wp_http_dummy.php and
22+
// wp_http_fetch.php both use PHP 5.3+ namespace syntax that causes
23+
// parse errors on PHP 5.2, so define the transport class inline here.
24+
if (!class_exists('Wp_Http_Dummy')) {
25+
/**
26+
* Minimal dummy HTTP transport for PHP 5.2.
27+
* Does not perform any HTTP requests; just satisfies WP_Http.
28+
*/
29+
class Wp_Http_Dummy {
30+
public $headers = '';
31+
public function __construct() {}
32+
public function request($url, $headers = array(), $data = array(), $options = array()) {
33+
return false;
34+
}
35+
public function request_multiple($requests, $options) {
36+
$responses = array();
37+
foreach ($requests as $id => $request) {
38+
$responses[$id] = false;
39+
}
40+
return $responses;
41+
}
42+
public static function test($capabilities = array()) {
43+
return true;
44+
}
45+
}
46+
}
47+
$__requests_class = class_exists('Requests') ? 'Requests' : null;
48+
if ($__requests_class) {
49+
call_user_func(array($__requests_class, 'add_transport'), 'Wp_Http_Dummy');
50+
}
51+
add_action('requests-requests.before_request', '_pg52_set_dummy_transport', 10, 5);
52+
add_filter('http_api_transports', '_pg52_dummy_transports');
53+
54+
function _pg52_set_dummy_transport($url, $headers, $data, $type, &$options) {
55+
$options['transport'] = 'Wp_Http_Dummy';
56+
}
57+
58+
function _pg52_dummy_transports() {
59+
return array('Dummy');
60+
}
61+
62+
// Disable WP Cron on legacy WordPress only. On PHP 5.2 the HTTP API
63+
// is stubbed with Wp_Http_Dummy (see above), so every spawn-cron
64+
// request would return false and WordPress would quietly retry
65+
// forever. Short-circuit the /wp-cron.php endpoint so nothing loops.
66+
//
67+
// Modern 0-playground.php intentionally does NOT define this: on PHP
68+
// 7+ the Fetch transport works, so WP Cron can run for real. Keep
69+
// this block legacy-only.
70+
define('DISABLE_WP_CRON', true);
71+
if (substr($_SERVER['PHP_SELF'], -12) === '/wp-cron.php') {
72+
header('HTTP/1.1 503 Service Unavailable');
73+
header('Content-Type: text/plain');
74+
echo 'WP Cron is temporarily disabled in the Playground.';
75+
exit;
76+
}

0 commit comments

Comments
 (0)