diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index 6fcd4ff6..7f002f2c 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -75,6 +75,25 @@ Raw Playground-oriented fields such as `runtime.blueprint`, `runtime.phpVersion` compatibility fields. Prefer the neutral `wordpress` backend and recipe inputs for new runtime setup where possible. +## Playground PHP bootstrap settings + +Playground disables opcache by default (`opcache.enable = 0` and +`opcache.enable_cli = 0`) in a `php.ini` mounted before PHP initializes. The +php-wasm file cache is slower than compiling scripts from source. To opt back +in, set `inputs.pluginRuntime.php.bootstrapIniEntries`, for example: + +```json +{ + "opcache.enable": "1", + "opcache.enable_cli": "1", + "opcache.file_cache": "/tmp/opcache" +} +``` + +Recipe bootstrap entries override these defaults. Use `bootstrapIniEntries`, +not runtime `iniEntries`, for opcache settings that must apply at PHP startup. +The defaults do not affect the choice of Playground runner. + ## Stable Field Names Recipe field names are case-sensitive. The current recipe schema accepts these diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 98e5648d..b2463463 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -358,25 +358,18 @@ export function shouldUseProgrammaticPlaygroundRunner(spec: RuntimeCreateSpec, o async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string }): Promise> { const iniEntries = runtimeBootstrapPhpIniEntries(spec) - if (!iniEntries && !requestWorkerEndpoint) { - return [] - } - const directory = join(spec.artifactsDirectory ?? "artifacts", "playground-internal-shared") await mkdir(directory, { recursive: true }) - if (iniEntries) { - await writeFile(join(directory, "php.ini"), phpIniContent(iniEntries), "utf8") - await writeFile(join(directory, "wp-codebox-auto-prepend.php"), runtimeAutoPrependPhp(spec), "utf8") - } + // Mount before PHP initializes so php-wasm's opcache-enabled defaults never apply. + await writeFile(join(directory, "php.ini"), phpIniContent(iniEntries ?? {}), "utf8") + await writeFile(join(directory, "wp-codebox-auto-prepend.php"), runtimeAutoPrependPhp(spec), "utf8") if (requestWorkerEndpoint) await writeFile(join(directory, "request-worker.php"), requestWorkerPhp(requestWorkerEndpoint.token), "utf8") const wpConfig = databaseBootstrapWpConfig(spec) if (wpConfig) await writeFile(join(directory, "wp-config.php"), wpConfig, "utf8") return [ - ...(iniEntries ? [ - { hostPath: join(directory, "php.ini"), vfsPath: "/internal/shared/php.ini" }, - { hostPath: join(directory, "wp-codebox-auto-prepend.php"), vfsPath: "/internal/shared/wp-codebox-auto-prepend.php" }, - ] : []), + { hostPath: join(directory, "php.ini"), vfsPath: "/internal/shared/php.ini" }, + { hostPath: join(directory, "wp-codebox-auto-prepend.php"), vfsPath: "/internal/shared/wp-codebox-auto-prepend.php" }, ...(requestWorkerEndpoint ? [ { hostPath: directory, vfsPath: "/internal/wp-codebox" }, { hostPath: join(directory, "request-worker.php"), vfsPath: `/wordpress${requestWorkerEndpoint.route}` }, @@ -489,7 +482,13 @@ function phpIniContent(entries: Record, autoPrependFile = "/inte "max_execution_time = 0", "max_input_time = -1", ] - for (const [name, value] of Object.entries(entries)) { + // Restoring cached scripts is slower than compiling source in php-wasm. + // Merge recipe overrides before serializing so each opcache setting occurs once. + for (const [name, value] of Object.entries({ + "opcache.enable": "0", + "opcache.enable_cli": "0", + ...entries, + })) { lines.push(`${name} = ${value}`) } diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index e3b28341..f3f7fa60 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -54,7 +54,7 @@ try { pluginRuntime: { php: { iniEntries: { memory_limit: "512M" }, - bootstrapIniEntries: { "opcache.file_cache": "/tmp/opcache" }, + bootstrapIniEntries: { "opcache.enable_cli": "1", "opcache.file_cache": "/tmp/opcache" }, }, }, }, @@ -110,6 +110,8 @@ try { assert.equal(typeof sharedAutoPrependPath, "string") const sharedPhpIni = await readFile(sharedPhpIniPath as string, "utf8") assert.match(sharedPhpIni, /opcache\.file_cache = \/tmp\/opcache/) + assert.deepEqual(sharedPhpIni.match(/^opcache\.enable_cli = .*$/gm), ["opcache.enable_cli = 1"], "recipe override is the only serialized value") + assert.match(sharedPhpIni, /^opcache\.enable = 0$/m) // The runtime default memory ceiling stays high enough for collect_artifacts to // base64 heavy snapshot/declared-artifact files without a hard PHP fatal. assert.match(sharedPhpIni, /memory_limit=512M/) @@ -217,13 +219,41 @@ try { await downloadedWordPressServer[Symbol.asyncDispose]() assert.equal(calls.length, 1) - assert.equal(calls[0]["mount-before-install"]?.length, 2) - assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/wp-codebox") - assert.match(calls[0]["mount-before-install"]?.[1]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) + assert.equal(calls[0]["mount-before-install"]?.length, 4) + assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/shared/php.ini") + assert.equal(calls[0]["mount-before-install"]?.[1]?.vfsPath, "/internal/shared/wp-codebox-auto-prepend.php") + assert.equal(calls[0]["mount-before-install"]?.[2]?.vfsPath, "/internal/wp-codebox") + assert.match(calls[0]["mount-before-install"]?.[3]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) assert.equal(calls[0].wordpressInstallMode, undefined) assert.equal(calls[0].workers, 1) assert.equal(shouldUseProgrammaticPlaygroundRunner(downloadedWordPressSpec), false) + // No pluginRuntime, extensions, or auto-prepend body: defaults must still be + // mounted without causing a mounted WordPress source to select the programmatic runner. + calls.length = 0 + const plainSpec: RuntimeCreateSpec = { + ...defaultRuntimeIniSpec, + environment: { ...defaultRuntimeIniSpec.environment, extensions: undefined }, + runtimeEnv: undefined, + secretEnv: undefined, + secretEnvTargets: undefined, + } + assert.equal(shouldUseProgrammaticPlaygroundRunner(plainSpec), false) + assert.equal(shouldUseProgrammaticPlaygroundRunner({ + ...plainSpec, + metadata: spec.metadata, + }), true, "explicit bootstrap entries still select the programmatic runner") + const plainServer = await startPlaygroundCliServer(plainSpec, [], { cliModule }) + await plainServer[Symbol.asyncDispose]() + const plainIniMount = calls[0]["mount-before-install"]?.find((mount) => mount.vfsPath === "/internal/shared/php.ini") + assert.ok(plainIniMount, "default php.ini is mounted before install") + const plainIni = await readFile(plainIniMount.hostPath, "utf8") + assert.match(plainIni, /^opcache\.enable = 0$/m) + assert.match(plainIni, /^opcache\.enable_cli = 0$/m) + const plainAutoPrependMount = calls[0]["mount-before-install"]?.find((mount) => mount.vfsPath === "/internal/shared/wp-codebox-auto-prepend.php") + assert.ok(plainAutoPrependMount) + assert.match(await readFile(plainAutoPrependMount.hostPath, "utf8"), /require_once '\/internal\/shared\/auto_prepend_file\.php'/) + calls.length = 0 const unmanagedInstallServer = await startPlaygroundCliServer({ ...downloadedWordPressSpec,