Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/commands/recipe-run-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ function classifyRecipePhaseFailure(phase: string): string {
return "startup"
case "mount_plugins":
return "plugin_mount"
case "materialize_runtime_inputs":
return "mount_materialization"
case "activate_plugins":
return "plugin_activation"
case "import_fixture_databases":
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/recipe-run-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export interface RecipeDiagnosticArtifactRef {

export type RecipePhasedPluginInputStage = "collect" | "project" | "resolve" | "mount" | "activate" | "readiness"

export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "activate_plugins" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts"
export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "materialize_runtime_inputs" | "activate_plugins" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts"

export interface RecipePhaseEvidence {
schema: "wp-codebox/recipe-phase-evidence/v1"
Expand Down
128 changes: 69 additions & 59 deletions packages/cli/src/commands/recipe-runtime-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,71 +176,70 @@ export async function applyRecipeRuntimeSetup(args: {
const extraPluginMounts = preparedExtraPluginMounts(extraPlugins)
await mountPreparedExtraPlugins(runtime, extraPlugins, extraPluginMounts, phaseExecutor, interruption, "mount_plugins")

for (const overlay of overlayCopies) {
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(copyRuntimeOverlayCode(overlay.source, overlay.target)) }), "setup", -3, `runtime.overlay.copy:${overlay.target}`))
interruption?.throwIfInterrupted()
}
await phaseTracker.run("materialize_runtime_inputs", phaseRuntimeInputData(recipe, extraPlugins, stagedFiles, dependencyOverlays), async () => {
for (const overlay of overlayCopies) {
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(copyRuntimeOverlayCode(overlay.source, overlay.target)) }), "setup", -3, `runtime.overlay.copy:${overlay.target}`))
interruption?.throwIfInterrupted()
}

for (const overlay of dependencyOverlays) {
await awaitRecipe(`dependency-overlay.mount:${overlay.package}`, runtime.mount({
type: overlay.type,
source: overlay.source,
target: overlay.target,
mode: overlay.mode,
metadata: overlay.metadata,
}))
interruption?.throwIfInterrupted()
}
for (const overlay of dependencyOverlays) {
await awaitRecipe(`dependency-overlay.mount:${overlay.package}`, runtime.mount({
type: overlay.type,
source: overlay.source,
target: overlay.target,
mode: overlay.mode,
metadata: overlay.metadata,
}))
interruption?.throwIfInterrupted()
}

const inputMounts: MountSpec[] = []
for (const [index, mount] of (recipe.inputs?.mounts ?? []).entries()) {
const source = resolve(recipeDirectory, mount.source)
const target = inputMountPathMap[index]?.canonicalTarget ?? mount.target
const metadata = await inputMountMetadataWithBaseline(source, mount, inputMountBaselinePaths, target)
const inputMount: MountSpec = {
type: await recipeMountType(source, mount.type),
source,
target,
mode: mount.mode ?? "readwrite",
...(mount.captureArtifacts !== undefined ? { captureArtifacts: mount.captureArtifacts } : {}),
...(mount.phase !== undefined ? { phase: mount.phase } : {}),
metadata,
const inputMounts: MountSpec[] = []
for (const [index, mount] of (recipe.inputs?.mounts ?? []).entries()) {
const source = resolve(recipeDirectory, mount.source)
const target = inputMountPathMap[index]?.canonicalTarget ?? mount.target
const metadata = await inputMountMetadataWithBaseline(source, mount, inputMountBaselinePaths, target)
const inputMount: MountSpec = {
type: await recipeMountType(source, mount.type),
source,
target,
mode: mount.mode ?? "readwrite",
...(mount.captureArtifacts !== undefined ? { captureArtifacts: mount.captureArtifacts } : {}),
...(mount.phase !== undefined ? { phase: mount.phase } : {}),
metadata,
}
inputMounts.push(inputMount)
await awaitRecipe(`input.mount:${mount.target}`, runtime.mount(inputMount))
interruption?.throwIfInterrupted()
}
inputMounts.push(inputMount)
await awaitRecipe(`input.mount:${mount.target}`, runtime.mount(inputMount))
interruption?.throwIfInterrupted()
}

for (const stagedFile of stagedFiles) {
await awaitRecipe(`staged-file.mount:${stagedFile.target}`, runtime.mount({
type: stagedFile.type,
source: stagedFile.source,
target: stagedFile.target,
mode: "readwrite",
metadata: stagedFile.metadata,
}))
interruption?.throwIfInterrupted()
}
for (const stagedFile of stagedFiles) {
await awaitRecipe(`staged-file.mount:${stagedFile.target}`, runtime.mount({
type: stagedFile.type,
source: stagedFile.source,
target: stagedFile.target,
mode: "readwrite",
metadata: stagedFile.metadata,
}))
interruption?.throwIfInterrupted()
}

const materializableMounts: MountSpec[] = [
...extraPluginMounts,
...inputMounts,
...stagedFiles.map((stagedFile) => ({
type: stagedFile.type,
source: stagedFile.source,
target: stagedFile.target,
mode: "readwrite" as const,
metadata: stagedFile.metadata,
})),
]
if (materializableMounts.length > 0 && canMaterializeMounts(runtime)) {
await awaitRecipe("input.materialize", () => materializePreparedMounts(runtime, materializableMounts))
interruption?.throwIfInterrupted()
}
const materializableMounts: MountSpec[] = [
...extraPluginMounts,
...inputMounts,
...stagedFiles.map((stagedFile) => ({
type: stagedFile.type,
source: stagedFile.source,
target: stagedFile.target,
mode: "readwrite" as const,
metadata: stagedFile.metadata,
})),
]
if (materializableMounts.length > 0 && canMaterializeMounts(runtime)) {
await awaitRecipe("input.materialize", () => materializePreparedMounts(runtime, materializableMounts))
interruption?.throwIfInterrupted()
}
})

