diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index aa93f301f..f4cd059d5 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -5,6 +5,7 @@ on: paths: - ".github/scripts/run-agent-task/**" - ".github/workflows/agent-task-contracts.yml" + - ".github/workflows/build-php-wasm-mysqli-poll-overlay.yml" - ".github/workflows/run-agent-task.yml" - "contracts/agent-task-workflow-request.fixture.json" - "contracts/run-agent-task-reusable-workflow-interface.v1.json" @@ -48,10 +49,12 @@ on: - "packages/runtime-core/src/bounded-runtime-plan.ts" - "tests/bounded-*.test.ts" - "tests/disposable-mysql-mysqli.integration.test.ts" + - "tests/mysqli-poll.integration.test.ts" push: paths: - ".github/scripts/run-agent-task/**" - ".github/workflows/agent-task-contracts.yml" + - ".github/workflows/build-php-wasm-mysqli-poll-overlay.yml" - ".github/workflows/run-agent-task.yml" - "contracts/agent-task-workflow-request.fixture.json" - "contracts/run-agent-task-reusable-workflow-interface.v1.json" @@ -95,6 +98,7 @@ on: - "packages/runtime-core/src/bounded-runtime-plan.ts" - "tests/bounded-*.test.ts" - "tests/disposable-mysql-mysqli.integration.test.ts" + - "tests/mysqli-poll.integration.test.ts" jobs: contracts: @@ -132,6 +136,7 @@ jobs: - run: npm run test:bounded-recipe-plan-integration - run: npm run test:recipe-step-continuation - run: npm run test:disposable-mysql-mysqli-e2e + - run: npm run test:mysqli-poll-integration - run: npm run test:runtime-sources-playground-integration - run: npm run test:playground-phpunit-readonly-cache-integration - run: npm run test:playground-custom-archive-cache @@ -152,4 +157,4 @@ jobs: - uses: actions/checkout@v4 - uses: docker://rhysd/actionlint:1.7.7 with: - args: .github/workflows/agent-task-contracts.yml .github/workflows/run-agent-task.yml + args: .github/workflows/agent-task-contracts.yml .github/workflows/build-php-wasm-mysqli-poll-overlay.yml .github/workflows/run-agent-task.yml diff --git a/.github/workflows/build-php-wasm-mysqli-poll-overlay.yml b/.github/workflows/build-php-wasm-mysqli-poll-overlay.yml new file mode 100644 index 000000000..43a1fc8ef --- /dev/null +++ b/.github/workflows/build-php-wasm-mysqli-poll-overlay.yml @@ -0,0 +1,89 @@ +name: Build PHP-WASM mysqli_poll Overlay + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-verify: + runs-on: ubuntu-latest + timeout-minutes: 300 + env: + PLAYGROUND_SHA: 581c7c172428159eb4e6c5309054a568cd39a97a + PLAYGROUND_ARCHIVE_SHA256: b9526f0fb0839cb255d0ae8bb815c548aafe603a3a49b1b8701442a0c1afca8e + PATCH_SHA: d746c930477eae3d726f754753eb92692ad30855 + PATCH_SHA256: f42ffb44f21e736c3fbf6e504e01e4a07207f8d764c38ce1f9c81db80d614a04 + PHP_VERSION: "8.3.32" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Reclaim runner disk + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc + docker system prune --all --force + + - name: Materialize pinned Playground source + working-directory: ${{ runner.temp }} + run: | + curl --fail --location --retry 3 --output playground.tar.gz "https://github.com/WordPress/wordpress-playground/archive/${PLAYGROUND_SHA}.tar.gz" + test "$(shasum -a 256 playground.tar.gz | cut -d ' ' -f 1)" = "${PLAYGROUND_ARCHIVE_SHA256}" + curl --fail --location --retry 3 --output mysqli-poll.patch "https://github.com/WordPress/wordpress-playground/commit/${PATCH_SHA}.patch" + test "$(shasum -a 256 mysqli-poll.patch | cut -d ' ' -f 1)" = "${PATCH_SHA256}" + mkdir wordpress-playground + tar -xzf playground.tar.gz --strip-components=1 -C wordpress-playground + git -C wordpress-playground apply --check --include=packages/php-wasm/compile/php/Dockerfile --include=packages/php-wasm/compile/php/php_wasm.c ../mysqli-poll.patch + git -C wordpress-playground apply --include=packages/php-wasm/compile/php/Dockerfile --include=packages/php-wasm/compile/php/php_wasm.c ../mysqli-poll.patch + + - name: Install Playground build dependencies + working-directory: ${{ runner.temp }}/wordpress-playground + run: npm ci + + - name: Compile PHP 8.3 Node runtimes + working-directory: ${{ runner.temp }}/wordpress-playground + run: | + node packages/php-wasm/compile/build.js --PLATFORM=node --PHP_VERSION="${PHP_VERSION}" + node packages/php-wasm/compile/build.js --PLATFORM=node --PHP_VERSION="${PHP_VERSION}" --WITH_JSPI=yes + npx nx build php-wasm-node-8-3 + + - name: Pack overlay and record provenance + working-directory: ${{ runner.temp }}/wordpress-playground + run: | + mkdir -p "${GITHUB_WORKSPACE}/artifacts" + npm pack ./dist/packages/php-wasm/node-builds/8-3 --pack-destination "${GITHUB_WORKSPACE}/artifacts" + ARTIFACT_PATH="$(find "${GITHUB_WORKSPACE}/artifacts" -name '*.tgz' -type f -print -quit)" + ARTIFACT_SHA256="$(shasum -a 256 "${ARTIFACT_PATH}" | cut -d ' ' -f 1)" + node -e 'const fs = require("node:fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ schema: "wp-codebox/php-wasm-overlay-provenance/v1", upstream: { repository: "WordPress/wordpress-playground", revision: process.env.PLAYGROUND_SHA }, patch: { revision: process.env.PATCH_SHA, sha256: process.env.PATCH_SHA256, files: ["packages/php-wasm/compile/php/Dockerfile", "packages/php-wasm/compile/php/php_wasm.c"] }, artifact: { file: require("node:path").basename(process.argv[2]), sha256: process.argv[3] } }, null, 2) + "\n")' "${GITHUB_WORKSPACE}/artifacts/provenance.json" "${ARTIFACT_PATH}" "${ARTIFACT_SHA256}" + + - name: Reclaim build disk + run: | + docker system prune --all --force + rm -rf "${RUNNER_TEMP}/wordpress-playground" "${RUNNER_TEMP}/playground.tar.gz" "${RUNNER_TEMP}/mysqli-poll.patch" + + - name: Install WP Codebox and overlay + run: | + npm ci + npm install --no-save --ignore-scripts "$(find "${GITHUB_WORKSPACE}/artifacts" -name '*.tgz' -type f -print -quit)" + npm run build + + - name: Verify mysqli_poll against MariaDB + env: + PLAYGROUND_NO_JSPI_RESPAWN: "1" + WP_CODEBOX_PHP_WASM_MODE: asyncify + run: npm run test:mysqli-poll-integration + + - name: Upload immutable overlay + if: always() + uses: actions/upload-artifact@v4 + with: + name: php-wasm-node-8-3-mysqli-poll-${{ github.sha }} + path: | + artifacts/*.tgz + artifacts/provenance.json + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 642ae1bc8..c7e8e0f06 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ vendor/ dist/ artifacts/ +runtime-overlays/* +!runtime-overlays/php-wasm-node-8-3-3.1.46.tgz +!runtime-overlays/provenance.json scripts/uploads/ *.tsbuildinfo .DS_Store diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4d699a9ad..08e886bc6 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -16,6 +16,7 @@ "@automattic/wp-codebox-cli": "file:packages/cli", "@automattic/wp-codebox-core": "file:packages/runtime-core", "@automattic/wp-codebox-playground": "file:packages/runtime-playground", + "@php-wasm/node-8-3": "file:runtime-overlays/php-wasm-node-8-3-3.1.46.tgz", "@php-wasm/node-8-4": "3.1.46", "@php-wasm/universal": "3.1.46", "@types/pngjs": "^6.0.5", @@ -1834,8 +1835,8 @@ }, "node_modules/@php-wasm/node-8-3": { "version": "3.1.46", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-3/-/node-8-3-3.1.46.tgz", - "integrity": "sha512-ZSH1rPvVuhIYJkGE/GRkQtv7RoQlO0IMWkHLJGt+JrTeaDxxm8Mv/F0H67MrA80q43zzNdNQaUEPn+ZrN/GSwg==", + "resolved": "file:runtime-overlays/php-wasm-node-8-3-3.1.46.tgz", + "integrity": "sha512-dnBmSZR9uki2wiXFpXVRFmH8Azg/AC+TIAcKoZgv1L2j6wSagSF+UG7LqEbqJMzMs+cY72c/4J1bLEY66N5P/g==", "license": "GPL-2.0-or-later", "dependencies": { "@php-wasm/universal": "3.1.46", diff --git a/package.json b/package.json index cbadb166b..ea13a90c6 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,8 @@ "npm-shrinkwrap.json", "scripts/apply-development-patches.mjs", "patches", + "runtime-overlays/php-wasm-node-8-3-3.1.46.tgz", + "runtime-overlays/provenance.json", "README.md", "LICENSE" ], @@ -288,6 +290,7 @@ "test:managed-database-diagnostics": "tsx tests/managed-database-diagnostics.test.ts", "test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts", "test:native-mariadb-runtime-service-integration": "tsx tests/native-mariadb-runtime-service.integration.test.ts", + "test:mysqli-poll-integration": "tsx tests/mysqli-poll.integration.test.ts", "test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts", "test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts", "test:runtime-contract-package-exports": "tsx tests/runtime-contract-package-exports.test.ts", @@ -338,6 +341,7 @@ "@automattic/wp-codebox-cli": "file:packages/cli", "@automattic/wp-codebox-core": "file:packages/runtime-core", "@automattic/wp-codebox-playground": "file:packages/runtime-playground", + "@php-wasm/node-8-3": "file:runtime-overlays/php-wasm-node-8-3-3.1.46.tgz", "@php-wasm/node-8-4": "3.1.46", "@php-wasm/universal": "3.1.46", "@types/pngjs": "^6.0.5", @@ -459,8 +463,8 @@ "on-finished": "^2.4.1", "once": "^1.4.0", "pako": "^1.0.11", - "patch-package": "^8.0.1", "parseurl": "^1.3.3", + "patch-package": "^8.0.1", "path-expression-matcher": "^1.5.0", "path-to-regexp": "^0.1.13", "pify": "^4.0.1", diff --git a/patches/README.md b/patches/README.md index 694b2cb90..94eeca6c5 100644 --- a/patches/README.md +++ b/patches/README.md @@ -17,3 +17,12 @@ the released CLI's log-only behavior. Without this narrow terminalization, a poisoned PHP-WASM worker remains alive and leaves the parent request pending until the recipe timeout. Remove the patch after the Playground CLI ships the same worker terminalization behavior. + +The manual `build-php-wasm-mysqli-poll-overlay.yml` workflow carries the C-level +`select()` wrapper and Asyncify imports from WordPress Playground PR +[#4170](https://github.com/WordPress/wordpress-playground/pull/4170). It +checksum-verifies the immutable patch and Playground revision +`581c7c172428159eb4e6c5309054a568cd39a97a`, rebuilds the PHP 8.3.32 Node +package, and verifies `mysqli_poll()` against MariaDB before uploading the +package and provenance. Remove the patch, vendored package, and workflow after +a published Playground package contains the same fix. diff --git a/runtime-overlays/php-wasm-node-8-3-3.1.46.tgz b/runtime-overlays/php-wasm-node-8-3-3.1.46.tgz new file mode 100644 index 000000000..54f48818e Binary files /dev/null and b/runtime-overlays/php-wasm-node-8-3-3.1.46.tgz differ diff --git a/runtime-overlays/provenance.json b/runtime-overlays/provenance.json new file mode 100644 index 000000000..852edc5fe --- /dev/null +++ b/runtime-overlays/provenance.json @@ -0,0 +1,19 @@ +{ + "schema": "wp-codebox/php-wasm-overlay-provenance/v1", + "upstream": { + "repository": "WordPress/wordpress-playground", + "revision": "581c7c172428159eb4e6c5309054a568cd39a97a" + }, + "patch": { + "revision": "d746c930477eae3d726f754753eb92692ad30855", + "sha256": "f42ffb44f21e736c3fbf6e504e01e4a07207f8d764c38ce1f9c81db80d614a04", + "files": [ + "packages/php-wasm/compile/php/Dockerfile", + "packages/php-wasm/compile/php/php_wasm.c" + ] + }, + "artifact": { + "file": "php-wasm-node-8-3-3.1.46.tgz", + "sha256": "109c079c958e1ab35ebcd098bdd895b080e2c5405b147f93ecb04e768c723553" + } +} diff --git a/tests/mysqli-poll.integration.test.ts b/tests/mysqli-poll.integration.test.ts new file mode 100644 index 000000000..1bfe4a865 --- /dev/null +++ b/tests/mysqli-poll.integration.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runRecipe } from "../packages/cli/src/commands/recipe-run.ts" + +const directory = await mkdtemp(join(tmpdir(), "wp-codebox-mysqli-poll-")) +try { + const recipePath = join(directory, "recipe.json") + const code = ` +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); +$connect = static function (): mysqli { + $db = mysqli_init(); + mysqli_real_connect($db, getenv('DB_HOST'), getenv('DB_USER'), getenv('DB_PASSWORD'), getenv('DB_NAME'), (int) getenv('DB_PORT')); + return $db; +}; +$first = $connect(); +$second = $connect(); +$first->query('CREATE TABLE poll_lock (id INT PRIMARY KEY) ENGINE=InnoDB'); +$first->query('INSERT INTO poll_lock VALUES (1)'); +$first->query('START TRANSACTION'); +$first->query('SELECT id FROM poll_lock WHERE id = 1 FOR UPDATE'); +$second->query('SELECT id FROM poll_lock WHERE id = 1 FOR UPDATE', MYSQLI_ASYNC); +$read = array($second); +$error = array(); +$reject = array(); +$started = microtime(true); +$ready = mysqli_poll($read, $error, $reject, 0, 100000); +$elapsed_ms = (microtime(true) - $started) * 1000; +$timeout_set_counts = array(count($read), count($error), count($reject)); +$first->query('ROLLBACK'); +$cleanup_ready = false; +for ($attempt = 0; $attempt < 20; $attempt++) { + $read = array($second); + $error = array(); + $reject = array(); + if (mysqli_poll($read, $error, $reject, 0, 100000) > 0) { + $cleanup_ready = true; + break; + } +} +if ($cleanup_ready) { + $second->reap_async_query(); +} +$first->query('DROP TABLE poll_lock'); +echo json_encode(array( + 'ready' => $ready, + 'elapsed_ms' => $elapsed_ms, + 'timeout_set_counts' => $timeout_set_counts, + 'cleanup_ready' => $cleanup_ready, +)); +` + await writeFile(recipePath, JSON.stringify({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { phpVersion: "8.3" }, + inputs: { + services: [{ + id: "mariadb", + kind: "mysql", + configuration: { engine: "mariadb" }, + outputs: { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" }, + }], + }, + workflow: { steps: [{ command: "wordpress.run-php", args: [`code=${code}`] }] }, + })) + + const result = await runRecipe({ + recipePath, + previewHoldBlocking: false, + previewLeaseRequested: false, + previewLeaseChild: false, + timeoutMs: 180_000, + json: true, + summary: false, + dryRun: false, + externalServiceWritesApproved: false, + }) + assert.equal(result.success, true, JSON.stringify(result)) + const output = JSON.parse(result.executions.at(-1)?.stdout.trim() ?? "{}") + assert.equal(output.ready, 0) + assert.ok(output.elapsed_ms >= 50, `mysqli_poll returned too early after ${output.elapsed_ms}ms`) + assert.ok(output.elapsed_ms < 2_000, `mysqli_poll exceeded its timeout: ${output.elapsed_ms}ms`) + assert.deepEqual(output.timeout_set_counts, [0, 0, 0]) + assert.equal(output.cleanup_ready, true) + console.log("mysqli_poll MariaDB integration passed") +} finally { + await rm(directory, { recursive: true, force: true }) +}