// Discovery inventories mounted files only. Running setup PHP here would
// activate dependencies before the discovery command can enforce its
// no-bootstrap boundary.
if (recipeHasPhpunitDiscoveryOnly(recipe)) {
return { executions }
}
Expand Down Expand Up @@ -533,6 +532,17 @@ function phasePluginMountData(extraPlugins: PreparedExtraPlugin[]): Record<strin
}
}

function phaseRuntimeInputData(recipe: WorkspaceRecipe, extraPlugins: PreparedExtraPlugin[], stagedFiles: PreparedStagedFile[], dependencyOverlays: PreparedDependencyOverlay[]): Record<string, unknown> {
const inputMounts = recipe.inputs?.mounts ?? []
return {
extraPluginCount: extraPlugins.length,
inputMountCount: inputMounts.length,
stagedFileCount: stagedFiles.length,
dependencyOverlayCount: dependencyOverlays.length,
inputMounts: inputMounts.map((mount) => ({ target: mount.target, mode: mount.mode ?? "readwrite", type: mount.type })),
}
}

function phasePluginActivationData(activatedPlugins: PreparedExtraPlugin[]): Record<string, unknown> {
return {
count: activatedPlugins.length,
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-playground/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export { createHostCommandTool, type HostCommandToolConfig } from "./host-comman
export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js"
export { maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, type PlaygroundCustomArchiveCacheMaintenance, type PlaygroundCustomArchiveCacheMaintenanceOptions, type PlaygroundCustomArchiveCachePolicy } from "./playground-wordpress-archive-cache.js"
export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collectWordPressRuntimeArtifacts, createWordPressEpisode, createWordPressRuntime, runWordPressEpisodeActions, type WordPressEpisodeSpec, type WordPressRuntimeActionHooks, type WordPressRuntimeSpec } from "./public.js"
export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js"
export { preflightPhpWasmRuntimeAssets, assertPhpWasmExtensionAbi, phpWasmExtensionMissingAbiSymbols, PhpWasmRuntimeAssetIntegrityError, PhpWasmExtensionAbiError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js"
export { assertPlaywrightBrowserReady, playwrightBrowserProvenance, playwrightBrowserReadiness, type PlaywrightBrowserProvenance, type PlaywrightBrowserReadiness } from "./playwright-browser-provenance.js"
export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, browserPreviewOrigins, resolveBrowserPreviewUrl, type BrowserPreviewNetworkPolicy, type BrowserPreviewTopology } from "./browser-preview-routing.js"
export { BROWSER_TRANSPORT_FAULT_CAPABILITIES, applyBrowserTransportFault, browserTransportFaultReport, createBrowserTransportFaultAdapter, installBrowserTransportFaults, type BrowserTransportFaultAdapter, type BrowserTransportFaultInstallOptions, type BrowserTransportFaultReport, type BrowserTransportFaultTeardown, type InstalledBrowserTransportFaults } from "./browser-transport-faults.js"
Expand Down
89 changes: 89 additions & 0 deletions packages/runtime-playground/src/php-wasm-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,95 @@ export async function assertPhpWasmExternalExtensionsSupported(extensions: reado
}
}

const PHP_ABI_IMPORT_PATTERN = /^(php_|zend_|convert_to_|_emalloc|_efree|_estrdup|_safe_emalloc)/
const phpWasmExportCache = new Map<string, Set<string>>()

export class PhpWasmExtensionAbiError extends Error {
readonly code = "wp-codebox-php-wasm-extension-abi-mismatch"
readonly diagnostic: { extension: string; phpVersion: string; missingSymbols: string[] }

constructor(diagnostic: { extension: string; phpVersion: string; missingSymbols: string[] }) {
const message = `PHP.wasm extension '${diagnostic.extension}' imports symbols the ${diagnostic.phpVersion} runtime does not export: ${diagnostic.missingSymbols.join(", ")}.`
super(message)
this.name = "PhpWasmExtensionAbiError"
this.diagnostic = diagnostic
}
}

export function phpWasmExtensionMissingAbiSymbols(extensionWasm: Uint8Array, phpExportNames: Iterable<string>): string[] {
const phpExports = phpExportNames instanceof Set ? phpExportNames : new Set(phpExportNames)
const imports = WebAssembly.Module.imports(webAssemblyModuleFromBytes(extensionWasm))
return [...new Set(imports
.filter((entry) => entry.module === "env" && entry.kind === "function" && PHP_ABI_IMPORT_PATTERN.test(entry.name) && !phpExports.has(entry.name))
.map((entry) => entry.name))].sort()
}

function webAssemblyModuleFromBytes(bytes: Uint8Array): WebAssembly.Module {
const copy = new Uint8Array(bytes.byteLength)
copy.set(bytes)
return new WebAssembly.Module(copy)
}

export async function assertPhpWasmExtensionAbi(options: {
extensions?: ReadonlyArray<{ manifest: string }>
phpVersion: string
phpWasmPath: string
mode?: "jspi" | "asyncify"
}): Promise<void> {
if (!options.extensions || options.extensions.length === 0) {
return
}

const phpExports = await phpWasmExportNames(options.phpWasmPath)
const mode = options.mode ?? "jspi"
for (const extension of options.extensions) {
const artifactPath = await resolveExtensionArtifactPath(extension.manifest, options.phpVersion, mode)
if (!artifactPath) continue
const missingSymbols = phpWasmExtensionMissingAbiSymbols(await readFile(artifactPath), phpExports)
if (missingSymbols.length > 0) {
throw new PhpWasmExtensionAbiError({
extension: extension.manifest,
phpVersion: options.phpVersion,
missingSymbols,
})
}
}
}

async function phpWasmExportNames(phpWasmPath: string): Promise<Set<string>> {
const cached = phpWasmExportCache.get(phpWasmPath)
if (cached) return cached
const names = new Set(WebAssembly.Module.exports(webAssemblyModuleFromBytes(await readFile(phpWasmPath))).map((entry) => entry.name))
phpWasmExportCache.set(phpWasmPath, names)
return names
}

async function resolveExtensionArtifactPath(manifestPath: string, phpVersion: string, mode: "jspi" | "asyncify"): Promise<string | undefined> {
if (!existsSync(manifestPath)) {
throw new PhpWasmExtensionAbiError({
extension: manifestPath,
phpVersion,
missingSymbols: [`missing-manifest:${manifestPath}`],
})
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { artifacts?: Array<{ phpVersion?: unknown; sourcePath?: unknown }> }
const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : []
const matching = artifacts.filter((artifact) => artifact.phpVersion === phpVersion && typeof artifact.sourcePath === "string")
const preferred = matching.find((artifact) => String(artifact.sourcePath).includes(mode)) ?? matching[0]
if (!preferred || typeof preferred.sourcePath !== "string") {
return undefined
}
const artifactPath = join(dirname(manifestPath), preferred.sourcePath)
if (!existsSync(artifactPath)) {
throw new PhpWasmExtensionAbiError({
extension: manifestPath,
phpVersion,
missingSymbols: [`missing-artifact:${preferred.sourcePath}`],
})
}
return artifactPath
}

const repairHint = "Repair the PHP wasm runtime package by reinstalling dependencies, for example: remove node_modules and package-lock drift, then run npm install; if using a package cache, clear the broken @php-wasm package cache first."
const compiledWasmCache = new Map<string, PhpWasmRuntimeAssetPreflight>()
const requireFromHere = createRequire(import.meta.url)
Expand Down
8 changes: 7 additions & 1 deletion packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { runAbilityCommand, runAdminActionInventoryCommand, runBenchCommand, run
import { PlaygroundSnapshotRestoreError, contentDigest, mountsFromSnapshot, runtimeSnapshotExportPayload, runtimeSnapshotExportPhp, runtimeSnapshotPayload, runtimeSnapshotRestorePhp, runtimeSpecFromSnapshot, snapshotDigest, type RuntimeSnapshotArtifact, type RuntimeSnapshotExportOptions } from "./runtime-snapshot.js"
import { createRuntimeWpCliBridge, type RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js"
import { writeReplayExportPackage } from "./replayable-wordpress-site-bundle.js"
import { preflightPhpWasmRuntimeAssets } from "./php-wasm-preflight.js"
import { assertPhpWasmExtensionAbi, preflightPhpWasmRuntimeAssets } from "./php-wasm-preflight.js"
import { previewReviewerAccess } from "./preview-reviewer-access.js"
import { installHostHttpTransportRoute } from "./host-http-transport.js"
import { wordpressActionAuthNoncePhpCode, wordpressFixtureUserWithoutPassword, wordpressUserSessionFromCommandArgs, type WordPressUserSessionResolution } from "./wordpress-user-sessions.js"
Expand Down Expand Up @@ -272,6 +272,12 @@ class PlaygroundRuntime implements Runtime {

static async create(spec: RuntimeCreateSpec, options: PlaygroundRuntimeBackendOptions = {}): Promise<PlaygroundRuntime> {
const phpWasmRuntimeAssetPreflight = await preflightPhpWasmRuntimeAssets({ phpVersion: spec.environment.phpVersion })
await assertPhpWasmExtensionAbi({
extensions: spec.environment.extensions,
phpVersion: phpWasmRuntimeAssetPreflight.phpVersion,
phpWasmPath: phpWasmRuntimeAssetPreflight.wasmPath,
mode: phpWasmRuntimeAssetPreflight.mode,
})
const runtime = new PlaygroundRuntime({
...spec,
metadata: {
Expand Down
58 changes: 58 additions & 0 deletions tests/php-wasm-extension-abi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"

import { assertPhpWasmExtensionAbi, PhpWasmExtensionAbiError, phpWasmExtensionMissingAbiSymbols } from "../packages/runtime-playground/src/php-wasm-preflight.js"

function wasmImporting(moduleName: string, importName: string): Uint8Array {
const moduleBytes = Buffer.from(moduleName, "utf8")
const nameBytes = Buffer.from(importName, "utf8")
const importPayload = Buffer.concat([
Buffer.from([1]),
Buffer.from([moduleBytes.length]),
moduleBytes,
Buffer.from([nameBytes.length]),
nameBytes,
Buffer.from([0, 0]),
])
return Buffer.concat([
Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]),
Buffer.from([0x01, 0x04, 0x01, 0x60, 0x00, 0x00]),
Buffer.from([0x02, importPayload.length]),
importPayload,
])
}

const sodiumLike = wasmImporting("env", "php_password_algo_register")
assert.deepEqual(phpWasmExtensionMissingAbiSymbols(sodiumLike, []), ["php_password_algo_register"])
assert.deepEqual(phpWasmExtensionMissingAbiSymbols(sodiumLike, ["php_password_algo_register"]), [])
assert.deepEqual(phpWasmExtensionMissingAbiSymbols(wasmImporting("env", "__assert_fail"), []), [])
assert.deepEqual(phpWasmExtensionMissingAbiSymbols(wasmImporting("env", "_emalloc_448"), ["_emalloc", "_emalloc_128"]), ["_emalloc_448"])

const root = await mkdtemp(join(tmpdir(), "wp-codebox-php-wasm-abi-"))
const phpWasmPath = join(root, "php.wasm")
const manifestDir = join(root, "sodium")
const manifestPath = join(manifestDir, "manifest.json")
const artifactPath = join(manifestDir, "sodium-php8.4-jspi.so")
await mkdir(manifestDir)
await writeFile(phpWasmPath, wasmImporting("env", "unused"))
await writeFile(artifactPath, sodiumLike)
await writeFile(manifestPath, JSON.stringify({
name: "sodium",
artifacts: [{ phpVersion: "8.4", sourcePath: "sodium-php8.4-jspi.so" }],
}))
await assert.rejects(
assertPhpWasmExtensionAbi({
extensions: [{ manifest: manifestPath }],
phpVersion: "8.4",
phpWasmPath,
mode: "jspi",
}),
(error: unknown) => error instanceof PhpWasmExtensionAbiError
&& error.code === "wp-codebox-php-wasm-extension-abi-mismatch"
&& error.diagnostic.missingSymbols.includes("php_password_algo_register"),
)
await rm(root, { recursive: true, force: true })

console.log("php wasm extension abi ok")
Loading
Loading