diff --git a/apps/browser-demos/pages/php-test/index.html b/apps/browser-demos/pages/php-test/index.html new file mode 100644 index 000000000..e2c02000f --- /dev/null +++ b/apps/browser-demos/pages/php-test/index.html @@ -0,0 +1,11 @@ + + + + + PHP PHPT Test Runner + + +
Loading PHP test image...
+ + + diff --git a/apps/browser-demos/pages/php-test/main.ts b/apps/browser-demos/pages/php-test/main.ts new file mode 100644 index 000000000..466ee501d --- /dev/null +++ b/apps/browser-demos/pages/php-test/main.ts @@ -0,0 +1,271 @@ +/** + * Browser runner for php-src PHPT tests. + * + * The Node/Playwright driver parses .phpt files and asks this page to run + * transient PHP scripts inside a VFS image containing php-src test assets. + */ +import { BrowserKernel } from "@host/browser-kernel-host"; +import { MemoryFileSystem } from "@host/vfs/memory-fs"; +import { ensureDirRecursive, writeVfsBinary } from "@host/vfs/image-helpers"; +import kernelWasmUrl from "@kernel-wasm?url"; + +interface RunPhpScriptRequest { + scriptPath: string; + script: string; + argv: string[]; + cwd: string; + env?: string[]; + uid?: number; + gid?: number; + stdin?: string; + stdinIsPipe?: boolean; + pipeStdio?: number[]; + waitForChildOutput?: boolean; + timeoutMs?: number; +} + +interface RunPhpScriptResult { + exitCode: number; + stdout: string; + stderr: string; + output?: string; + error?: string; + durationMs: number; +} + +declare global { + interface Window { + __phpTestReady: boolean; + __runPhpScript: (request: RunPhpScriptRequest) => Promise; + } +} + +let kernelBytes: ArrayBuffer | null = null; +let vfsImageBytes: Uint8Array | null = null; +let phpBytes: ArrayBuffer | null = null; + +function readVfsFile(fs: MemoryFileSystem, path: string): Uint8Array { + const st = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const out = new Uint8Array(st.size); + let offset = 0; + while (offset < out.length) { + const n = fs.read(fd, out.subarray(offset), null, out.length - offset); + if (n <= 0) break; + offset += n; + } + return out.slice(0, offset); + } finally { + fs.close(fd); + } +} + +function createFs(): MemoryFileSystem { + if (!vfsImageBytes) throw new Error("PHP test VFS image not loaded"); + return MemoryFileSystem.fromImage(vfsImageBytes, { + maxByteLength: 2 * 1024 * 1024 * 1024, + }); +} + +function ensureParent(fs: MemoryFileSystem, path: string): void { + const slash = path.lastIndexOf("/"); + if (slash > 0) ensureDirRecursive(fs, path.slice(0, slash)); +} + +function parentPath(path: string): string { + const slash = path.lastIndexOf("/"); + return slash > 0 ? path.slice(0, slash) : "/"; +} + +function makeDirectoryWritableByGuest( + fs: MemoryFileSystem, + path: string, + uid: number, + gid: number, +): void { + try { + const st = fs.lstat(path); + // The harness prepares an ephemeral php-src image per PHPT section. + // When the guest process intentionally runs as a non-root uid, make the + // source root and the current PHPT directory writable by that guest just + // like the Node-host harness does for copied source trees. This changes + // only test fixture ownership/mode; kernel credential checks still decide + // whether user-mode operations are allowed. + if ((st.mode & 0o170000) !== 0o040000) return; + fs.chown(path, uid, gid); + fs.chmod(path, 0o777); + } catch { + // Missing paths will be reported by the actual PHP process or by the + // script write below. This helper is best-effort fixture setup. + } +} + +function prepareGuestWritableWorkspace( + fs: MemoryFileSystem, + scriptPath: string, + uid?: number, + gid?: number, +): void { + if (uid == null && gid == null) return; + const effectiveUid = uid ?? 0; + const effectiveGid = gid ?? effectiveUid; + makeDirectoryWritableByGuest(fs, "/php-src", effectiveUid, effectiveGid); + makeDirectoryWritableByGuest( + fs, + parentPath(scriptPath), + effectiveUid, + effectiveGid, + ); +} + +function binaryStringToBytes(value: string): Uint8Array { + const bytes = new Uint8Array(value.length); + for (let i = 0; i < value.length; i++) { + bytes[i] = value.charCodeAt(i) & 0xff; + } + return bytes; +} + +function bytesToBinaryString(data: Uint8Array): string { + let out = ""; + const chunk = 0x8000; + for (let i = 0; i < data.length; i += chunk) { + out += String.fromCharCode(...data.subarray(i, i + chunk)); + } + return out; +} + +function corsProxyUrlPrefix(): string { + const base = import.meta.env.BASE_URL ?? "/"; + const normalized = base.startsWith("/") ? base : `/${base}`; + const proxyPath = `${normalized.endsWith("/") ? normalized : `${normalized}/`}__kandelo_cors_proxy`; + const proxyUrl = new URL(proxyPath, window.location.href); + proxyUrl.searchParams.set("url", ""); + return proxyUrl.href; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function init() { + const [kernelBuf, imageBuf] = await Promise.all([ + fetch(kernelWasmUrl).then((r) => { + if (!r.ok) throw new Error(`kernel fetch failed: ${r.status}`); + return r.arrayBuffer(); + }), + fetch("/php-test.vfs.zst").then((r) => { + if (!r.ok) { + throw new Error( + `php-test.vfs.zst not found (${r.status}). Run: bash images/vfs/scripts/build-php-test-vfs-image.sh`, + ); + } + return r.arrayBuffer(); + }), + ]); + + kernelBytes = kernelBuf; + vfsImageBytes = new Uint8Array(imageBuf); + const fs = createFs(); + const php = readVfsFile(fs, "/usr/local/bin/php"); + phpBytes = php.buffer.slice(php.byteOffset, php.byteOffset + php.byteLength); + + window.__runPhpScript = async (request: RunPhpScriptRequest) => { + const start = performance.now(); + const fs = createFs(); + prepareGuestWritableWorkspace(fs, request.scriptPath, request.uid, request.gid); + ensureParent(fs, request.scriptPath); + writeVfsBinary(fs, request.scriptPath, binaryStringToBytes(request.script), 0o644); + + let stdout = ""; + let stderr = ""; + let output = ""; + const kernel = new BrowserKernel({ + memfs: fs, + maxWorkers: 4, + corsProxyUrl: corsProxyUrlPrefix(), + onStdout: (data) => { + const text = bytesToBinaryString(data); + stdout += text; + output += text; + }, + onStderr: (data) => { + const text = bytesToBinaryString(data); + stderr += text; + output += text; + }, + }); + + const stdin = request.stdin == null ? undefined : binaryStringToBytes(request.stdin); + const env = [ + "HOME=/tmp", + "TMPDIR=/tmp", + "PATH=/usr/local/bin:/usr/bin:/bin", + "TEST_PHP_EXECUTABLE=/usr/local/bin/php", + "TEST_PHP_EXECUTABLE_ESCAPED='/usr/local/bin/php'", + ...(request.env ?? []), + ]; + + try { + await kernel.init(kernelBytes!); + const exitCode = await Promise.race([ + kernel.spawn(phpBytes!, ["/usr/local/bin/php", ...request.argv], { + cwd: request.cwd, + env, + stdin, + stdinIsPipe: request.stdinIsPipe, + pipeStdio: request.pipeStdio, + uid: request.uid, + gid: request.gid, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("TIMEOUT")), request.timeoutMs ?? 60_000), + ), + ]); + + if (request.waitForChildOutput) { + const deadline = performance.now() + 1_000; + while (performance.now() < deadline) { + const processes = await kernel.enumProcs().catch(() => []); + if (processes.length === 0) break; + await delay(25); + } + } + + let lastOutputLength = -1; + let stablePolls = 0; + for (let waitedMs = 0; waitedMs < 500 && stablePolls < 3; waitedMs += 25) { + await delay(25); + const outputLength = output.length; + if (waitedMs >= 100 && outputLength === lastOutputLength) { + stablePolls++; + } else { + stablePolls = 0; + } + lastOutputLength = outputLength; + } + return { exitCode, stdout, stderr, output, durationMs: Math.round(performance.now() - start) }; + } catch (err: any) { + const message = err?.message || String(err); + return { + exitCode: -1, + stdout, + stderr, + output, + error: message.includes("TIMEOUT") ? "TIMEOUT" : message, + durationMs: Math.round(performance.now() - start), + }; + } finally { + await kernel.destroy().catch(() => {}); + } + }; + + window.__phpTestReady = true; + document.getElementById("status")!.textContent = "Ready"; +} + +init().catch((err) => { + console.error(err); + document.getElementById("status")!.textContent = `Error: ${err?.message || err}`; +}); diff --git a/apps/browser-demos/vite.config.ts b/apps/browser-demos/vite.config.ts index f517aaba2..b4ad7187d 100644 --- a/apps/browser-demos/vite.config.ts +++ b/apps/browser-demos/vite.config.ts @@ -2,7 +2,12 @@ import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; import { execSync } from "child_process"; -import { defineConfig, type Plugin, type PreviewServer, type ViteDevServer } from "vite"; +import { + defineConfig, + type Plugin, + type PreviewServer, + type ViteDevServer, +} from "vite"; import react from "@vitejs/plugin-react"; import { tryResolveBinary } from "../../host/src/binary-resolver"; @@ -39,7 +44,10 @@ function devCorsProxyFetchUrlForBase(base: string): string { return `${devCorsProxyPathForBase(base)}?url=`; } -function injectCorsProxyUrlPlaceholder(content: string, corsProxyUrl: string): string { +function injectCorsProxyUrlPlaceholder( + content: string, + corsProxyUrl: string, +): string { return content.replace('"__CORS_PROXY_URL__"', JSON.stringify(corsProxyUrl)); } @@ -75,7 +83,7 @@ function resolveKernelArtifactsAlias(): Plugin { const fetched = path.resolve(repoRoot, "binaries/kernel.wasm"); this.error( "kernel.wasm not found, or every candidate is stale. Run `bash build.sh` from the repo root.\n" + - ` Looked at: ${local}\n Looked at: ${fetched}` + ` Looked at: ${local}\n Looked at: ${fetched}`, ); } if (pathPart === ROOTFS) { @@ -91,7 +99,7 @@ function resolveKernelArtifactsAlias(): Plugin { } this.error( "rootfs.vfs not found. Run `bash build.sh` from the repo root, or fetch/build the rootfs package.\n" + - candidates.map((file) => ` Looked at: ${file}`).join("\n") + candidates.map((file) => ` Looked at: ${file}`).join("\n"), ); } return null; @@ -145,8 +153,8 @@ function resolveBinariesAlias(): Plugin { const fetched = path.resolve(repoRoot, "binaries", rest); this.error( `@binaries: ${rest} not found, or every candidate is stale. ` + - `Looked at:\n ${local}\n ${fetched}\n` + - `Run \`./run.sh fetch\` to install release archives, or build the artifact locally.` + `Looked at:\n ${local}\n ${fetched}\n` + + `Run \`./run.sh fetch\` to install release archives, or build the artifact locally.`, ); }, }; @@ -197,9 +205,7 @@ function injectGitRevision(): Plugin { encoding: "utf-8", }).trim(); // Convert git@github.com:user/repo.git or https://github.com/user/repo.git - const match = remoteUrl.match( - /github\.com[:/](.+?)(?:\.git)?$/ - ); + const match = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); const repoPath = match ? match[1] : "brandonpayton/kandelo"; const fullRev = execSync("git rev-parse HEAD", { cwd: repoRoot, @@ -290,7 +296,8 @@ function injectCorsProxyUrl(): Plugin { name: "inject-cors-proxy-url", configResolved(config) { base = config.base; - servedCorsProxyUrl = configuredCorsProxyUrl() || devCorsProxyFetchUrlForBase(base); + servedCorsProxyUrl = + configuredCorsProxyUrl() || devCorsProxyFetchUrlForBase(base); outputCorsProxyUrl = buildCorsProxyUrl(); }, configureServer(server) { @@ -404,6 +411,7 @@ const defaultDemoInputs = { const demoInputs = { ...defaultDemoInputs, "sqlite-test": path.resolve(__dirname, "pages/sqlite-test/index.html"), + "php-test": path.resolve(__dirname, "pages/php-test/index.html"), // The perl, python, ruby, erlang, texlive, and redis package entries // are not bundled into this static build while their slow builds // live in kandelo-software. The root gallery fetches that @@ -480,10 +488,13 @@ export default defineConfig({ }, worker: { format: "es", - plugins: () => [ - resolveKernelArtifactsAlias(), - resolveBinariesAlias(), - ], + plugins: () => [resolveKernelArtifactsAlias(), resolveBinariesAlias()], }, - assetsInclude: ["**/*.wasm", "**/*.sql", "**/*.vfs", "**/*.vfs.zst", "**/*.zip"], + assetsInclude: [ + "**/*.wasm", + "**/*.sql", + "**/*.vfs", + "**/*.vfs.zst", + "**/*.zip", + ], }); diff --git a/crates/kernel/src/openssl.cnf b/crates/kernel/src/openssl.cnf new file mode 100644 index 000000000..be8279c92 --- /dev/null +++ b/crates/kernel/src/openssl.cnf @@ -0,0 +1,24 @@ +openssl_conf = openssl_init + +[openssl_init] + +[req] +default_bits = 2048 +default_md = sha256 +distinguished_name = req_distinguished_name +x509_extensions = v3_ca +req_extensions = v3_req + +[req_distinguished_name] + +[v3_req] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment + +[v3_ca] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer:always +basicConstraints = CA:true + +[usr_cert] +basicConstraints = CA:FALSE diff --git a/docs/software-unit-tests.md b/docs/software-unit-tests.md new file mode 100644 index 000000000..b9fb39179 --- /dev/null +++ b/docs/software-unit-tests.md @@ -0,0 +1,904 @@ +# Core Software Test Suites on Kandelo + +This project proves Kandelo by running real upstream/project test suites for +large guest software on both Node.js and browser hosts where possible. + +Status date: 2026-06-16. + +## Current Status + +| Project | What is wired today | Node host status | Browser host status | +|---------|---------------------|------------------|---------------------| +| MariaDB | `mysql-test/main/*.test` through `mysqltest` against `mariadbd` | Started full run, stopped after 710 results due Node heap OOM | Browser run reached the harness but failed VFS/init fetch and recorded 1149 failures | +| SQLite direct | Direct execution of each upstream Tcl `test/*.test` script once through `testfixture` | Completed 1159 scripts: 912 PASS, 36 FAIL, 15 XFAIL, 196 XPASS | Completed 1159 scripts: 876 PASS, 62 FAIL, 38 XFAIL, 173 XPASS, 10 TIME | +| SQLite official | Upstream `test/testrunner.tcl` permutations `full` and `all` | Completed corrected Node `full --jobs 2`: 1416/1416 official Tcl jobs finalized, 1416 passed, 0 failed, 1,703,255 SQLite internal cases, 0 case errors. The prior `busy2.test` failure was fixed by rebuilding the SQLite artifacts with `SQLITE_ENABLE_SETLK_TIMEOUT=2`, matching SQLite's own official lock-timeout test configuration; see `docs/sqlite-official-test-report.md` | Same inventory: 1416 `full` jobs and 10523 `all` jobs. Current browser iteration is past the earlier `writecrash.test` blocker and reached a timeout/stall checkpoint at 40/1416 jobs, 12,206 cases, 0 case errors, with `test/sort4.test` still running. Node isolated `sort4.test` passes 11/11 in 54s; browser isolated `sort4.test` was still at 0/1 after about 3 minutes before maintenance stop. See `docs/sqlite-official-test-report.md`. | +| PHP | PHPT runtime tests from the PHP source tree | Full discovered Node PHPT set completed on PR #2: 19,017/19,017 covered, 14,554 PASS, 0 FAIL, 0 TIMEOUT, 9 XFAIL, 1 XPASS, 3,987 SKIP, 466 UNSUPPORTED. | Browser harness is wired through the `php-test` Vite page and VFS image; current PR #2 browser coverage is still partial: 2,363/19,017 covered, 2,141 PASS, 2 FAIL, 1 XFAIL, 212 SKIP, 7 UNSUPPORTED. | +| SpiderMonkey smoke | Kandelo-authored shell coverage tests, not Mozilla's official suite | Completed 17/17 PASS | Completed 17/17 PASS | +| SpiderMonkey official | Mozilla `jstests.py` and `jit_test.py` harnesses using `js.wasm` through a Kandelo shell wrapper | Paused until the process-memory architecture bug is fixed, so Node/browser results stay comparable | Paused until the browser process-memory architecture bug is fixed | +| Node.js library | Upstream Node.js `test/parallel/test-*.js` and `test/sequential/test-*.js` through the SpiderMonkey-backed Node-compatible runtime | Completed 3925 tests: 336 PASS, 3264 FAIL, 325 TIME | Completed 3925 tests: 339 PASS, 3564 FAIL, 22 TIME | + +Logs from the 2026-05-28 full runs are under `test-runs/software-unit-tests/`. + +## 2026-06-16 PHP PHPT Node Current-Head Full Run + +php-src discovery finds **19,017** `.phpt` files from PHP **8.3.15**. The +current-head Node run on PR #2 completed the full discovered set. The aggregate +uses chronological chunk results plus targeted reruns for tests whose earlier +results were invalidated by harness or external-service issues: + +| Host | Scope | Pass | XFAIL | XPASS | Fail | Timeout | Skip | Unsupported | Untested | Total | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Node | Chunked `--all`, current PR head `bc13ad8631a2` | 14,554 | 9 | 1 | 0 | 0 | 3,987 | 466 | 0 | 19,017 | + +The bounded final segment used the restartable chunk harness. The chunk wrapper defaults to `--host node` and can also checkpoint browser runs with `--host browser`; use `--rebuild-vfs` for browser after changing `PHP_WASM`, `PHP_OPCACHE_SO`, or `PHP_EXTENSION_DIR` so shared extensions such as `zend_test.so` are present in the image. The VFS builder honors an explicit `PHP_OPCACHE_SO` by writing it to `/usr/lib/php/extensions/opcache.so` after scanning `PHP_EXTENSION_DIR`, so a stale directory entry cannot override the requested OPcache side module: + +```bash +TEST_NON_ROOT_USER=nobody \ +TEST_FPM_RUN_AS_ROOT=1 \ +PHP_TEST_FAILURE_SNIPPET_BYTES=5000 \ +PHP_WASM=/tmp/kad-php-dynfork.wasm \ +PHP_FPM_WASM="$PWD/packages/registry/php/bin/php-fpm.wasm" \ +PHP_OPCACHE_SO=/tmp/kad-opcache-sidefork.so \ +PHP_EXTENSION_DIR="$PWD/packages/registry/php/bin" \ +PHP_TEST_CHUNK_SIZE=500 \ +PHP_TEST_JOBS=2 \ +PHP_TEST_TIMEOUT_MS=240000 \ +PHP_TEST_HOST_RESET_INTERVAL=1 \ +scripts/run-php-upstream-node-chunks.sh \ + --host node \ + --start-offset 8962 \ + --out-dir /tmp/kad-1-test-logs/php-node-bounded-chunks-from-8962-20260616075451 \ + --chunk-size 500 --jobs 2 --timeout 240000 --host-reset-interval 1 +``` + +Important reruns included: + +- `Zend/tests/generators/bug71441.phpt`, which now passes after increasing the + default Node host worker stack from 16 MiB to 32 MiB. This is a general host + stability change for deep guest stacks, not PHP-specific behavior. +- FPM non-root/virtual-ownership coverage, which now passes after allowing + host-backed mounts to expose stable virtual uid/gid metadata to the guest. +- Two online `httpbin.org` HTTP/1.1 PHPTs, which are now counted as upstream + skips under `SKIP_ONLINE_TESTS=1` after host `curl --http1.1` confirmed + `httpbin.org` currently returns HTTP/1.1 503. This is an external service + availability issue, not a Kandelo kernel failure. + +The only XPASS is +`sapi/fpm/tests/log-bwd-multiple-msgs-stdout-stderr.phpt`, an upstream +intermittent XFAIL that passed locally. + +Current skip/unsupported coverage gaps should be reduced through normal +runtime packaging and harness support: + +- Missing optional PHP extensions/dependencies: `intl`, `oci8`, `gd`, `curl`, + `ldap`, `ffi`, `gmp`, `imap`, `zip`, `pgsql`, and related extension suites. +- External services: MySQL/PDO MySQL connection tests require a running + compatible database service; the two `httpbin.org` online tests are skipped + while HTTP/1.1 requests to that service return 503. +- FPM/CGI/web PHPTs: the Node harness can now stage `php-fpm` when + `PHP_FPM_WASM` is set, and passes upstream's `TEST_FPM_RUN_AS_ROOT` control + env through to guest tests. This exposes real FPM coverage instead of + treating every FPM helper test as a CLI test: + + ```bash + TEST_FPM_RUN_AS_ROOT=1 \ + PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ + PHP_FPM_WASM="$PWD/packages/registry/php/bin/php-fpm.wasm" \ + PHP_OPCACHE_SO=/tmp/kad-opcache-sidefork.so \ + scripts/run-php-upstream-tests.sh --host node --json \ + sapi/fpm/tests/.phpt + ``` + +- web/CGI PHPT sections such as `EXPECTHEADERS`, `POST`, `POST_RAW`, `GET`, + `COOKIE`, `CGI`, `GZIP_POST`, `DEFLATE_POST`, and `REDIRECTTEST` still need + general harness support. +- Fibers require a real general `getcontext`/`makecontext`/`swapcontext` + implementation or another Wasm context-switching backend. +- phpdbg PHPTs require building and packaging the phpdbg SAPI. +- DNS record-query PHPTs require enabling a correct resolver backend in the + PHP build/runtime. + + +## 2026-06-14 PHP PHPT Node Chunked Full Run + +php-src discovery still finds **19,017** `.phpt` files from PHP **8.3.15**. +The current no-skip-env Node run is using the restartable chunk harness added in +this PR update: + +```bash +PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ +PHP_OPCACHE_SO="$PWD/packages/registry/php/bin/opcache.so" \ + scripts/run-php-upstream-node-chunks.sh \ + --chunk-size 500 --jobs 4 --timeout 600000 \ + --host-reset-interval 25 \ + --out-dir /tmp/kad-1-test-logs/php-node-chunks-20260614225117 +``` + +This wrapper runs the same reusable PHPT harness in fresh Node.js processes per +chunk and writes resumable `chunk-.jsonl`, `.stderr`, `.exit`, `.done`, +`summary.json`, and `summary.md` artifacts. It avoids the previous monolithic +Node run shape, which reached **1,275 results** (**1,265 pass**, **10 skip**) +but grew to about **7.3 GiB RSS** and was killed before completion. + +Latest observed partial no-skip-env Node counts while the chunked run continues: + +| Host | Scope | Pass | XFAIL | Fail | Timeout | Skip | Unsupported | Untested | Total | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Node | Chunked `--all`, offsets 0..current partial chunk | 1,519 | 0 | 1 | 0 | 18 | 0 | 17,479 | 19,017 | + +Current failing test observed in the partial run: + +- `Zend/tests/concat_003.phpt`: pure PHP performance threshold check. The test + expects concatenating a large set of strings to finish below the upstream + native-runtime threshold of 1.0 second; the Wasm PHP runtime currently reports + `bool(false)`. This is not yet tied to a POSIX/kernel semantic failure and + should not be papered over with PHP-specific kernel behavior. + +Current skips observed in the partial run are upstream `SKIPIF` gates for +64-bit-only Zend tests, Zend MM, and missing optional extensions (`curl`, +`intl`). These remain coverage gaps to reduce through general PHP runtime +packaging/host support, not through Kandelo kernel special cases. + +New general platform fix in this update: + +- AF_INET6 loopback stream listeners are registered through the same + cross-process host TCP bridge used for AF_INET loopback while preserving guest + IPv6 socket metadata. This lets a child process listen on `::1` and a sibling + process connect to `::1`, matching normal loopback behavior. Targeted Node + verification now passes: + +```bash +PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ +PHP_OPCACHE_SO="$PWD/packages/registry/php/bin/opcache.so" \ + scripts/run-php-upstream-tests.sh --host node --timeout 180000 --json \ + ext/openssl/tests/san_ipv6_peer_matching.phpt +# => PASS +``` + +Additional local validation for this update: + +- `cargo test -p kandelo inet6_loopback --target x86_64-unknown-linux-gnu` — 3 pass +- `bash packages/registry/kernel/build-kernel.sh` — rebuilt and installed `local-binaries/kernel.wasm` / `host/wasm/kandelo-kernel.wasm` + +## 2026-06-12 PHP PHPT Node Iteration + +php-src discovery currently finds **19,017** `.phpt` files from PHP **8.3.15**. + +Current no-skip-env Node full run command: + +```bash +PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ +PHP_OPCACHE_SO="$PWD/packages/registry/php/bin/opcache.so" \ + scripts/run-php-upstream-tests.sh --host node --all --jobs 4 \ + --timeout 600000 --host-reset-interval 25 --json +``` + +The active log is recorded in `/tmp/kad-1-current-node-full-log`. + +Changes since the 2026-06-11 handoff: + +- The PHPT harness now has `--host-reset-interval` for Node. This reboots each + runner's Kandelo kernel after a bounded number of PHPTs, reclaiming + host-side WebAssembly memory the same way native `make test` gets OS process + reclamation between PHP invocations. `0` disables the reset. +- The PHP package now builds and ships `zend_test.so` as an opt-in shared + extension. Upstream php-src uses `zend_test` for engine coverage; making it a + normal loadable module removes those skips without loading test-only code by + default or special-casing the harness. +- PHP configure now passes `--disable-rpath`; wasm-ld does not support ELF + runtime library search path flags, and the Wasm PHP package links static + dependency archives and explicit side modules instead. +- Targeted Node checks after the rebuild: + - `Zend/tests/attributes/016_custom_attribute_validation.phpt`: PASS + - `Zend/tests/bug74093.phpt`: PASS, validating POSIX timer delivery for Zend + max-execution timers. + - `Zend/tests/new_oom.phpt`: PASS with `--timeout 600000`; it takes about + 177 seconds on this AO worker and can false-timeout under the older 180s + full-run timeout. + - The previous opcache/OpenSSL targeted failure set remains PASS. + - `Zend/tests/concat_003.phpt`: still FAIL. The measured timed section takes + about 4.3 seconds on the Wasm PHP runtime versus the upstream native + performance threshold of 1.0 second. This is not currently attributable to + a POSIX/kernel semantic failure. + + +## 2026-06-11 PHP PHPT Handoff Status + +php-src discovery currently finds **19,017** `.phpt` files from PHP **8.3.15**. + +Latest full/partial run evidence from this AO worker: + +| Host | Run | Pass | Fail | Time | Skip | Unsupported | XFAIL | Untested | Notes | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| Node | `SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 scripts/run-php-upstream-tests.sh --host node --all --jobs 6 --timeout 120000 --json` | 12,589 | 71 | 7 | 5,880 | 462 | 8 | 0 | Complete pre-isolation baseline in `/tmp/kad-1-test-logs/php-node-full-current-detached-20260611062754.jsonl`. Some failures were false negatives from parallel workers sharing one writable `/php-src`. | +| Node | Same command after per-worker source-root isolation | 8,532 | 29 | 6 | 4,714 | 272 | 6 | 5,458 | Run was killed by the worker with exit 137 at 13,559/19,017 in `/tmp/kad-1-test-logs/php-node-full-isolated-current-20260611103236.jsonl`; rerun is required. | +| Browser | Four concurrent shards, Nix Chromium, partial | 1,172 | 17 | 0 | 17 | 0 | 0 | 17,811 | Partial logs under `/tmp/kad-1-test-logs/php-browser-shards4-current/`; the run was stopped before completion to continue Node/kernel iteration. | + +Important interpretation of the Node skip count: the 5,880 skips are upstream +PHPT `SKIPIF` decisions, not harness failures. The largest groups are missing +optional PHP extensions/services in this build or environment: `soap` (552), +`intl` (524), `opcache` (500 in the old run), MySQL connection refused (391), +`oci8` (330), `gd` (292), `zend_test` (162), PDO MySQL connection refused +(145), `curl` (142), 64-bit-only tests (140), Windows-only tests (124), and +FPM/root guards (123). + +The `opcache` skips in the complete Node baseline were a harness configuration +gap. Kandelo ships opcache as the separate Zend extension side module +`opcache.so`; it is not statically loaded into `php.wasm`. The harness now loads +known available shared extensions requested by `--EXTENSIONS--` with the proper +`zend_extension=` directive, recognizes PHP's loaded extension name +`Zend OPcache` as satisfying `opcache`, and writes `opcache.so` into the browser +PHPT VFS image. Targeted verification after this fix: + +```bash +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host node --timeout 60000 --json \ + ext/opcache/tests/bool_cp_in_pass1.phpt +# => PASS +``` + +Current WIP kernel/host fixes in this handoff: + +- POSIX wait status encoding now distinguishes normal exits from signal deaths. + Normal `_exit(status)` is masked to 8 bits and reported to `waitpid(2)` as + `(status & 0xff) << 8`; signal termination records a separate signal number + and reports the low 7-bit signal status. Host-side process scans still expose + shell-style `128 + signal` where that API expects it. +- Hosts can mark captured stdio descriptors as pipes. The descriptor keeps its + host stdio handle for I/O, but `fstat(2)` reports FIFO metadata and + `isatty(3)` observes non-terminal behavior. This is needed for PHPT + `--CAPTURE_STDIO--` cases and is a general POSIX metadata correction. +- Centralized `fork(2)` retries host PID allocation when the kernel still owns a + zombie/limbo PID. The kernel remains the source of truth for PID occupancy; + `fork(2)` callers should not observe an internal `EEXIST` collision. +- Thread exit now clears `CLONE_CHILD_CLEARTID` storage and wakes the futex wait + word, matching Linux pthread join expectations. +- Centralized host/kernel calls that pass guest pointers now route through the + host pointer-width helper instead of hard-coded `BigInt` arguments. + +Current WIP PHP harness fixes in this handoff: + +- Node `--jobs N` uses one copied php-src tree per worker, avoiding cross-test + contamination from generated `.php`/`.clean.php` files and tests that mutate + source-adjacent fixtures. Targeted rerun of failures caused by shared source + state passed 7/7 after this change. +- Node runner reuses a host for throughput but resets it after section timeouts; + timeout handling no longer leaves the worker stuck for later PHPTs. +- Combined stdout/stderr ordering is captured from host callbacks so PHPT + expectations that intentionally interleave warnings and output compare + correctly. +- PHPT placeholder handling now includes `{TMP}`, `{MAIL:...}`, and `{ENV:...}` + in addition to `{PWD}`. +- `TEST_PHP_EXTRA_ARGS` is kept empty, matching upstream use as extra switches + rather than an executable path. +- Browser PHPT runs can use `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`; on this AO + runner a Nix Chromium binary was used because Playwright's downloaded browser + lacked system shared libraries. + +Local checks run before this handoff commit: + +- `git diff --check` +- `npm --prefix host run typecheck` +- `npm --prefix host test -- --run test/multi-worker.test.ts test/select-timeout-retry.test.ts` — 12 pass +- `cargo test -p kandelo --target x86_64-unknown-linux-gnu poll_waitable_child --lib` — 5 pass +- Targeted Node PHPT opcache side-module check above — 1 pass + +Recommended resume commands: + +```bash +# Fast targeted verification for the opcache side-module harness fix. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host node --timeout 60000 --json \ + ext/opcache/tests/bool_cp_in_pass1.phpt + +# Full Node rerun; use --jobs on a machine with enough memory. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host node --all --jobs 6 \ + --timeout 120000 --json + +# Browser smoke after rebuilding the VFS with opcache.so included. +CHROMIUM=$(nix shell nixpkgs#chromium --command sh -lc 'command -v chromium') +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM" \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host browser --rebuild-vfs \ + --limit 3 --timeout 90000 --json + +# Browser full run should be sharded to reduce memory pressure. +CHROMIUM=$(nix shell nixpkgs#chromium --command sh -lc 'command -v chromium') +PHP_TEST_VITE_PORT=5231 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM" \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host browser --all --shard 1/4 \ + --timeout 90000 --json +``` + +## 2026-06-05 PHP PHPT Harness Notes + +The PHP PHPT harness is `scripts/run-php-upstream-tests.sh`. It runs the +upstream `php-src` `.phpt` inventory against Kandelo without calling native +`run-tests.php` directly: each `--EXTENSIONS--`, `--SKIPIF--`, `--FILE--`, +and `--CLEAN--` section is executed as a PHP process inside Kandelo, then the +harness applies the PHPT expectation match. + +Current defaults use the PHP package source metadata, which now matches the +PHP binary built by `packages/registry/php/build-php.sh` (PHP 8.3.15). The +node host mounts the source tree at `/php-src`, mounts the PHP binary +directory at `/kandelo-bin`, and runs tests from `/php-src` to match upstream +`run-tests.php` working-directory semantics. The browser host uses the +`php-test` Vite page and `apps/browser-demos/public/php-test.vfs.zst`; rebuild +that image after changing the PHP source, PHP binary, kernel, shell, or +utility binary inputs. + +Recommended commands while iterating: + +```bash +# Expanded ext/standard PHPT tranche that currently passes cleanly on both +# supported hosts: 537 total, 466 pass, 70 skip, 1 unsupported. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node \ + ext/standard/tests/time ext/standard/tests/versioning \ + ext/standard/tests/directory ext/standard/tests/crypt \ + ext/standard/tests/ini_info ext/standard/tests/hrtime \ + ext/standard/tests/password ext/standard/tests/misc \ + ext/standard/tests/assert ext/standard/tests/url \ + ext/standard/tests/filters ext/standard/tests/class_object \ + ext/standard/tests/image ext/standard/tests/math \ + ext/standard/tests/serialize \ + --timeout 60000 --json + +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser \ + ext/standard/tests/time ext/standard/tests/versioning \ + ext/standard/tests/directory ext/standard/tests/crypt \ + ext/standard/tests/ini_info ext/standard/tests/hrtime \ + ext/standard/tests/password ext/standard/tests/misc \ + ext/standard/tests/assert ext/standard/tests/url \ + ext/standard/tests/filters ext/standard/tests/class_object \ + ext/standard/tests/image ext/standard/tests/math \ + ext/standard/tests/serialize \ + --timeout 60000 --json + +# Full ext/standard strings directory, now clean on both supported hosts: +# 716 total, 663 pass, 53 skip. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node ext/standard/tests/strings --timeout 60000 --json + +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser ext/standard/tests/strings --timeout 60000 --json + +# Full ext/standard array directory, now clean on both supported hosts: +# 817 total, 802 pass, 15 skip. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node ext/standard/tests/array --timeout 60000 --json + +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser ext/standard/tests/array --timeout 60000 --json + +# Node host. Shard full runs; SKIP_* vars are upstream PHPT control env. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node --all --shard 1/16 --timeout 180000 --json + +# Browser host. Requires Playwright's shared library deps on this AO runner. +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser --all --shard 1/16 --timeout 180000 --json + +# Rebuild the browser PHPT VFS image. The image includes /bin/sh and +# standard utilities so PHP shell-backed APIs such as system()/exec() work. +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +scripts/run-php-upstream-tests.sh \ + --host browser --rebuild-vfs --limit 3 --timeout 90000 --json +``` + +The browser VFS builder resolves `php.wasm`, `dash.wasm`, and +`coreutils.wasm`, and `sed.wasm` via the normal binary resolver. If a local +binary cache is stale, set `PHP_WASM`, `DASH_WASM`, `COREUTILS_WASM`, or +`SED_WASM` explicitly. + +Kernel/POSIX fixes found by PHPT so far in the current PR: + +- Pathname resolution must be component-wise. Kandelo no longer collapses + `missing/..` lexically before the backend can report `ENOENT`. +- A trailing slash is significant: it requires the preceding component to + resolve as a directory, while `mkdir("newdir/")` still uses the parent of + `newdir`. +- `..` at a VFS mount root resolves to the parent mount instead of being + treated as an escape from the host-backed mount. +- Empty pathnames now fail with `ENOENT` instead of resolving to the current + directory. +- `getcwd(2)` validates that the current working directory still exists and + returns `ENOENT` after it is removed. +- `chdir(2)` stores a canonical current working directory after successful + component-wise resolution, so later `getcwd(2)` does not expose literal `.` + or `..` path components. +- Host-backed absolute symlinks that point inside their guest mount are + followed for `stat`/`open` while `readlink` still returns the original guest + target text. +- BSD `flock(2)` locks are open-file-description locks. `LOCK_SH` is allowed + on write-only descriptors, separate opens in the same process conflict, and + `LOCK_NB` returns `EAGAIN` instead of being retried as a blocking syscall. +- PHPT section semantics now match upstream more closely: test `--INI--` is + applied to `--FILE--` only, stable generated names are used for `--FILE--` + and `--CLEAN--`, `--INI--` assignment whitespace is normalized, `{PWD}` in + `--INI--`/`--ENV--` expands to the guest test directory, PHP-style trim + removes edge NUL bytes for EXPECT matching, PHPT source/output bytes are + preserved instead of UTF-8 decoded, EXPECTF `%r...%r` regex spans and + percent placeholders follow upstream substitution ordering, flaky PHPTs retry + once, and selected upstream control env vars such as `SKIP_SLOW_TESTS` pass + through to guest PHP. +- Stream/socket behavior now covers the standard cases exercised by PHP's + stream suite: abstract AF_UNIX addresses are not filesystem-backed, UDP + `INADDR_ANY` destinations route to loopback, AF_INET6 loopback sockaddrs are + round-tripped, accepted sockets preserve Kandelo's nonblocking status + contract, and malformed numeric IPv4 names fail resolution instead of being + treated as browser synthetic DNS names. +- Additional network PHPT coverage now passes for AF_UNIX datagram loopback, + AF_INET6 UDP loopback, and browser-side rejection of syntactically invalid + DNS names instead of assigning synthetic addresses to them. + +## 2026-06-02 SQLite Allocator Status + +After rebasing onto `origin/main` at `2e6293a50ccf996b1a434aa701057b862a46c587`, +the next official SQLite Node-host run reached 77/1416 jobs and 22419 SQLite +cases with 0 case errors before repeated wasm `unreachable` traps appeared in +path-heavy `stat`/`lstat` calls. + +The trap mapped to the wasm kernel allocator path, not to SQLite itself: +`crates/kernel/src/lib.rs` used a global bump allocator whose `dealloc` was a +no-op. Temporary Rust allocations such as `Vec` allocations in path +normalization were leaked for the lifetime of the centralized kernel. Long +official SQLite runs therefore exhausted the kernel heap. + +The wasm kernel now uses a lock-protected `dlmalloc::Dlmalloc` global allocator +with real `dealloc`, `alloc_zeroed`, and `realloc`. Validation so far: + +- Wasm kernel release build passed. +- Host build passed. +- Host typecheck passed. +- Focused official SQLite Node run of `savepoint6.test`, + `fts5origintext5.test`, `fts5ah.test`, and `sort4.test` completed 3/4 jobs + before a 10 minute outer timeout: 10189 SQLite cases, 0 case errors. +- The incomplete focused job was `test/sort4.test`, which is marked + `TESTRUNNER: superslow`; it was still running, not failed. + +This fixes the immediate kernel heap leak/allocator exhaustion class. It does +not yet provide a complete SQLite `full` pass/fail matrix. + +## 2026-06-03 SQLite Retry Status + +Current focus remains official SQLite `full`, not SpiderMonkey. The Node-host +run was restarted after three root-cause fixes: + +- Stale file-backed `MAP_SHARED` tracking after page-rounded `munmap` could + corrupt anonymous mappings reused at the same address. This reproduced in + `test/wal.test`; direct rerun now passes 581/581 cases. +- Rapid pthread create/join loops could exhaust the 16 reserved thread slots + because slots were freed only after a later JS worker message. Slots are now + reclaimed when the kernel confirms thread `SYS_EXIT`. Direct `sort4.test` + now passes 11/11 without slot-exhaustion output, and the new + `thread-slot-reuse` regression creates/joins 64 threads successfully. +- The next full `--jobs 4` run reached 74/1416 jobs before the official + runner's own `testrunner.db` became genuinely malformed. The corrupt DB had + zero-filled low pages, including the jobs table root page and the overflow + page for the `CREATE TABLE jobs` schema record. The root cause was that + centralized direct handlers for large `write`/`pwrite` and `writev`/`pwritev` + bypassed the normal post-syscall shared-backing update path. A stale + file-backed mapping cache could then flush or expose zero pages after the + real file had been written. Those direct handlers now update and refresh + shared backings before unblocking the guest. Regression: + `examples/mmap_shared_large_pwrite.c`. + +Focused validation now passing on Node: + +- `host/test/mmap-shared.test.ts`: 3 passed, 1 skipped. +- `host/test/pthread.test.ts`: 3 passed. +- Direct official SQLite jobs: `wal.test` 581/581, `writecrash.test` 995/995, + `rtree4.test` 112471/112471, `sort4.test` 11/11, and + `fts5optimize2.test` 4/4. + +Live official run: +`test-runs/sqlite-full-node-j4-after-large-write-sync-20260603-151517`. +Latest recorded stdout progress is at least 364/1416 jobs (25.71%), past the +previous 74-job malformed-DB blocker, with no visible SQLite case errors or +Kandelo runtime failures. Live DB reads are intentionally avoided while the +guest runner owns the WAL-mode control database; case counts are taken from +`testrunner.log` snapshots until the run finishes. + +## 2026-06-02 SQLite Official Status + +Detailed report: `docs/sqlite-official-test-report.md`. + +The answer to "do we know exactly what parts of the full SQLite suite pass and +fail on Kandelo?" is currently no. We know the official inventory and we have +targeted official job results, but neither the Node nor browser host can yet +finish `full --jobs 1` and produce a trustworthy complete runner database. + +The most important blocker is file-backed `MAP_SHARED` coherency. Kandelo +currently populates file-backed mappings by copying file bytes into each guest +process memory and writes mapped bytes back on `msync`/`munmap`. SQLite WAL +uses a file-backed `test.db-shm` mapping as live shared memory and does not +depend on `msync` for WAL-index coherence. That makes the observed +`busy2.test`, `wal3.test`, and `walsetlk.test` failures kernel/filesystem +correctness failures to fix before treating full SQLite numbers as meaningful. + +Other known blockers are the browser `SharedFS` 64-FD cap reducing +`manydb.test`, a SQLite testfixture build mismatch around +`SQLITE_ENABLE_UPDATE_DELETE_LIMIT`, and a browser artifact bug where the +timeboxed full run exported a valid 1024-byte SQLite DB with no `jobs` table. + +## 2026-06-01 SQLite Rebase Status + +The branch was rebased onto `origin/main` at +`95e31d2588e8fa7653796e0245c023f26fc59556` ("Reduce initial process memory +allocation"). The old branch was preserved as +`backup/prove-by-guest-software-tests-pre-main-rebase-20260601`. + +The rebase plus current work fixed the immediate SQLite kernel trap found after +the main memory-layout changes: + +- The first post-rebase full SQLite run failed in the kernel on `munmap`. + The root issue was `MemoryManager::munmap` rebuilding the entire mapping + table into a fresh `Vec` on every unmap; in the wasm kernel, allocation + failure/panic becomes `unreachable`. +- `MemoryManager::munmap` now updates mappings in place, preserves existing + mapping-table storage for non-splitting unmaps, and propagates `ENOMEM` only + if a middle split cannot reserve one extra slot. +- Validation: native kernel unit tests passed 866/866; focused host lifecycle + regressions passed 14/14; the targeted SQLite repro set + (`capi2.test`, `avtrans.test`, `temptable2.test`, `backup_malloc.test`) + passed 4/4 jobs and 2572/2572 cases with 0 errors in + `test-runs/main-rebase-full/20260601-155813-sqlite-targeted/`. + +The next full Node-host official SQLite run was stopped, not completed: + +- Run root: + `test-runs/main-rebase-full/20260601-160004-sqlite-full-node/`. +- At stop time the database had 1394 total jobs, 45 done, 91 failed, + 4 running, 1254 ready, 8183 reported cases, and 4554 case errors. +- The run log had no `handleSyscall kernel threw` or wasm out-of-bounds lines. + It did include one guest `testfixture.wasm` `unreachable` from pid 499; the + parent testrunner continued afterward, so this is not currently classified as + the same kernel-fatal class as the earlier `munmap` trap. +- The dominant remaining failure pattern is SQLite-level `database is locked` + output. A serial subset rerun with `--jobs 1` reduced five previously noisy + failures to 5 jobs, 267 cases, and 3 case errors: + `test-runs/main-rebase-full/20260601-160321-sqlite-failed-subset-j1/`. + In that subset, `tkt3731.test`, `func4.test`, and `vacuum5.test` passed; + `writecrash.test` still failed with `database is locked`, and `upfrom4.test` + still failed two SQL-result cases. + +Official SQLite suite inventory after the `getdents64` fix: + +- The large SQLite test suite is defined by upstream + `packages/registry/sqlite/sqlite-full-src/test/testrunner.tcl`, with Tcl + file sets from `test/permutations.test` and the `all` config list from + `test/testrunner_data.tcl`. +- `full` means the full Tcl file set. Current explain plans queue 1416 Tcl + jobs on both Node and browser hosts: + `test-runs/main-rebase-full/20260601-212612-sqlite-full-explain-node-getdents-fix/` + and + `test-runs/main-rebase-full/20260601-212612-sqlite-full-explain-browser-getdents-fix/`. +- `all` means `full` plus SQLite's official config permutations. Current + explain plans queue 10523 Tcl jobs on both Node and browser hosts: + `test-runs/main-rebase-full/20260601-212633-sqlite-all-explain-node-getdents-fix/` + and + `test-runs/main-rebase-full/20260601-212657-sqlite-all-explain-browser-getdents-fix/`. + The largest config groups are `full` 1416, `memsubsys1` 1329, + `memsubsys2` 1330, `no_mutex_try` 1331, `inmemory_journal` 1256, + `journaltest` 1164, `prepare` 1221, and `mmap` 1224. +- SQLite's "around 300,000 tests" figure refers to the internal case counts + each Tcl job reports as `N errors out of M tests`. Those counts are not known + from `--explain`; they are aggregated into `testrunner.db` as jobs execute. + Earlier stopped official `full` runs had already reported 586267, 836413, + and 839152 cases before completion, so the official path is the path that + reaches the hundreds-of-thousands case count. +- `scripts/run-software-unit-tests.sh` now runs SQLite through the official + testrunner by default. Set `SQLITE_OFFICIAL_PERMUTATION=all` to run the + wider permutation set. + +The earlier 1394/1393 `full` explain counts were wrong. The VFS image +contained the missing files, but the kernel consumed a host directory entry +before checking whether the guest `getdents64` buffer had room for it. If the +entry did not fit, the syscall returned without preserving that entry, so the +next `getdents64` call skipped it. `OpenFileDesc` now carries one pending +directory entry across calls, `lseek(SEEK_SET)` clears that pending entry, and +`sys_getdents64` advances the directory offset only after successfully writing +an entry to guest memory. The regressions +`test_getdents64_keeps_entry_that_does_not_fit` and +`test_getdents64_resumes_synthetic_entries_after_full_buffer` cover these +boundary cases. + +The next official Node `full --jobs 1` run was intentionally stopped before +completion: + +- Run root: + `test-runs/main-rebase-full/20260601-220000-sqlite-full-node-j1-getdents-synth-fix/`. +- At stop time it had 1416 jobs queued, 17 done, 1 failed, 1 running, + 1397 ready, 102908 reported SQLite cases, and 10 case errors. +- The failed job was `test/busy2.test`. Its first two failures showed + `PRAGMA journal_mode = wal` returning `delete`. That was an invalid Kandelo + artifact, not an upstream-suite issue: `packages/registry/sqlite/build-sqlite.sh` + and `packages/registry/sqlite/build-testfixture.sh` both used + `-DSQLITE_OMIT_WAL`. +- The no-WAL flag has been removed from both builds. `sqlite3.wasm`, + `testfixture.wasm`, and `apps/browser-demos/public/sqlite-test.vfs.zst` + were rebuilt with WAL enabled. +- Targeted validation through the official runner: + `test-runs/sqlite-official-node-full/20260601-213948/` ran + `busy2.test` and reduced the failure to 4 errors out of 29 cases. WAL mode + now works; the remaining failures are checkpoint/accounting differences: + expected `wal_checkpoint` results such as `{0 4 3}` are observed as + `{0 4 0}` or `{0 3 3}`. + +The remaining `busy2.test` failures expose a real platform bug, not a harness +problem. SQLite WAL uses byte-range locks plus a file-backed `MAP_SHARED` +mapping of `test.db-shm` as live shared memory. Kandelo currently populates a +file-backed mapping by copying file bytes into each process memory and writes +MAP_SHARED data back only on `msync` or `munmap`. That is not coherent shared +memory between separately allocated guest process memories. SQLite does not +use `msync` for its WAL index, so separate processes can observe stale +wal-index state even though fcntl byte locks are visible. The root fix is to +implement sound file-backed `MAP_SHARED` coherency across processes, then rerun +`busy2.test` and restart the full official SQLite run. + +SpiderMonkey official tests remain paused until the SQLite/kernel reliability +work is stable. The previous SpiderMonkey official Node path did not include +browser-host official execution, so it is not counted as proof for the +platform. + +## 2026-05-29 SQLite/SpiderMonkey Status + +SpiderMonkey official tests are intentionally paused. The browser host still +allocates a full 1 GiB shared WebAssembly memory per guest process because the +syscall channel is placed near max memory. That must be fixed in the memory +layout, not worked around in the harness, before official SpiderMonkey browser +numbers are meaningful. + +SQLite official `full` on the Node host is the active focus: + +- `test-runs/sqlite-official-node-full-thread-ceiling/20260529-142042/` + was stopped after the old Node crash path wedged parent `waitpid`. The DB + had 1394 jobs, 560 done, 21 failed, 4 running, 809 ready, 586267 reported + test cases, and 451 case errors. +- `test-runs/sqlite-official-node-full-crash-reap-fix/20260529-155403/` + progressed further, then was stopped on a kernel wasm `memory access out of + bounds` while `waitpid` reaped a child through `kernel_remove_process`. After + the process was stopped, the DB passed `pragma integrity_check` and reported + 1394 jobs, 873 done, 41 failed, 4 running, 476 ready, 836413 reported test + cases, and 617 case errors. +- The kernel allocator now records allocation metadata and coalesces free-list + overlaps defensively instead of deriving the free interval from the caller's + layout. The Node worker crash path now ignores duplicate error notifications + after the process has already been removed from the host process map. +- Validation after those fixes: kernel memory-manager unit tests passed 20/20, + `host/test/wasm-trap.test.ts` passed 3/3, and a targeted official SQLite run + of `fallocate.test` and `select7.test` failed cleanly with 2 reported case + errors instead of hanging. +- `test-runs/sqlite-official-node-full-allocator-fix/20260529-173059/` + reached 1394 jobs total, 875 done, 39 failed, 4 running, 476 ready, + 839152 reported test cases, and 612 case errors before it was stopped. The + first kernel failure was again `RuntimeError: memory access out of bounds` + during `kernel_remove_process` from `consumeExitedChild()` while handling + `waitpid`; active jobs were `pagerfault2.test`, `analyzeE.test`, + `fts3fault.test`, and `backup_ioerr.test`. +- The new OOB mapped to a `memory.copy` inside Rust's BTreeMap removal of a + large `Process` value. The most plausible root cause found so far was a + wasm-only allocator bug: allocations carved from a free block could leave the + suffix `FreeNode` at an unaligned `user + requested` address. That alignment + bug is now patched, and deallocation rejects unaligned allocation metadata. + This is under validation, not yet proven by a complete SQLite `full` run. +- Validation after the alignment patch: full native kernel tests passed + 850/850, `host/test/wasm-trap.test.ts` passed 3/3, and a targeted official + SQLite run for `pagerfault2.test`, `analyzeE.test`, `fts3fault.test`, and + `backup_ioerr.test` is running under + `test-runs/sqlite-official-node-reap-oob-regression/20260529-193249/`. + +SQLite's upstream testrunner repeatedly prints +`WARNING: Multi-threaded tests skipped: Linked against a non-threadsafe Tcl build`. +Those skipped Tcl-threaded cases are a caveat on all current SQLite official +numbers. + +## Important Corrections + +The previous status overstated two areas: + +- The `scripts/run-spidermonkey-unit-tests.sh` runner is not the official + Mozilla SpiderMonkey suite. It is a Kandelo smoke suite that exercises shell + builtins, Intl, workers, Atomics, file APIs, GC pressure, promises, and error + handling. Mozilla's official shell suites are `js/src/tests/jstests.py` and + `js/src/jit-test/jit_test.py`. +- The SQLite results counted Tcl test scripts, not individual SQLite test + cases. SQLite's own documentation distinguishes `veryquick`, `full`, `all`, + and `release`: `full` is all Tcl scripts, `all` is `full` plus permutations, + and `release` runs many build configurations plus fuzz/thread/mptest-style + work. The completed Kandelo runs executed the 1159 Tcl scripts once; they did + not run SQLite's official `all` or `release` permutations. + +`scripts/run-sqlite-upstream-tests.sh` and the browser SQLite runner now parse +and aggregate the internal `N errors out of M tests` counts for future runs. +The 2026-05-28 logs do not contain the per-script stdout needed to reconstruct +the exact SQLite case count after the fact. + +## Entry Points + +```bash +# Default pragmatic suite set on both hosts. SpiderMonkey here is the smoke +# suite because official browser-host SpiderMonkey is not wired yet. +scripts/run-software-unit-tests.sh + +# Run one host. +scripts/run-software-unit-tests.sh --host node +scripts/run-software-unit-tests.sh --host browser + +# Run selected suites. `mysql` is accepted as an alias for MariaDB. +scripts/run-software-unit-tests.sh --host browser sqlite php nodejs +scripts/run-software-unit-tests.sh --host node mariadb spidermonkey-official +``` + +| Suite | Node.js host | Browser host | +|-------|--------------|--------------| +| MariaDB / mysql-test | `scripts/run-mariadb-tests.sh --all` | `scripts/run-browser-mariadb-tests.sh --all` | +| SQLite direct Tcl scripts | `scripts/run-sqlite-upstream-tests.sh --all` | `scripts/run-browser-sqlite-upstream-tests.sh --all` | +| SQLite official testrunner | `scripts/run-sqlite-official-tests.sh --host node --permutation full` | `scripts/run-sqlite-official-tests.sh --host browser --permutation full` | +| PHP PHPT runtime tests | `scripts/run-php-upstream-tests.sh --host node --all` | `scripts/run-php-upstream-tests.sh --host browser --all` | + +PHP PHPT harness notes: + +```bash +# Full php-src PHPT run (all discovered .phpt files). +scripts/run-php-upstream-tests.sh --host node --all +scripts/run-php-upstream-tests.sh --host browser --all + +# Smoke/debug a prefix or selector and emit machine-readable results. +scripts/run-php-upstream-tests.sh --host node --limit 25 --json +scripts/run-php-upstream-tests.sh --host browser Zend/tests/001.phpt --json + +# Split a full sorted discovery set for lower-memory CI or AO shards. +scripts/run-php-upstream-tests.sh --host node --all --shard 1/16 --json +scripts/run-php-upstream-tests.sh --host browser --all --offset 500 --limit 100 --json + +# Write docs/php-upstream-test-report.md for the selected host/run. +scripts/run-php-upstream-tests.sh --host node --all --report +``` + +The PHPT harness writes the generated `--FILE--` section as the upstream +`.php` beside each `.phpt` file, then restores any pre-existing file. +This matches php-src's `run-tests.php` behavior for tests that assert `__FILE__` +or exception source locations. Browser runs use the same generated path inside +the `/php-src` VFS image and start a temporary Vite server; set +`PHP_TEST_VITE_PORT` if port `5201` is occupied. Browser PHPT runs pass Chromium +Wasm stack-switching flags by default so stack-heavy guest workloads get a +larger secondary Wasm stack in dedicated workers. Add extra browser flags with +`PHP_TEST_CHROMIUM_ARGS` or `KANDELO_CHROMIUM_ARGS`; set +`PHP_TEST_DISABLE_BROWSER_WASM_STACK_FLAGS=1` only when debugging those default +stack settings. + +The runner also mirrors `run-tests.php` comparison and working-directory +semantics: CRLF is normalized and both actual and expected output are trimmed +before comparison, `EXPECTF` placeholders include php-src's `%r...%r` regex and +`%0` NUL forms, and each PHP process runs with `TEST_PHP_SRCDIR` as its current +directory so source-root-relative paths such as `./ext/standard/tests/file` +behave like upstream. + +| SpiderMonkey smoke | `scripts/run-spidermonkey-unit-tests.sh --host node` | `scripts/run-spidermonkey-unit-tests.sh --host browser` | +| SpiderMonkey official | `scripts/run-spidermonkey-official-tests.sh --host node --suite both` | Not implemented | +| Node.js library tests | `scripts/run-nodejs-library-tests.sh --host node --all` | `scripts/run-nodejs-library-tests.sh --host browser --all` | + +## Official SpiderMonkey + +The official Node-host path uses Mozilla's Python harnesses with an executable +shim at `scripts/kandelo-js-shell-wrapper.sh`. The shim is passed to the +official harness as the `js` shell, but it runs `packages/registry/spidermonkey/bin/js.wasm` +inside Kandelo via `examples/run-example.ts`. + +`jstests.py` is run with shell WPT disabled by default +(`SPIDERMONKEY_OFFICIAL_WPT=disabled`) because the local Firefox source tree is +missing some Python import path setup needed by the WPT manifest updater. Set +`SPIDERMONKEY_OFFICIAL_WPT=enabled` after that dependency path is fixed. + +```bash +# One small official smoke from each Mozilla harness. +scripts/run-spidermonkey-official-tests.sh --suite both --smoke + +# Full official JS shell tests. +scripts/run-spidermonkey-official-tests.sh --suite jstests +scripts/run-spidermonkey-official-tests.sh --suite jit-tests +scripts/run-spidermonkey-official-tests.sh --suite both + +# Pass jstests path selectors after --. +scripts/run-spidermonkey-official-tests.sh --suite jstests -- non262/Array/array-001.js +``` + +Browser-host official SpiderMonkey remains open work. The browser would need a +persistent Playwright/Vite bridge or a browser-native implementation of the +Mozilla harness command scheduling, plus a VFS image containing the official +`js/src/tests` and `js/src/jit-test` trees. + +## SQLite Scope + +There are now two SQLite paths: + +- Direct script runner: runs each `test/*.test` file once through `testfixture`. + This is the runner used for the completed Node and browser results above. +- Official testrunner: invokes SQLite's upstream `test/testrunner.tcl` for + `veryquick`, `full`, or `all` on the Node or browser host. A `full main.test` + smoke run completed with `0 errors out of 95 tests`. + +```bash +# Direct runner, one pass over test/*.test. +scripts/run-sqlite-upstream-tests.sh --all +scripts/run-browser-sqlite-upstream-tests.sh --all + +# Official upstream testrunner permutations. +scripts/run-sqlite-official-tests.sh --host node --permutation veryquick +scripts/run-sqlite-official-tests.sh --host node --permutation full +scripts/run-sqlite-official-tests.sh --host node --permutation all +scripts/run-sqlite-official-tests.sh --host browser --permutation full + +# Explain planned official work without running it. +scripts/run-sqlite-official-tests.sh --host node --permutation all --explain +``` + +SQLite `release`, `mdevtest`, and `sdevtest` are not wired as Kandelo guest +runs yet because they require rebuilding multiple host configurations and +running additional fuzz/thread/mptest binaries. + +## Prerequisites + +Build or fetch `kernel.wasm` before running any browser or Node suite: + +```bash +bash build.sh +# or +scripts/fetch-binaries.sh +``` + +MariaDB needs `mariadbd`, `mysqltest.wasm`, and the `mysql-test/` tree: + +```bash +bash packages/registry/mariadb/build-mariadb.sh +``` + +SQLite needs Tcl, SQLite, and the testfixture binary: + +```bash +bash packages/registry/tcl/build-tcl.sh +bash packages/registry/sqlite/build-sqlite.sh +bash packages/registry/sqlite/build-testfixture.sh +``` + +PHP needs the CLI wasm binary. The PHPT source tree is taken from +`PHP_SOURCE_DIR`, a local `packages/registry/php/php-src`, or the package +source tarball: + +```bash +bash packages/registry/php/build-php.sh +``` + +SpiderMonkey needs the standalone JS shell wasm binary: + +```bash +bash packages/registry/spidermonkey/build-spidermonkey.sh +``` + +Node.js library tests use the SpiderMonkey-backed `node.wasm` package and the +upstream Node.js source tree. By default the runner downloads the source bundle +matching the host `node` version and verifies it with Node.js `SHASUMS256.txt`; +override with `NODEJS_TEST_VERSION` or `NODEJS_SOURCE_DIR`: + +```bash +bash packages/registry/spidermonkey-node/build-spidermonkey-node.sh +scripts/run-nodejs-library-tests.sh --host node --list +``` + +Browser runs build suite-specific VFS images under +`apps/browser-demos/public/` when missing: + +```bash +bash images/vfs/scripts/build-sqlite-test-vfs-image.sh +bash images/vfs/scripts/build-php-test-vfs-image.sh +bash images/vfs/scripts/build-spidermonkey-test-vfs-image.sh +bash images/vfs/scripts/build-nodejs-test-vfs-image.sh +``` diff --git a/images/rootfs/etc/services b/images/rootfs/etc/services index 41b895dba..8ba68d7ef 100644 --- a/images/rootfs/etc/services +++ b/images/rootfs/etc/services @@ -8,8 +8,11 @@ ftp 21/tcp ssh 22/tcp telnet 23/tcp smtp 25/tcp mail +nicname 43/tcp whois domain 53/tcp domain 53/udp +gopher 70/tcp +finger 79/tcp http 80/tcp www pop3 110/tcp pop-3 nntp 119/tcp readnews untp diff --git a/images/vfs/scripts/build-php-test-vfs-image.sh b/images/vfs/scripts/build-php-test-vfs-image.sh new file mode 100755 index 000000000..ac38c0811 --- /dev/null +++ b/images/vfs/scripts/build-php-test-vfs-image.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +cd "$REPO_ROOT" +npx tsx "$SCRIPT_DIR/build-php-test-vfs-image.ts" "$@" diff --git a/images/vfs/scripts/build-php-test-vfs-image.ts b/images/vfs/scripts/build-php-test-vfs-image.ts new file mode 100644 index 000000000..496a39c69 --- /dev/null +++ b/images/vfs/scripts/build-php-test-vfs-image.ts @@ -0,0 +1,541 @@ +/** + * Build a VFS image for running php-src PHPT runtime tests in the browser. + * + * The image contains: + * - /bin/sh plus standard shell utilities for PHP's shell-backed exec APIs + * - /usr/local/bin/php + * - /php-src/ + * + * The Playwright-side runner parses each .phpt file and writes transient + * PHP scripts into the restored image before spawning /usr/local/bin/php. + */ +import { cpSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + ensureDir, + ensureDirRecursive, + symlink, + writeVfsFile, + writeVfsBinary, +} from "../../../host/src/vfs/image-helpers"; +import { findRepoRoot, tryResolveBinary } from "../../../host/src/binary-resolver"; +import { ensureSourceExtract } from "./source-extract-helper"; +import { saveImage, walkAndWrite } from "./vfs-image-helpers"; + +const REPO_ROOT = findRepoRoot(); +const LOCAL_PHP_SRC = join(REPO_ROOT, "packages/registry/php/php-src"); +const PHP_WASM = process.env.PHP_WASM + ?? tryResolveBinary("programs/php/php.wasm") + ?? join(LOCAL_PHP_SRC, "sapi/cli/php"); +const OPCACHE_SO = process.env.PHP_OPCACHE_SO + ?? tryResolveBinary("programs/php/opcache.so"); +const PHP_EXTENSION_DIR = process.env.PHP_EXTENSION_DIR + ?? (OPCACHE_SO ? dirname(OPCACHE_SO) : undefined); +const DASH_WASM = process.env.DASH_WASM + ?? tryResolveBinary("programs/dash.wasm"); +const COREUTILS_WASM = process.env.COREUTILS_WASM + ?? tryResolveBinary("programs/coreutils.wasm"); +const SED_WASM = process.env.SED_WASM + ?? tryResolveBinary("programs/sed.wasm"); +const GREP_WASM = process.env.GREP_WASM + ?? tryResolveBinary("programs/grep.wasm"); +const OUT_FILE = process.env.PHP_TEST_VFS_OUT + ?? join(REPO_ROOT, "apps/browser-demos/public/php-test.vfs.zst"); +const FS_INITIAL_BYTES = Number(process.env.PHP_TEST_VFS_INITIAL_BYTES ?? 256 * 1024 * 1024); +const FS_MAX_BYTES = Number(process.env.PHP_TEST_VFS_MAX_BYTES ?? 2 * 1024 * 1024 * 1024); + +const ETC_PASSWD = [ + "root:x:0:0:root:/root:/bin/sh", + "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin", + "user:x:1000:1000:user:/home/user:/bin/sh", + "", +].join("\n"); + +const ETC_GROUP = [ + "root:x:0:", + "nogroup:x:65534:", + "nobody:x:65534:", + "user:x:1000:", + "", +].join("\n"); + +const ETC_SERVICES = readFileSync( + join(REPO_ROOT, "images/rootfs/etc/services"), + "utf8", +); + +const COREUTILS_NAMES = [ + "arch", "b2sum", "base32", "base64", "basename", "basenc", "cat", + "chcon", "chgrp", "chmod", "chown", "chroot", "cksum", "comm", "cp", + "csplit", "cut", "date", "dd", "df", "dir", "dircolors", "dirname", + "du", "echo", "env", "expand", "expr", "factor", "false", "fmt", + "fold", "groups", "head", "hostid", "id", "install", "join", "link", + "ln", "logname", "ls", "md5sum", "mkdir", "mkfifo", "mknod", "mktemp", + "mv", "nice", "nl", "nohup", "nproc", "numfmt", "od", "paste", + "pathchk", "pr", "printenv", "printf", "ptx", "pwd", "readlink", + "realpath", "rm", "rmdir", "runcon", "seq", "sha1sum", "sha224sum", + "sha256sum", "sha384sum", "sha512sum", "shred", "shuf", "sleep", + "sort", "split", "stat", "stty", "sum", "sync", "tac", "tail", + "tee", "test", "timeout", "touch", "tr", "true", "truncate", "tsort", + "tty", "uname", "unexpand", "uniq", "unlink", "vdir", "wc", "whoami", + "yes", +]; + +const PGREP_SCRIPT = `#!/bin/sh +if [ "$1" != "-P" ] || [ -z "$2" ]; then + exit 2 +fi +want_ppid=$2 +found=1 +for stat in /proc/[0-9]*/stat; do + [ -r "$stat" ] || continue + line=$(cat "$stat" 2>/dev/null) || continue + pid=\${line%% *} + after=\${line#*) } + set -- $after + ppid=$2 + if [ "$ppid" = "$want_ppid" ]; then + printf '%s\\n' "$pid" + found=0 + fi +done +exit "$found" +`; + +const PS_SCRIPT = `#!/bin/sh +pids= +format= +while [ "$#" -gt 0 ]; do + case "$1" in + -p|--pid) + shift + pids=$1 + ;; + -p*) + pids=\${1#-p} + ;; + -o|--format) + shift + format=$1 + ;; + -o*) + format=\${1#-o} + ;; + *) + ;; + esac + shift +done + +[ -n "$format" ] || format=pid,command +header=1 +case "$format" in + *=*) + header=0 + ;; +esac + +fields=$(printf '%s' "$format" | tr ',' ' ') +if [ -n "$pids" ]; then + pid_list=$(printf '%s' "$pids" | tr ',' ' ') +else + pid_list= + for proc_dir in /proc/[0-9]*; do + [ -d "$proc_dir" ] || continue + pid_list="$pid_list \${proc_dir#/proc/}" + done +fi + +if [ "$header" = 1 ]; then + out= + for field in $fields; do + field=\${field%=} + case "$field" in + pid) label=PID ;; + nice|ni) label=NICE ;; + comm|command|args) label=COMMAND ;; + *) label=$(printf '%s' "$field" | tr '[:lower:]' '[:upper:]') ;; + esac + out="$out\${out:+ }$label" + done + printf '%s\\n' "$out" +fi + +found=0 +for pid in $pid_list; do + stat=/proc/$pid/stat + [ -r "$stat" ] || continue + line=$(cat "$stat" 2>/dev/null) || continue + comm=\${line#*(} + comm=\${comm%)*} + after=\${line#*) } + set -- $after + nice=\${17:-0} + cmd=$(tr '\\000' ' ' < /proc/$pid/cmdline 2>/dev/null) + [ -n "$cmd" ] || cmd=$comm + row= + for field in $fields; do + field=\${field%=} + case "$field" in + pid) value=$pid ;; + nice|ni) value=$nice ;; + comm|command|args) value=$cmd ;; + *) value= ;; + esac + row="$row\${row:+ }$value" + done + printf '%s\\n' "$row" + found=1 +done + +[ "$found" = 1 ] +`; + +function resolvePhpSource(): string { + return process.env.PHP_SOURCE_DIR + ?? ensureSourceExtract("php", REPO_ROOT, existsSync(LOCAL_PHP_SRC) ? LOCAL_PHP_SRC : undefined); +} + +function collectPhptDirs(root: string): string[] { + const dirs = new Set(); + function walk(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === ".git" || entry.name === ".deps" || entry.name === ".libs") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith(".phpt")) { + dirs.add(dir); + } + } + } + walk(root); + // Some PHPTs include helper fixtures from extension directories that do not + // themselves contain .phpt files. Keep those directories in the browser VFS + // so SKIPIF sections behave like they do against a complete php-src tree. + for (const rel of ["ext/dl_test/tests"]) { + const full = join(root, rel); + if (existsSync(full)) dirs.add(full); + } + return [...dirs].sort(); +} + +const SUPPORT_FILE_PATTERN = + /\.(?:inc|php|phtml|pem|crt|csr|key|cnf|ini|txt|dat|data|json|xml|xsd|dtd|rng|csv|sql|stub)$/i; + +function isTestPath(relPath: string): boolean { + return relPath.split(/[\\/]+/).includes("tests"); +} + +function isSupportFileName(name: string): boolean { + return SUPPORT_FILE_PATTERN.test(name); +} + +function directoryHasSupportFiles(sourceRoot: string, dir: string): boolean { + const relDir = relative(sourceRoot, dir); + if (!relDir || !isTestPath(relDir)) return false; + for (const entry of readdirSync(dir)) { + if (!isSupportFileName(entry)) continue; + try { + if (statSync(join(dir, entry)).isFile()) return true; + } catch { + // Ignore unreadable or disappearing entries. + } + } + return false; +} + +function collectPhptSupportDirs(sourceRoot: string, phptDirs: string[]): string[] { + const dirs = new Set(); + const phptDirSet = new Set(phptDirs); + for (const phptDir of phptDirs) { + let current = dirname(phptDir); + while (current !== sourceRoot && current.startsWith(sourceRoot)) { + if (!phptDirSet.has(current) && directoryHasSupportFiles(sourceRoot, current)) { + dirs.add(current); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + } + return [...dirs].sort(); +} + +function copySupportFiles( + fs: MemoryFileSystem, + sourceRoot: string, + dir: string, +): number { + const relDir = relative(sourceRoot, dir); + const destDir = relDir ? `/php-src/${relDir}` : "/php-src"; + ensureDirRecursive(fs, destDir); + let count = 0; + for (const entry of readdirSync(dir)) { + if (!isSupportFileName(entry)) continue; + const relPath = relDir ? `${relDir}/${entry}` : entry; + if (shouldExclude(sourceRoot, relPath)) continue; + const full = join(dir, entry); + try { + if (!statSync(full).isFile()) continue; + writeVfsBinary(fs, `${destDir}/${entry}`, new Uint8Array(readFileSync(full)), 0o644); + count++; + } catch { + // Skip unreadable or disappearing support files, matching walkAndWrite. + } + } + return count; +} + +function preparePhpTestFixtures(sourceRoot: string): void { + const fixtureDir = join(REPO_ROOT, "tests/php-fixtures/openssl-sni-2036"); + const destDir = join(sourceRoot, "ext/openssl/tests"); + if (existsSync(fixtureDir) && existsSync(destDir)) { + for (const entry of readdirSync(fixtureDir)) { + if (!entry.startsWith("sni_server_") || !entry.endsWith(".pem")) continue; + cpSync(join(fixtureDir, entry), join(destDir, entry)); + } + } + + const mysqliFakeServer = join(sourceRoot, "ext/mysqli/tests/fake_server.inc"); + if (existsSync(mysqliFakeServer)) { + const text = readFileSync(mysqliFakeServer, "utf8"); + if (!text.includes("MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS")) { + const from = ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`; + const to = ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + + if ($data && $bytes_len > 1024) { + // Large reads in this fake MySQL server are used to drain the + // connection tail after the client reacts to a crafted packet. + // fread() on a POSIX stream may return as soon as any bytes are + // available; it is not required to wait for later client writes to + // coalesce into the same TCP segment. Native php-src runs usually + // see the final COM_STMT_CLOSE and COM_QUIT together after the + // fixed sleep above, but the browser host can schedule the guest + // peer more slowly. Keep draining for a short idle window and print + // one Received line so the fixture remains semantically identical + // without relying on transport coalescing. + $idleMs = getenv('MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS'); + $idleMs = $idleMs !== false && is_numeric($idleMs) ? max(0, (int) $idleMs) : 250; + $deadline = microtime(true) + ($idleMs / 1000); + $wasBlocking = stream_get_meta_data($this->conn)['blocked'] ?? true; + stream_set_blocking($this->conn, false); + try { + while (strlen($data) < $bytes_len && microtime(true) < $deadline) { + usleep(10000); + $chunk = fread($this->conn, $bytes_len - strlen($data)); + if ($chunk !== false && $chunk !== '') { + $data .= $chunk; + $deadline = microtime(true) + ($idleMs / 1000); + } + } + } finally { + stream_set_blocking($this->conn, $wasBlocking); + } + } + + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP mysqli fake_server fixture: read() marker not found in ${mysqliFakeServer}`, + ); + } + // The source tree is a local extracted test fixture, not tracked PHP + // package source. Patch it before packing the browser VFS so browser and + // Node PHPT runs exercise the same transport-tolerant fixture behavior. + writeFileSync(mysqliFakeServer, text.replace(from, to), "utf8"); + } + } +} + +function shouldExclude(sourceRoot: string, relPath: string): boolean { + const base = relPath.split("/").pop() ?? relPath; + if (relPath.includes("/.git/") || relPath.includes("/.deps/") || relPath.includes("/.libs/")) return true; + if (base.startsWith(".nfs")) return true; + if (isGeneratedPhptArtifact(sourceRoot, relPath)) return true; + if (base.endsWith(".o") || base.endsWith(".lo") || base.endsWith(".la") || base.endsWith(".a")) return true; + if (base === "php" || base === "phpdbg" || base === "php-cgi" || base === "php-fpm") { + try { + const st = statSync(join(sourceRoot, relPath)); + return st.size > 1024 * 1024; + } catch { + return true; + } + } + return false; +} + +function isGeneratedPhptArtifact(sourceRoot: string, relPath: string): boolean { + const slash = relPath.lastIndexOf("/"); + const dir = slash >= 0 ? relPath.slice(0, slash) : ""; + const base = slash >= 0 ? relPath.slice(slash + 1) : relPath; + + // Some PHPTs create a same-stem directory next to the test and then remove + // it from --CLEAN--. If a long browser run is interrupted during the test, + // the source checkout/cache can retain a huge generated directory; baking it + // into the immutable browser VFS changes the next run's initial state. Keep + // small same-stem directories because upstream also uses that convention for + // legitimate helper fixtures (for example ext/phar/tests/bug53872/). + if (base && existsSync(join(sourceRoot, dir, `${base}.phpt`))) { + try { + const full = join(sourceRoot, relPath); + const st = statSync(full); + if (st.isDirectory() && readdirSync(full).length >= 100) { + return true; + } + } catch { + // Fall through to the file-artifact checks below. + } + } + + for (const suffix of [".skip.php", ".clean.php", ".php"]) { + if (!base.endsWith(suffix)) continue; + const stem = base.slice(0, -suffix.length); + if (stem && existsSync(join(sourceRoot, dir, `${stem}.phpt`))) return true; + } + + // PHPTs commonly leave archives/databases named after the test stem when a + // run is interrupted before --CLEAN--. Those files are execution products, + // not source fixtures; baking them into the browser image changes future + // test initial state (for example PharData opens an existing corrupt .zip + // instead of creating a new archive). Keep same-stem PHPT artifacts out of + // the immutable browser VFS image while preserving unrelated helper files. + const artifact = base.match(/^(.+?)(\.(?:\d+\.)*(?:phar|tar|zip|db|sqlite|sqlite3)(?:\.[A-Za-z0-9_-]+)*)$/); + if (!artifact) return false; + return existsSync(join(sourceRoot, dir, `${artifact[1]}.phpt`)); +} + +async function main() { + if (!existsSync(PHP_WASM)) { + throw new Error(`PHP wasm not found at ${PHP_WASM}. Run: bash packages/registry/php/build-php.sh`); + } + if (!DASH_WASM || !existsSync(DASH_WASM)) { + throw new Error("dash.wasm not found. Run: scripts/fetch-binaries.sh or set DASH_WASM"); + } + if (!COREUTILS_WASM || !existsSync(COREUTILS_WASM)) { + throw new Error("coreutils.wasm not found. Run: scripts/fetch-binaries.sh or set COREUTILS_WASM"); + } + if (!SED_WASM || !existsSync(SED_WASM)) { + throw new Error("sed.wasm not found. Run: scripts/fetch-binaries.sh or set SED_WASM"); + } + if (!GREP_WASM || !existsSync(GREP_WASM)) { + throw new Error("grep.wasm not found. Run: scripts/fetch-binaries.sh or set GREP_WASM"); + } + const phpSrc = resolvePhpSource(); + if (!existsSync(phpSrc)) { + throw new Error(`php-src not found at ${phpSrc}`); + } + preparePhpTestFixtures(phpSrc); + + console.log("==> Building PHP PHPT test VFS image"); + console.log(` php-src: ${phpSrc}`); + + const sab = new SharedArrayBuffer(FS_INITIAL_BYTES, { maxByteLength: FS_MAX_BYTES }); + const fs = MemoryFileSystem.create(sab, FS_MAX_BYTES); + for (const dir of [ + "/tmp", "/home", "/root", "/dev", "/etc", "/bin", "/usr", "/usr/bin", + "/usr/lib", "/usr/lib/php", "/usr/lib/php/extensions", + "/usr/local", "/usr/local/bin", "/php-src", + ]) { + ensureDir(fs, dir); + } + fs.chmod("/tmp", 0o1777); + writeVfsFile(fs, "/etc/passwd", ETC_PASSWD); + writeVfsFile(fs, "/etc/group", ETC_GROUP); + writeVfsFile(fs, "/etc/services", ETC_SERVICES); + + writeVfsBinary(fs, "/usr/bin/dash", new Uint8Array(readFileSync(DASH_WASM))); + symlink(fs, "/usr/bin/dash", "/bin/sh"); + symlink(fs, "/usr/bin/dash", "/bin/dash"); + + writeVfsBinary(fs, "/usr/bin/coreutils", new Uint8Array(readFileSync(COREUTILS_WASM))); + for (const name of COREUTILS_NAMES) { + symlink(fs, "/usr/bin/coreutils", `/bin/${name}`); + symlink(fs, "/usr/bin/coreutils", `/usr/bin/${name}`); + } + symlink(fs, "/usr/bin/coreutils", "/bin/["); + symlink(fs, "/usr/bin/coreutils", "/usr/bin/["); + + writeVfsBinary(fs, "/usr/bin/sed", new Uint8Array(readFileSync(SED_WASM))); + symlink(fs, "/usr/bin/sed", "/bin/sed"); + + writeVfsBinary(fs, "/usr/bin/grep", new Uint8Array(readFileSync(GREP_WASM))); + symlink(fs, "/usr/bin/grep", "/bin/grep"); + symlink(fs, "/usr/bin/grep", "/usr/bin/egrep"); + symlink(fs, "/usr/bin/grep", "/bin/egrep"); + symlink(fs, "/usr/bin/grep", "/usr/bin/fgrep"); + symlink(fs, "/usr/bin/grep", "/bin/fgrep"); + + writeVfsFile(fs, "/usr/bin/pgrep", PGREP_SCRIPT, 0o755); + symlink(fs, "/usr/bin/pgrep", "/bin/pgrep"); + writeVfsFile(fs, "/usr/bin/ps", PS_SCRIPT, 0o755); + symlink(fs, "/usr/bin/ps", "/bin/ps"); + + writeVfsBinary(fs, "/usr/local/bin/php", new Uint8Array(readFileSync(PHP_WASM))); + if (PHP_EXTENSION_DIR && existsSync(PHP_EXTENSION_DIR)) { + for (const entry of readdirSync(PHP_EXTENSION_DIR)) { + if (!entry.endsWith(".so")) continue; + const src = join(PHP_EXTENSION_DIR, entry); + writeVfsBinary( + fs, + `/usr/lib/php/extensions/${entry}`, + new Uint8Array(readFileSync(src)), + ); + } + } + if (OPCACHE_SO && existsSync(OPCACHE_SO)) { + // PHP_OPCACHE_SO is the explicit harness override for the OPcache side + // module. Honor it even when PHP_EXTENSION_DIR also contains an + // opcache.so; otherwise browser PHPT runs can silently package a stale + // or non-side-module opcache under the canonical extension path while the + // runner advertises OPcache as available. + writeVfsBinary( + fs, + "/usr/lib/php/extensions/opcache.so", + new Uint8Array(readFileSync(OPCACHE_SO)), + ); + } + + const phptDirs = collectPhptDirs(phpSrc); + const supportDirs = collectPhptSupportDirs(phpSrc, phptDirs); + console.log(` Writing ${phptDirs.length} PHPT directories...`); + let fileCount = 0; + for (const dir of phptDirs) { + const rel = relative(phpSrc, dir); + const dest = rel ? `/php-src/${rel}` : "/php-src"; + ensureDirRecursive(fs, dirname(dest)); + fileCount += walkAndWrite(fs, dir, dest, { + exclude: (childRel) => shouldExclude(phpSrc, rel ? `${rel}/${childRel}` : childRel), + }); + } + if (supportDirs.length > 0) { + console.log(` Writing ${supportDirs.length} PHPT support directories...`); + for (const dir of supportDirs) { + fileCount += copySupportFiles(fs, phpSrc, dir); + } + } + console.log(` ${fileCount} files`); + + await saveImage(fs, OUT_FILE); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/registry/less/build-less.sh b/packages/registry/less/build-less.sh index 8a54a9bea..1518072b3 100755 --- a/packages/registry/less/build-less.sh +++ b/packages/registry/less/build-less.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Build less 661 for wasm32-posix-kernel. +# Build less for wasm32-posix-kernel. # # Uses the SDK's wasm32posix-configure wrapper for cross-compilation. # Output: packages/registry/less/bin/less.wasm @@ -11,7 +11,7 @@ set -euo pipefail # returns "not found" — less then falls back to hardcoded ANSI sequences. # We also provide a minimal termcap.h header. -LESS_VERSION="${LESS_VERSION:-661}" +LESS_VERSION="${LESS_VERSION:-668}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" SRC_DIR="$SCRIPT_DIR/less-src" @@ -47,8 +47,28 @@ fi if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading less $LESS_VERSION..." TARBALL="less-${LESS_VERSION}.tar.gz" - URL="https://www.greenwoodsoftware.com/less/${TARBALL}" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$URL" -o "/tmp/$TARBALL" + DOWNLOAD_URLS=( + "https://www.greenwoodsoftware.com/less/${TARBALL}" + "https://ftp.gnu.org/gnu/less/${TARBALL}" + ) + for URL in "${DOWNLOAD_URLS[@]}"; do + if curl \ + --connect-timeout 20 \ + --retry 3 \ + --retry-delay 5 \ + --retry-max-time 120 \ + --retry-all-errors \ + -fsSL "$URL" \ + -o "/tmp/$TARBALL" + then + break + fi + rm -f "/tmp/$TARBALL" + done + if [ ! -f "/tmp/$TARBALL" ]; then + echo "ERROR: failed to download $TARBALL from all configured mirrors" >&2 + exit 1 + fi mkdir -p "$SRC_DIR" tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 rm "/tmp/$TARBALL" diff --git a/packages/registry/libiconv/build-libiconv.sh b/packages/registry/libiconv/build-libiconv.sh new file mode 100755 index 000000000..7d6ebde9b --- /dev/null +++ b/packages/registry/libiconv/build-libiconv.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Build GNU libiconv for wasm32/wasm64-posix-kernel. +# +# Honors the dep-resolver build-script contract. Resolver-provided builds set: +# WASM_POSIX_DEP_OUT_DIR +# WASM_POSIX_DEP_VERSION +# WASM_POSIX_DEP_SOURCE_URL +# WASM_POSIX_DEP_SOURCE_SHA256 +# +# Legacy invocation installs into packages/registry/libiconv/libiconv-install. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SRC_DIR="$SCRIPT_DIR/libiconv-src" + +# Worktree-local SDK on PATH (no global npm link required). +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +LIBICONV_VERSION="${WASM_POSIX_DEP_VERSION:-${LIBICONV_VERSION:-1.17}}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/libiconv-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://ftp.gnu.org/pub/gnu/libiconv/libiconv-${LIBICONV_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" + +if ! command -v wasm32posix-cc &>/dev/null; then + echo "ERROR: wasm32posix-cc not found. Run inside nix develop or source sdk/activate.sh with LLVM available." >&2 + exit 1 +fi + +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" + +if [ ! -d "$SRC_DIR" ]; then + echo "==> Downloading GNU libiconv $LIBICONV_VERSION..." + TARBALL="/tmp/libiconv-${LIBICONV_VERSION}.tar.gz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + else + echo "==> (no SOURCE_SHA256 declared; skipping verification)" + fi + mkdir -p "$SRC_DIR" + tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + rm "$TARBALL" +fi + +cd "$SRC_DIR" +make distclean 2>/dev/null || true +rm -rf "$INSTALL_DIR" + +echo "==> Configuring GNU libiconv for Wasm..." +wasm32posix-configure \ + --disable-shared \ + --enable-static \ + --disable-nls \ + --prefix="$INSTALL_DIR" \ + CFLAGS="-O2" + +echo "==> Building GNU libiconv..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +echo "==> Installing to $INSTALL_DIR..." +make install + +mkdir -p "$INSTALL_DIR/lib/pkgconfig" +cat > "$INSTALL_DIR/lib/pkgconfig/libiconv.pc" < GNU libiconv build complete!" + ls -lh "$INSTALL_DIR/lib/libiconv.a" "$INSTALL_DIR/lib/libcharset.a" +else + echo "ERROR: Build failed — libiconv/libcharset archive missing" >&2 + exit 1 +fi diff --git a/packages/registry/libiconv/build.toml b/packages/registry/libiconv/build.toml new file mode 100644 index 000000000..d2c499c35 --- /dev/null +++ b/packages/registry/libiconv/build.toml @@ -0,0 +1,7 @@ +script_path = "packages/registry/libiconv/build-libiconv.sh" +repo_url = "https://github.com/brandonpayton/kandelo.git" +commit = "8c53383229fab78f97b098c3207a655159c03041" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/libiconv/package.json b/packages/registry/libiconv/package.json new file mode 100644 index 000000000..4692d0e1c --- /dev/null +++ b/packages/registry/libiconv/package.json @@ -0,0 +1,15 @@ +{ + "name": "wasm-posix-libiconv-example", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build-libiconv": "bash build-libiconv.sh", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^3.2.0" + } +} diff --git a/packages/registry/libiconv/package.toml b/packages/registry/libiconv/package.toml new file mode 100644 index 000000000..2bfc41dfa --- /dev/null +++ b/packages/registry/libiconv/package.toml @@ -0,0 +1,27 @@ +# Per-library manifest for GNU libiconv. See docs/package-management.md. + +kind = "library" + +name = "libiconv" +version = "1.17" +kernel_abi = 7 +depends_on = [] +# PHP depends on libiconv. Keep both wasm widths available for transitive +# consumers that build broader runtime stacks. +arches = ["wasm32", "wasm64"] + +[source] +url = "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz" +sha256 = "8f74213b56238c85a50a5329f77e06198771e70dd9a739779f4c02f65d971313" + +[license] +spdx = "LGPL-2.1-or-later AND GPL-3.0-or-later" +url = "https://git.savannah.gnu.org/cgit/libiconv.git/tree/COPYING?h=v1.17" + +[build] +script_path = "packages/registry/libiconv/build-libiconv.sh" + +[outputs] +libs = ["lib/libiconv.a", "lib/libcharset.a"] +headers = ["include/iconv.h", "include/libcharset.h", "include/localcharset.h"] +pkgconfig = ["lib/pkgconfig/libiconv.pc"] diff --git a/packages/registry/libxml2/build-libxml2.sh b/packages/registry/libxml2/build-libxml2.sh index e0d8670d0..9c00bbc09 100644 --- a/packages/registry/libxml2/build-libxml2.sh +++ b/packages/registry/libxml2/build-libxml2.sh @@ -12,6 +12,7 @@ # WASM_POSIX_DEP_SOURCE_URL # tarball URL # WASM_POSIX_DEP_SOURCE_SHA256 # expected sha256 of the tarball # WASM_POSIX_DEP_ZLIB_DIR # resolved zlib prefix (direct dep) +# WASM_POSIX_DEP_LIBICONV_DIR # resolved GNU libiconv prefix (direct dep) # # For ad-hoc / legacy invocation (`bash build-libxml2.sh`), the script # falls back to the in-tree `libxml2-install/` layout and to a @@ -43,7 +44,7 @@ fi SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" export WASM_POSIX_SYSROOT="$SYSROOT" -# --- Locate zlib --- +# --- Locate zlib / libiconv --- # Resolver surfaces the direct-dep install path via contract env var. # Legacy mode falls back to the sibling zlib-install dir that # `build-zlib.sh` lays down (also our historical layout). @@ -62,6 +63,21 @@ if [ ! -f "$ZLIB_PREFIX/lib/libz.a" ]; then exit 1 fi +LIBICONV_PREFIX="${WASM_POSIX_DEP_LIBICONV_DIR:-}" +if [ -z "$LIBICONV_PREFIX" ]; then + LEGACY_LIBICONV="$SCRIPT_DIR/../libiconv/libiconv-install" + if [ ! -f "$LEGACY_LIBICONV/lib/libiconv.a" ]; then + echo "==> Building GNU libiconv (legacy path)..." + bash "$SCRIPT_DIR/../libiconv/build-libiconv.sh" + fi + LIBICONV_PREFIX="$LEGACY_LIBICONV" +fi + +if [ ! -f "$LIBICONV_PREFIX/lib/libiconv.a" ]; then + echo "ERROR: GNU libiconv not found at $LIBICONV_PREFIX" >&2 + exit 1 +fi + # --- Fetch + verify source --- if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading libxml2 $LIBXML2_VERSION..." @@ -88,12 +104,14 @@ rm -f config.h config.status wasm32posix-configure \ --disable-shared --enable-static \ - --without-python --without-readline --without-iconv \ + --without-python --without-readline \ --without-icu --without-lzma --without-http --without-ftp \ --without-threads \ --with-zlib="$ZLIB_PREFIX" \ + --with-iconv="$LIBICONV_PREFIX" \ --prefix="$INSTALL_DIR" \ - CFLAGS="-O2" + CFLAGS="-O2 -I$LIBICONV_PREFIX/include" \ + LDFLAGS="-L$LIBICONV_PREFIX/lib" # Compile directly without libtool. Source list mirrors Makefile.am's # libxml2_la_SOURCES plus the modules our `configure` run enables. @@ -112,7 +130,7 @@ SOURCES=( schematron.c ) -CFLAGS="-O2 -DHAVE_CONFIG_H -I. -I./include" +CFLAGS="-O2 -DHAVE_CONFIG_H -I. -I./include -I$ZLIB_PREFIX/include -I$LIBICONV_PREFIX/include" echo "==> Compiling libxml2 source files..." OBJS=() @@ -150,7 +168,7 @@ Description: libXML library version2. Version: $LIBXML2_VERSION Requires: Libs: -L\${libdir} -lxml2 -Libs.private: -lz -lm +Libs.private: -liconv -lcharset -lz -lm Cflags: -I\${includedir}/libxml PCEOF diff --git a/packages/registry/libxml2/build.toml b/packages/registry/libxml2/build.toml index e083180ee..88f31e4e8 100644 --- a/packages/registry/libxml2/build.toml +++ b/packages/registry/libxml2/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/libxml2/build-libxml2.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 1 +revision = 2 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/libxml2/package.toml b/packages/registry/libxml2/package.toml index 19ba5da96..a7553ce59 100644 --- a/packages/registry/libxml2/package.toml +++ b/packages/registry/libxml2/package.toml @@ -1,16 +1,15 @@ # Per-library manifest for libxml2. See docs/package-management.md. # -# libxml2 depends on zlib for optional compressed-input support (the -# `--with-zlib` configure path). Only zlib is declared as a build-time -# dep in legacy; libxml2's other optional deps (lzma, icu, python, etc.) -# are all disabled via `--without-*` in build-libxml2.sh. +# libxml2 depends on zlib for optional compressed-input support and GNU +# libiconv for non-UTF/XML encodings. Other optional deps (lzma, icu, python, +# etc.) are disabled via `--without-*` in build-libxml2.sh. kind = "library" name = "libxml2" version = "2.13.8" kernel_abi = 7 -depends_on = ["zlib@1.3.1"] +depends_on = ["zlib@1.3.1", "libiconv@1.17"] # Opt into wasm64 because PHP depends on it. See # memory/wasm64-build-policy.md. arches = ["wasm32", "wasm64"] diff --git a/packages/registry/php/build-php.sh b/packages/registry/php/build-php.sh index 0407f915a..7a225f89e 100755 --- a/packages/registry/php/build-php.sh +++ b/packages/registry/php/build-php.sh @@ -48,22 +48,43 @@ OPENSSL_PREFIX="${WASM_POSIX_DEP_OPENSSL_DIR:-}" [ -z "$OPENSSL_PREFIX" ] && { echo "==> Resolving openssl..."; OPENSSL_PREFIX="$(resolve_dep openssl)"; } LIBXML2_PREFIX="${WASM_POSIX_DEP_LIBXML2_DIR:-}" [ -z "$LIBXML2_PREFIX" ] && { echo "==> Resolving libxml2..."; LIBXML2_PREFIX="$(resolve_dep libxml2)"; } +LIBICONV_PREFIX="${WASM_POSIX_DEP_LIBICONV_DIR:-}" +[ -z "$LIBICONV_PREFIX" ] && { echo "==> Resolving GNU libiconv..."; LIBICONV_PREFIX="$(resolve_dep libiconv)"; } [ -f "$ZLIB_PREFIX/lib/libz.a" ] || { echo "ERROR: zlib resolve missing libz.a"; exit 1; } [ -f "$SQLITE_PREFIX/lib/libsqlite3.a" ] || { echo "ERROR: sqlite resolve missing libsqlite3.a"; exit 1; } [ -f "$OPENSSL_PREFIX/lib/libssl.a" ] || { echo "ERROR: openssl resolve missing libssl.a"; exit 1; } [ -f "$LIBXML2_PREFIX/lib/libxml2.a" ] || { echo "ERROR: libxml2 resolve missing libxml2.a"; exit 1; } +[ -f "$LIBICONV_PREFIX/lib/libiconv.a" ] || { echo "ERROR: GNU libiconv resolve missing libiconv.a"; exit 1; } echo "==> zlib at $ZLIB_PREFIX" echo "==> sqlite at $SQLITE_PREFIX" echo "==> openssl at $OPENSSL_PREFIX" echo "==> libxml2 at $LIBXML2_PREFIX" +echo "==> GNU libiconv at $LIBICONV_PREFIX" -# Compose PKG_CONFIG_PATH for all 4 deps so wasm32posix-configure's +# Compose PKG_CONFIG_PATH for all deps so wasm32posix-configure's # pkg-config probes can find them in the cache instead of the sysroot. -DEP_PKG_CONFIG_PATH="$ZLIB_PREFIX/lib/pkgconfig:$SQLITE_PREFIX/lib/pkgconfig:$OPENSSL_PREFIX/lib/pkgconfig:$LIBXML2_PREFIX/lib/pkgconfig" +DEP_PKG_CONFIG_PATH="$ZLIB_PREFIX/lib/pkgconfig:$SQLITE_PREFIX/lib/pkgconfig:$OPENSSL_PREFIX/lib/pkgconfig:$LIBXML2_PREFIX/lib/pkgconfig:$LIBICONV_PREFIX/lib/pkgconfig" # Compose -I and -L flags for defense-in-depth (autoconf raw probes). -DEP_CPPFLAGS="-I$ZLIB_PREFIX/include -I$SQLITE_PREFIX/include -I$OPENSSL_PREFIX/include -I$LIBXML2_PREFIX/include" -DEP_LDFLAGS="-L$ZLIB_PREFIX/lib -L$SQLITE_PREFIX/lib -L$OPENSSL_PREFIX/lib -L$LIBXML2_PREFIX/lib" +DEP_CPPFLAGS="-I$ZLIB_PREFIX/include -I$SQLITE_PREFIX/include -I$OPENSSL_PREFIX/include -I$LIBXML2_PREFIX/include -I$LIBICONV_PREFIX/include" +DEP_LDFLAGS="-L$ZLIB_PREFIX/lib -L$SQLITE_PREFIX/lib -L$OPENSSL_PREFIX/lib -L$LIBXML2_PREFIX/lib -L$LIBICONV_PREFIX/lib" + +# Some locally rebuilt dependency prefixes used during PHP/kernel +# conformance iteration intentionally contain only headers and static +# archives, not pkg-config metadata. PHP's configure probes support explicit +# _CFLAGS/_LIBS overrides, so provide them unconditionally. This +# keeps the package build independent of host pkg-config installation details +# while still using the normal PHP dependency-discovery path. +ZLIB_CFLAGS_VALUE="-I$ZLIB_PREFIX/include" +ZLIB_LIBS_VALUE="-L$ZLIB_PREFIX/lib -lz" +SQLITE_CFLAGS_VALUE="-I$SQLITE_PREFIX/include" +SQLITE_LIBS_VALUE="-L$SQLITE_PREFIX/lib -lsqlite3" +OPENSSL_CFLAGS_VALUE="-I$OPENSSL_PREFIX/include" +OPENSSL_LIBS_VALUE="-L$OPENSSL_PREFIX/lib -lssl -lcrypto" +LIBXML_CFLAGS_VALUE="-I$LIBXML2_PREFIX/include/libxml2 -I$LIBXML2_PREFIX/include" +LIBXML_LIBS_VALUE="-L$LIBXML2_PREFIX/lib -lxml2 -L$LIBICONV_PREFIX/lib -liconv -lcharset -lz" +ICONV_CFLAGS_VALUE="-I$LIBICONV_PREFIX/include" +ICONV_LIBS_VALUE="-L$LIBICONV_PREFIX/lib -liconv -lcharset" if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading PHP $PHP_VERSION..." @@ -79,6 +100,30 @@ cd "$SRC_DIR" # Apply patches for Wasm compatibility echo "==> Patching PHP for Wasm..." +# The upstream no-phar test fixture is a self-extracting PHP stub embedded at +# fixed byte offsets. It masks CRC values with the literal 0xffffffff; on +# wasm32/PHP with 32-bit longs that literal is a float and PHP 8.3 emits +# E_DEPRECATED before the fixture output. Use an equal-width integer mask so +# the fixture remains byte-offset-compatible and works on both 32-bit and +# 64-bit PHP runtimes. The fixture is also a signed phar archive used by other +# tests, so refresh its SHA1 phar signature after changing the stub bytes. +if [ -f ext/phar/tests/files/nophar.phar ] \ + && grep -aq '0xffffffff' ext/phar/tests/files/nophar.phar; then + python3 - <<'PY' +from pathlib import Path +import hashlib +p = Path("ext/phar/tests/files/nophar.phar") +b = bytearray(p.read_bytes().replace(b"0xffffffff", b"(-1) ")) +if b[-4:] != b"GBMB": + raise SystemExit("nophar.phar: missing phar signature magic") +algo = int.from_bytes(b[-8:-4], "little") +if algo != 2: + raise SystemExit(f"nophar.phar: expected SHA1 signature algorithm 2, got {algo}") +b[-28:-8] = hashlib.sha1(bytes(b[:-28])).digest() +p.write_bytes(b) +PY +fi + # Disable inline assembly in Zend (safety net — Wasm doesn't match arch guards anyway) if ! grep -q 'ZEND_USE_ASM_ARITHMETIC 0' Zend/zend_multiply.h 2>/dev/null; then if [ -f Zend/zend_multiply.h ]; then @@ -88,6 +133,431 @@ if ! grep -q 'ZEND_USE_ASM_ARITHMETIC 0' Zend/zend_multiply.h 2>/dev/null; then fi fi +# When ZEND_MAX_EXECUTION_TIMERS is enabled, zend_executor_globals embeds a +# `struct sigaction`. Some translation units include zend_globals.h without +# having included first, leaving that struct incomplete. Include the +# standard timer/signal declarations at the header that owns those fields. +if [ -f Zend/zend_globals.h ] \ + && ! grep -q "wasm-zend-max-execution-timers include patch applied" Zend/zend_globals.h; then + sed -i.bak '/#include /a\ +#ifdef ZEND_MAX_EXECUTION_TIMERS\ +# include \ +# include \ +#endif\ +/* wasm-zend-max-execution-timers include patch applied */' Zend/zend_globals.h + rm -f Zend/zend_globals.h.bak +fi + +# WebAssembly cannot receive native async POSIX signals while a process worker +# is executing a CPU-bound Wasm loop. PHP's VM already cooperatively checks +# EG(vm_interrupt) on loop backedges; provide a wasm-posix timer hook that the +# host runs from the kernel worker and uses to set EG(timed_out)+EG(vm_interrupt) +# in shared process memory. This preserves PHP's general max_execution_time +# behavior on wasm without special-casing the kernel for PHP. +if [ -f Zend/zend_execute_API.c ] \ + && ! grep -q "wasm-vm-interrupt-timer patch applied" Zend/zend_execute_API.c; then + python3 - <<'PY' +from pathlib import Path +p = Path("Zend/zend_execute_API.c") +s = p.read_text() +s = s.replace( + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n", + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n" + "#if defined(__wasm32__) || defined(__wasm64__)\n" + "extern void __wasm_posix_vm_interrupt_after(void *timed_out, void *vm_interrupt, zend_long seconds);\n" + "#endif\n" + "/* wasm-vm-interrupt-timer patch applied */\n", +) +s = s.replace( + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "\tzend_max_execution_timer_settime(seconds);\n", + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * Schedule the cooperative Wasm VM interrupt only for the normal\n" + "\t * timeout phase (or for seconds=0 cancellation). PHP's native\n" + "\t * ZEND_MAX_EXECUTION_TIMERS path sets EG(timed_out) before arming\n" + "\t * hard_timeout, and that hard timeout must continue to be enforced\n" + "\t * by the POSIX timer signal path so PHP reports n+hard seconds and\n" + "\t * exits like a normal POSIX build.\n" + "\t */\n" + "\tif (seconds <= 0 || !zend_atomic_bool_load_ex(&EG(timed_out))) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t}\n" + "# endif\n" + "\tzend_max_execution_timer_settime(seconds);\n", +) +s = s.replace( + "#else\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", + "#else\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * When the cooperative Wasm VM interrupt observes the soft timeout\n" + "\t * before the POSIX signal is delivered, the disarm above prevents PHP's\n" + "\t * native signal handler from arming hard_timeout. Re-arm the cooperative\n" + "\t * interrupt for the shutdown hard-timeout window so runaway shutdown\n" + "\t * handlers terminate like they do on a POSIX build.\n" + "\t */\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), EG(hard_timeout));\n" + "\t\tEG(hard_timeout) = 0;\n" + "\t}\n" + "# endif\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", +) +p.write_text(s) +PY +fi + +# The cooperative VM interrupt above preserves normal max_execution_time +# behavior, but it cannot asynchronously interrupt a CPU-bound Wasm function. +# If PHP first regains control after both max_execution_time and hard_timeout +# elapsed, report the native hard-timeout diagnostic that upstream emits from +# its signal handler. +if [ -f Zend/zend_execute_API.c ] \ + && ! grep -q "wasm-vm-interrupt-hard-timeout patch applied" Zend/zend_execute_API.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("Zend/zend_execute_API.c") +s = p.read_text() + +def replace_once(old: str, new: str, label: str) -> None: + global s + if old not in s: + raise SystemExit(f"Zend hard-timeout patch: could not find {label}") + s = s.replace(old, new, 1) + +if '#include "zend_hrtime.h"' not in s: + replace_once( + '#include "zend_call_stack.h"\n', + '#include "zend_call_stack.h"\n' + '#if defined(__wasm32__) || defined(__wasm64__)\n' + '#include "zend_hrtime.h"\n' + '#endif\n', + "zend_call_stack include", + ) + +replace_once( + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n" + "#if defined(__wasm32__) || defined(__wasm64__)\n" + "extern void __wasm_posix_vm_interrupt_after(void *timed_out, void *vm_interrupt, zend_long seconds);\n" + "#endif\n" + "/* wasm-vm-interrupt-timer patch applied */\n", + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n" + "#if defined(__wasm32__) || defined(__wasm64__)\n" + "extern void __wasm_posix_vm_interrupt_after(void *timed_out, void *vm_interrupt, zend_long seconds);\n" + "\n" + "static zend_hrtime_t zend_wasm_timeout_deadline = 0;\n" + "static zend_hrtime_t zend_wasm_hard_timeout_deadline = 0;\n" + "\n" + "#define ZEND_WASM_HRTIME_MAX ((zend_hrtime_t) -1)\n" + "#define ZEND_WASM_INTERRUPT_EARLY_NS UINT64_C(100000000)\n" + "\n" + "static zend_always_inline zend_hrtime_t zend_wasm_timeout_seconds_to_ns(zend_long seconds)\n" + "{\n" + "\tif (seconds <= 0) {\n" + "\t\treturn 0;\n" + "\t}\n" + "\tif ((zend_hrtime_t) seconds > ZEND_WASM_HRTIME_MAX / (zend_hrtime_t) ZEND_NANO_IN_SEC) {\n" + "\t\treturn ZEND_WASM_HRTIME_MAX;\n" + "\t}\n" + "\treturn (zend_hrtime_t) seconds * (zend_hrtime_t) ZEND_NANO_IN_SEC;\n" + "}\n" + "\n" + "static zend_always_inline zend_hrtime_t zend_wasm_deadline_after(zend_hrtime_t now, zend_hrtime_t delay_ns)\n" + "{\n" + "\tif (delay_ns > ZEND_WASM_HRTIME_MAX - now) {\n" + "\t\treturn ZEND_WASM_HRTIME_MAX;\n" + "\t}\n" + "\treturn now + delay_ns;\n" + "}\n" + "\n" + "static zend_always_inline void zend_wasm_clear_timeout_deadlines(void)\n" + "{\n" + "\tzend_wasm_timeout_deadline = 0;\n" + "\tzend_wasm_hard_timeout_deadline = 0;\n" + "}\n" + "\n" + "static zend_always_inline void zend_wasm_record_timeout_deadline(zend_long seconds)\n" + "{\n" + "\tzend_hrtime_t now = zend_hrtime();\n" + "\tzend_hrtime_t timeout_ns = zend_wasm_timeout_seconds_to_ns(seconds);\n" + "\tzend_wasm_timeout_deadline = zend_wasm_deadline_after(now, timeout_ns);\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\tzend_hrtime_t hard_ns = zend_wasm_timeout_seconds_to_ns(EG(hard_timeout));\n" + "\t\tzend_wasm_hard_timeout_deadline = zend_wasm_deadline_after(zend_wasm_timeout_deadline, hard_ns);\n" + "\t} else {\n" + "\t\tzend_wasm_hard_timeout_deadline = 0;\n" + "\t}\n" + "}\n" + "\n" + "static zend_always_inline void zend_wasm_arm_hard_timeout(zend_long seconds)\n" + "{\n" + "\tzend_hrtime_t now = zend_hrtime();\n" + "\tzend_hrtime_t hard_ns = zend_wasm_timeout_seconds_to_ns(seconds);\n" + "\tzend_wasm_timeout_deadline = 0;\n" + "\tzend_wasm_hard_timeout_deadline = zend_wasm_deadline_after(now, hard_ns);\n" + "}\n" + "\n" + "static zend_always_inline bool zend_wasm_hard_timeout_expired(void)\n" + "{\n" + "\tzend_hrtime_t now;\n" + "\tif (EG(hard_timeout) <= 0 || zend_wasm_hard_timeout_deadline == 0) {\n" + "\t\treturn false;\n" + "\t}\n" + "\tnow = zend_hrtime();\n" + "\treturn now >= zend_wasm_hard_timeout_deadline\n" + "\t\t|| zend_wasm_hard_timeout_deadline - now <= ZEND_WASM_INTERRUPT_EARLY_NS;\n" + "}\n" + "\n" + "static ZEND_COLD void zend_wasm_hard_timeout_exit(void)\n" + "{\n" + "\tconst char *error_filename = NULL;\n" + "\tuint32_t error_lineno = 0;\n" + "\tchar log_buffer[2048];\n" + "\tint output_len = 0;\n" + "\n" + "\tif (zend_is_compiling()) {\n" + "\t\terror_filename = ZSTR_VAL(zend_get_compiled_filename());\n" + "\t\terror_lineno = zend_get_compiled_lineno();\n" + "\t} else if (zend_is_executing()) {\n" + "\t\terror_filename = zend_get_executed_filename();\n" + "\t\tif (error_filename[0] == '[') {\n" + "\t\t\terror_filename = NULL;\n" + "\t\t\terror_lineno = 0;\n" + "\t\t} else {\n" + "\t\t\terror_lineno = zend_get_executed_lineno();\n" + "\t\t}\n" + "\t}\n" + "\tif (!error_filename) {\n" + "\t\terror_filename = \"Unknown\";\n" + "\t}\n" + "\n" + "\toutput_len = snprintf(log_buffer, sizeof(log_buffer), \"\\nFatal error: Maximum execution time of \" ZEND_LONG_FMT \"+\" ZEND_LONG_FMT \" seconds exceeded (terminated) in %s on line %d\\n\", EG(timeout_seconds), EG(hard_timeout), error_filename, error_lineno);\n" + "\tif (output_len > 0) {\n" + "\t\tzend_quiet_write(2, log_buffer, MIN(output_len, sizeof(log_buffer)));\n" + "\t}\n" + "\t_exit(124);\n" + "}\n" + "#endif\n" + "/* wasm-vm-interrupt-hard-timeout patch applied */\n", + "wasm timeout declaration", +) + +replace_once( + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * Schedule the cooperative Wasm VM interrupt only for the normal\n" + "\t * timeout phase (or for seconds=0 cancellation). PHP's native\n" + "\t * ZEND_MAX_EXECUTION_TIMERS path sets EG(timed_out) before arming\n" + "\t * hard_timeout, and that hard timeout must continue to be enforced\n" + "\t * by the POSIX timer signal path so PHP reports n+hard seconds and\n" + "\t * exits like a normal POSIX build.\n" + "\t */\n" + "\tif (seconds <= 0 || !zend_atomic_bool_load_ex(&EG(timed_out))) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t}\n" + "# endif\n" + "\tzend_max_execution_timer_settime(seconds);\n", + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * Host-side timers set Zend's cooperative VM interrupt flags. They\n" + "\t * cannot asynchronously unwind an already-running Wasm function, so keep\n" + "\t * absolute deadlines here and report the native hard-timeout diagnostic\n" + "\t * if PHP first regains control after max_execution_time+hard_timeout.\n" + "\t */\n" + "\tif (seconds <= 0) {\n" + "\t\tzend_wasm_clear_timeout_deadlines();\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t} else if (!zend_atomic_bool_load_ex(&EG(timed_out))) {\n" + "\t\tzend_wasm_record_timeout_deadline(seconds);\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t}\n" + "# endif\n" + "\tzend_max_execution_timer_settime(seconds);\n", + "wasm timeout scheduling", +) + +replace_once( + "#else\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * When the cooperative Wasm VM interrupt observes the soft timeout\n" + "\t * before the POSIX signal is delivered, the disarm above prevents PHP's\n" + "\t * native signal handler from arming hard_timeout. Re-arm the cooperative\n" + "\t * interrupt for the shutdown hard-timeout window so runaway shutdown\n" + "\t * handlers terminate like they do on a POSIX build.\n" + "\t */\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), EG(hard_timeout));\n" + "\t\tEG(hard_timeout) = 0;\n" + "\t}\n" + "# endif\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", + "#else\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\tif (zend_wasm_hard_timeout_expired()) {\n" + "\t\tzend_wasm_hard_timeout_exit();\n" + "\t}\n" + "# endif\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\tzend_wasm_arm_hard_timeout(EG(hard_timeout));\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), EG(hard_timeout));\n" + "\t}\n" + "# endif\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", + "wasm zend_timeout body", +) + +replace_once( + "#elif ZEND_MAX_EXECUTION_TIMERS\n" + "\tzend_max_execution_timer_settime(0);\n" + "#elif defined(HAVE_SETITIMER)\n", + "#elif ZEND_MAX_EXECUTION_TIMERS\n" + "\tzend_max_execution_timer_settime(0);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\tzend_wasm_clear_timeout_deadlines();\n" + "\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), 0);\n" + "# endif\n" + "#elif defined(HAVE_SETITIMER)\n", + "zend_unset_timeout max execution timer body", +) + +p.write_text(s) +PY +fi + +# PHP's DBA extension keeps an in-process lock guard because some platforms +# allow same-process read/write opens that the extension wants to reject. For +# DB-lock mode, upstream replaces info->path with the stream's opened_path only +# after the first guard check. On wasm-posix this means a later relative-path +# open can miss an already-open canonical-path handle and report "Read during +# write: allowed" for the built-in flatfile/inifile handlers. Repeat the guard +# after DB-lock path canonicalization, before taking the stream lock. +if [ -f ext/dba/dba.c ] \ + && ! grep -q "wasm-dba-db-lock-path-conflict patch applied" ext/dba/dba.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("ext/dba/dba.c") +s = p.read_text() + +find_func = '''static dba_info *php_dba_find(const zend_string *path) +{ +\tzend_resource *le; +\tdba_info *info; +\tzend_long numitems, i; + +\tnumitems = zend_hash_next_free_element(&EG(regular_list)); +\tfor (i=1; itype == le_db || le->type == le_pdb) { +\t\t\tinfo = (dba_info *)(le->ptr); +\t\t\tif (zend_string_equals(path, info->path)) { +\t\t\t\treturn (dba_info *)(le->ptr); +\t\t\t} +\t\t} +\t} + +\treturn NULL; +} +/* }}} */ +''' + +helper = find_func + ''' + +static bool php_dba_lock_conflicts(const dba_info *info, int lock_mode) +{ +\tdba_info *other; + +\tif ((other = php_dba_find(info->path)) == NULL) { +\t\treturn false; +\t} + +\treturn ( (lock_mode&LOCK_EX) && (other->lock.mode&(LOCK_EX|LOCK_SH)) ) +\t || ( (other->lock.mode&LOCK_EX) && (lock_mode&(LOCK_EX|LOCK_SH)) ); +} +/* wasm-dba-db-lock-path-conflict patch applied */ +''' + +if find_func not in s: + raise SystemExit("DBA lock patch: could not find php_dba_find") +s = s.replace(find_func, helper, 1) + +s = s.replace( + "\tdba_info *info, *other;\n", + "\tdba_info *info;\n", + 1, +) + +old_check = '''\tif (hptr->flags & DBA_LOCK_ALL) { +\t\tif ((other = php_dba_find(info->path)) != NULL) { +\t\t\tif ( ( (lock_mode&LOCK_EX) && (other->lock.mode&(LOCK_EX|LOCK_SH)) ) +\t\t\t || ( (other->lock.mode&LOCK_EX) && (lock_mode&(LOCK_EX|LOCK_SH)) ) +\t\t\t ) { +\t\t\t\terror = "Unable to establish lock (database file already open)"; /* force failure exit */ +\t\t\t} +\t\t} +\t} +''' + +new_check = '''\tif ((hptr->flags & DBA_LOCK_ALL) && php_dba_lock_conflicts(info, lock_mode)) { +\t\terror = "Unable to establish lock (database file already open)"; /* force failure exit */ +\t} +''' + +if old_check not in s: + raise SystemExit("DBA lock patch: could not find initial conflict check") +s = s.replace(old_check, new_check, 1) + +old_after_stream_open = '''\t\tif (!info->lock.fp) { +\t\t\tdba_close(info); +\t\t\t/* stream operation already wrote an error message */ +\t\t\tFREE_PERSISTENT_RESOURCE_KEY(); +\t\t\tRETURN_FALSE; +\t\t} +\t\tif (!error && !php_stream_supports_lock(info->lock.fp)) { +''' + +new_after_stream_open = '''\t\tif (!info->lock.fp) { +\t\t\tdba_close(info); +\t\t\t/* stream operation already wrote an error message */ +\t\t\tFREE_PERSISTENT_RESOURCE_KEY(); +\t\t\tRETURN_FALSE; +\t\t} +\t\tif (!error && is_db_lock && (hptr->flags & DBA_LOCK_ALL) && php_dba_lock_conflicts(info, lock_mode)) { +\t\t\terror = "Unable to establish lock (database file already open)"; /* force failure exit */ +\t\t} +\t\tif (!error && !php_stream_supports_lock(info->lock.fp)) { +''' + +if old_after_stream_open not in s: + raise SystemExit("DBA lock patch: could not find post-stream-open lock block") +s = s.replace(old_after_stream_open, new_after_stream_open, 1) + +p.write_text(s) +PY +fi + # opcache's MAP_ANON shared-memory probe is an AC_RUN_IFELSE that fails # under cross-compilation; the fallback only sets have_shm_mmap_anon=yes # for *linux* hosts (configure ext/opcache/config.m4). Without this @@ -105,6 +575,17 @@ if [ -f configure ] && ! grep -q "wasm-opcache patch applied" configure; then rm -f configure.bak fi +# PHP's configure enables Zend max-execution timers only on Linux hosts even +# when --enable-zend-max-execution-timers is explicitly requested. Kandelo's +# wasm target provides the Linux timer_create/timer_settime ABI, including the +# Linux SIGEV_THREAD_ID notification mode used by Zend, so allow wasm targets +# through the same configure gate. The follow-up timer_create probe still +# decides whether the feature is actually compiled in. +if [ -f configure ] && ! grep -q "wasm-zend-max-execution-timers patch applied" configure; then + perl -i.bak -0pe "s/ \\*linux\\*\\) :\\n ;; #\\(\\n \\*\\) :\\n ZEND_MAX_EXECUTION_TIMERS='no' ;;/ *linux*|wasm32*|wasm64*) :\\n ;; #(\\n *) :\\n ZEND_MAX_EXECUTION_TIMERS='no' ;; # wasm-zend-max-execution-timers patch applied/" configure + rm -f configure.bak +fi + # Default opcache.enable to "0" (was "1"). Rationale: PHP's built-in dev # server (`php -S`) uses the cli-server SAPI, which IS in opcache's # supported_sapis list (only the bare `cli` SAPI is gated by @@ -131,12 +612,57 @@ if [ -f ext/opcache/zend_accelerator_module.c ] \ rm -f ext/opcache/zend_accelerator_module.c.bak ext/opcache/zend_accelerator_module.c.bak2 fi +# ext/sockets gates its Linux classic-BPF socket option implementation only on +# SO_ATTACH_REUSEPORT_CBPF. Kandelo's musl headers expose that socket option +# number but do not provide 's `struct sock_filter`/ +# `struct sock_fprog` definitions. Build the rest of the sockets extension and +# omit only the BPF option arm when the platform lacks those Linux filter +# declarations. +if [ -f ext/sockets/sockets.c ] \ + && ! grep -q "wasm-sockets-cbpf-guard patch applied" ext/sockets/sockets.c; then + python3 - <<'PY' +from pathlib import Path +p = Path("ext/sockets/sockets.c") +s = p.read_text() +s = s.replace( + "#ifdef SO_ATTACH_REUSEPORT_CBPF\n", + "#if defined(SO_ATTACH_REUSEPORT_CBPF) && defined(HAVE_LINUX_FILTER_H) /* wasm-sockets-cbpf-guard patch applied */\n", +) +p.write_text(s) +PY +fi + echo "==> Configuring PHP for Wasm (CLI + FPM, single tree)..." # Drop a stale config.cache from a previous build whose env (CPPFLAGS, # PKG_CONFIG_PATH, etc.) may not match this run. autoconf would # otherwise reject the cache with "changes in the environment can # compromise the build" — recovering requires a fresh cache anyway. rm -f "$SCRIPT_DIR/config.cache" +if [ -f Makefile ] && ! grep -q 'ext/zend_test' Makefile; then + echo "==> Existing PHP Makefile lacks zend_test shared-extension rules; reconfiguring..." + rm -f Makefile config.cache +fi +if [ -f Makefile ] && grep -q -- '-rpath' Makefile; then + echo "==> Existing PHP Makefile contains ELF rpath flags unsupported by wasm-ld; reconfiguring..." + rm -f Makefile config.cache +fi +if [ -f Makefile ]; then + # Keep the local configure output aligned with the PHPT coverage profile + # this script now requests. These are bundled/general-purpose extensions, + # not test-only shims; enabling them lets upstream PHPTs exercise the + # Kandelo POSIX surface instead of being skipped as "extension not loaded". + for ext in bcmath calendar dba ftp iconv pcntl posix shmop soap sockets sysvmsg sysvsem sysvshm; do + if ! grep -q "phpext_${ext}_ptr" main/internal_functions.c 2>/dev/null; then + echo "==> Existing PHP Makefile lacks ${ext}; reconfiguring..." + rm -f Makefile config.cache + break + fi + done +fi +if [ -f Makefile ] && ! grep -q '#define ICONV_ALIASED_LIBICONV 1' main/php_config.h 2>/dev/null; then + echo "==> Existing PHP Makefile does not use GNU libiconv aliases; reconfiguring..." + rm -f Makefile config.cache +fi if [ ! -f Makefile ]; then # LDFLAGS notes (kept OUTSIDE the line-continuation block below # because `# comment` lines inside a `\`-continued bash block @@ -177,13 +703,34 @@ if [ ! -f Makefile ]; then # out of bounds" because it tries to dereference the now-bogus heap # pointer. 4 MB gives PASS_6 enough headroom for any function that # passes its own `blocks*vars > 4M` size guard. + # + # ac_cv_lib_iconv_libiconv=yes: PHP's autoconf probe calls `libiconv()` + # with an old-style no-argument prototype. That is tolerated by native ELF + # linkers but invalid for WebAssembly's typed call graph, so wasm-ld rejects + # the probe before configure can discover that GNU libiconv's header maps + # iconv/iconv_open/iconv_close to libiconv/libiconv_open/libiconv_close. + # Preseeding the cache with the known result keeps the cross-compile build + # aligned with the actual library/header ABI rather than falling back to + # musl's narrower iconv implementation. PKG_CONFIG_PATH="$DEP_PKG_CONFIG_PATH" \ CPPFLAGS="$DEP_CPPFLAGS" \ LDFLAGS="$DEP_LDFLAGS -ldl -Wl,--export-all \ -u setgid -u setuid -u initgroups -u writev -u asctime \ -Wl,-z,stack-size=4194304" \ + ZLIB_CFLAGS="$ZLIB_CFLAGS_VALUE" \ + ZLIB_LIBS="$ZLIB_LIBS_VALUE" \ + SQLITE_CFLAGS="$SQLITE_CFLAGS_VALUE" \ + SQLITE_LIBS="$SQLITE_LIBS_VALUE" \ + OPENSSL_CFLAGS="$OPENSSL_CFLAGS_VALUE" \ + OPENSSL_LIBS="$OPENSSL_LIBS_VALUE" \ + LIBXML_CFLAGS="$LIBXML_CFLAGS_VALUE" \ + LIBXML_LIBS="$LIBXML_LIBS_VALUE" \ + ICONV_CFLAGS="$ICONV_CFLAGS_VALUE" \ + ICONV_LIBS="$ICONV_LIBS_VALUE" \ + ac_cv_lib_iconv_libiconv=yes \ wasm32posix-configure \ --disable-all \ + --disable-rpath \ --disable-cgi \ --disable-phpdbg \ --enable-cli \ @@ -194,11 +741,26 @@ if [ ! -f Makefile ]; then --enable-ctype \ --enable-tokenizer \ --enable-filter \ - --enable-phar \ + --enable-bcmath \ + --enable-calendar \ + --enable-dba \ + --enable-ftp \ + --with-iconv="$LIBICONV_PREFIX" \ + --enable-pcntl \ + --enable-phar=shared \ + --enable-posix \ + --enable-shmop \ + --enable-soap \ + --enable-sockets \ + --enable-sysvmsg \ + --enable-sysvsem \ + --enable-sysvshm \ + --enable-zend-test=shared \ --without-valgrind \ --without-pcre-jit \ --disable-fiber-asm \ --disable-zend-signals \ + --enable-zend-max-execution-timers \ --enable-session \ --with-sqlite3 \ --enable-pdo \ @@ -222,25 +784,39 @@ if [ ! -f Makefile ]; then # The debug-trace value is worth keeping. CLI inherits the same # flags; it just produces a slightly larger binary. - # Patch config.h: disable features that pass link-time checks (--allow-undefined) - # but don't actually exist in our musl sysroot + # Patch config.h: disable features that pass link-time checks + # (--allow-undefined) but are not currently usable through Kandelo's PHP + # runtime. In particular, the musl resolver exposes res_search(3), but + # PHP's DNS record APIs can block on external DNS record queries in generic + # arginfo probes. Disable the DNS search-family feature macros together so + # PHP does not register dns_get_record()/dns_get_mx() without a usable + # resolver backend. echo "==> Patching main/php_config.h for Wasm..." sed -i.bak \ -e 's/^#define HAVE_DNS_SEARCH 1/\/* #undef HAVE_DNS_SEARCH *\//' \ -e 's/^#define HAVE_DNS_SEARCH_FUNC 1/\/* #undef HAVE_DNS_SEARCH_FUNC *\//' \ -e 's/^#define HAVE_RES_NSEARCH 1/\/* #undef HAVE_RES_NSEARCH *\//' \ -e 's/^#define HAVE_RES_NDESTROY 1/\/* #undef HAVE_RES_NDESTROY *\//' \ - -e 's/^#define HAVE_DN_EXPAND 1/\/* #undef HAVE_DN_EXPAND *\//' \ - -e 's/^#define HAVE_DN_SKIPNAME 1/\/* #undef HAVE_DN_SKIPNAME *\//' \ - -e 's/^#define HAVE_FOPENCOOKIE 1/\/* #undef HAVE_FOPENCOOKIE *\//' \ + -e 's/^#define HAVE_RES_SEARCH 1/\/* #undef HAVE_RES_SEARCH *\//' \ -e 's/^#define HAVE_FUNOPEN 1/\/* #undef HAVE_FUNOPEN *\//' \ -e 's/^#define HAVE_STD_SYSLOG 1/\/* #undef HAVE_STD_SYSLOG *\//' \ -e 's/^#define HAVE_SETPROCTITLE 1/\/* #undef HAVE_SETPROCTITLE *\//' \ -e 's/^#define HAVE_SETPROCTITLE_FAST 1/\/* #undef HAVE_SETPROCTITLE_FAST *\//' \ - -e 's/^#define HAVE_PRCTL 1/\/* #undef HAVE_PRCTL *\//' \ -e 's/^#define HAVE_RAND_EGD 1/\/* #undef HAVE_RAND_EGD *\//' \ + -e 's/^#define HAVE_FORKX 1/\/* #undef HAVE_FORKX *\//' \ + -e 's/^#define HAVE_RFORK 1/\/* #undef HAVE_RFORK *\//' \ main/php_config.h && rm -f main/php_config.h.bak + # Do not bake the host build prefix into PHP's runtime extension_dir. + # Upstream configure expands it from --prefix, but Kandelo programs run in + # a guest filesystem where the build checkout does not exist. Use the + # guest path populated by the VFS images and mounted by the PHPT harness so + # normal PHP invocations like `php -n -d extension=phar.so` work without + # requiring absolute, harness-specific extension paths. + sed -i.bak \ + -e 's|^#define PHP_EXTENSION_DIR .*|#define PHP_EXTENSION_DIR "/usr/lib/php/extensions"|' \ + main/build-defs.h && rm -f main/build-defs.h.bak + # Remove -MMD/-MF/-MT dependency tracking flags from Makefile. # libtool doesn't understand these flags and misidentifies the source file, # causing "mv: rename foo.o" errors during compilation. @@ -264,6 +840,44 @@ if [ ! -f Makefile ]; then && rm -f libtool.bak fi +if [ -f main/build-defs.h ]; then + sed -i.bak \ + -e 's|^#define PHP_EXTENSION_DIR .*|#define PHP_EXTENSION_DIR "/usr/lib/php/extensions"|' \ + main/build-defs.h && rm -f main/build-defs.h.bak +fi + +# SQLite's feature probes can be distorted by cross-linker behavior even when +# the resolved library has the symbol. Keep the PHP build config aligned with +# the Kandelo SQLite package: sqlite3_expanded_sql() is always present in our +# SQLite 3.49.x build, and column metadata is enabled by the SQLite package so +# pdo_sqlite can expose table names without leaving unresolved imports. +# PHP's fopencookie probe is also distorted by wasm cross-linking. Kandelo's +# musl sysroot provides fopencookie(3), and PHP uses it for generic stream → +# stdio casts rather than requiring every user stream wrapper to implement its +# own stream_cast method. +if [ -f main/php_config.h ]; then + sed -i.bak \ + -e 's|^/\* #undef HAVE_SQLITE3_EXPANDED_SQL \*/|#define HAVE_SQLITE3_EXPANDED_SQL 1|' \ + -e 's|^/\* #undef HAVE_SQLITE3_COLUMN_TABLE_NAME \*/|#define HAVE_SQLITE3_COLUMN_TABLE_NAME 1|' \ + -e 's|^/\* #undef HAVE_FOPENCOOKIE \*/|#define HAVE_FOPENCOOKIE 1|' \ + -e 's|^/\* #undef HAVE_PRCTL \*/|#define HAVE_PRCTL 1|' \ + -e 's|^#define HAVE_FORKX 1|/* #undef HAVE_FORKX */|' \ + -e 's|^#define HAVE_RFORK 1|/* #undef HAVE_RFORK */|' \ + main/php_config.h && rm -f main/php_config.h.bak + # PHP's generated object dependencies do not reliably notice the + # php_config.h feature override above after an incremental rebuild. Force + # the stream-casting unit to be rebuilt so generic stream→FILE* casts use + # fopencookie instead of falling back to wrapper-specific stream_cast hooks. + rm -f main/streams/cast.o main/streams/cast.lo main/streams/.libs/cast.o + rm -f ext/pcntl/pcntl.o ext/pcntl/pcntl.lo ext/pcntl/.libs/pcntl.o + # The same dependency-tracking gap can leave ext/iconv compiled against an + # older config after switching from musl iconv to GNU libiconv. Rebuild this + # unit so the libiconv header aliases are reflected in the final binary. + if grep -q '#define ICONV_ALIASED_LIBICONV 1' main/php_config.h; then + rm -f ext/iconv/iconv.o ext/iconv/iconv.lo ext/iconv/.libs/iconv.o + fi +fi + # `make` per-file rules embed `INCLUDES` from configure but ignore # `CPPFLAGS` (which only contains `-D_GNU_SOURCE`); `INCLUDES` for # our libxml2 ends up as `-I.../include/libxml` because PHP's @@ -281,6 +895,8 @@ make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" EXTRA_CFLAGS="$EXTRA_INC_LIBX echo "==> Both PHP binaries built successfully!" +FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" + # Build opcache as a shared Zend extension (.so side module). # PHP's `make` produces PIC-compiled `.libs/ext/opcache/*.o` because # opcache's `[[outputs]]` config is "always shared", but the bundled @@ -305,8 +921,40 @@ wasm32posix-cc -shared -fPIC -o "$SCRIPT_DIR/bin/opcache.so" \ ext/opcache/.libs/shared_alloc_shm.o \ ext/opcache/.libs/shared_alloc_mmap.o \ ext/opcache/.libs/shared_alloc_posix.o +echo "==> Applying fork instrumentation to opcache.so side module..." +"$FORK_INSTRUMENT" "$SCRIPT_DIR/bin/opcache.so" -o "$SCRIPT_DIR/bin/opcache.so.instr" --entry env.fork +mv "$SCRIPT_DIR/bin/opcache.so.instr" "$SCRIPT_DIR/bin/opcache.so" echo "==> opcache.so: $(wc -c < "$SCRIPT_DIR/bin/opcache.so") bytes" +# Build Phar as a shared extension too. The PHP package intentionally keeps +# shared extensions loadable through normal `extension=...` INI directives so +# subprocesses and PHPT fixtures that opt in to extensions use the same path as +# a general PHP runtime. Shipping phar.so avoids relying on a statically linked +# Phar module while still letting callers decide whether to load it. +echo "==> Building phar.so (extension)..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" EXTRA_CFLAGS="$EXTRA_INC_LIBXML" ext/phar/phar.la || true +wasm32posix-cc -shared -fPIC -o "$SCRIPT_DIR/bin/phar.so" \ + ext/phar/.libs/dirstream.o \ + ext/phar/.libs/func_interceptors.o \ + ext/phar/.libs/phar.o \ + ext/phar/.libs/phar_object.o \ + ext/phar/.libs/phar_path_check.o \ + ext/phar/.libs/stream.o \ + ext/phar/.libs/tar.o \ + ext/phar/.libs/util.o \ + ext/phar/.libs/zip.o +echo "==> phar.so: $(wc -c < "$SCRIPT_DIR/bin/phar.so") bytes" + +# Build zend_test as a normal shared extension. Upstream php-src uses this +# extension to exercise engine edge cases through --EXTENSIONS--. Shipping it +# as an opt-in module keeps the PHP runtime general-purpose while letting the +# PHPT harness run those tests without pretending the extension is present. +echo "==> Building zend_test.so (extension)..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" EXTRA_CFLAGS="$EXTRA_INC_LIBXML" ext/zend_test/zend_test.la || true +wasm32posix-cc -shared -fPIC -o "$SCRIPT_DIR/bin/zend_test.so" \ + ext/zend_test/.libs/*.o +echo "==> zend_test.so: $(wc -c < "$SCRIPT_DIR/bin/zend_test.so") bytes" + # Copy to bin/ with .wasm extension (needed for Vite browser demos) mkdir -p "$SCRIPT_DIR/bin" cp sapi/cli/php "$SCRIPT_DIR/bin/php.wasm" @@ -328,7 +976,6 @@ if [ -n "$WASM_OPT" ]; then "$WASM_OPT" -O2 "$SCRIPT_DIR/bin/php-fpm.wasm" -o "$SCRIPT_DIR/bin/php-fpm.wasm" fi -FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" echo "==> Applying fork instrumentation to CLI..." "$FORK_INSTRUMENT" "$SCRIPT_DIR/bin/php.wasm" -o "$SCRIPT_DIR/bin/php.wasm.instr" mv "$SCRIPT_DIR/bin/php.wasm.instr" "$SCRIPT_DIR/bin/php.wasm" @@ -337,6 +984,8 @@ echo "==> Applying fork instrumentation to FPM..." "$FORK_INSTRUMENT" "$SCRIPT_DIR/bin/php-fpm.wasm" -o "$SCRIPT_DIR/bin/php-fpm.wasm.instr" mv "$SCRIPT_DIR/bin/php-fpm.wasm.instr" "$SCRIPT_DIR/bin/php-fpm.wasm" +chmod 0755 "$SCRIPT_DIR/bin/php.wasm" "$SCRIPT_DIR/bin/php-fpm.wasm" + ls -la "$SCRIPT_DIR/bin/php.wasm" "$SCRIPT_DIR/bin/php-fpm.wasm" # Install into local-binaries/ so the resolver picks the freshly-built @@ -345,3 +994,5 @@ source "$REPO_ROOT/scripts/install-local-binary.sh" install_local_binary php "$SCRIPT_DIR/bin/php.wasm" php.wasm install_local_binary php "$SCRIPT_DIR/bin/php-fpm.wasm" php-fpm.wasm install_local_binary php "$SCRIPT_DIR/bin/opcache.so" +install_local_binary php "$SCRIPT_DIR/bin/phar.so" +install_local_binary php "$SCRIPT_DIR/bin/zend_test.so" diff --git a/packages/registry/php/build.toml b/packages/registry/php/build.toml index eae64edbc..7e7f77367 100644 --- a/packages/registry/php/build.toml +++ b/packages/registry/php/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/php/build-php.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 3 +revision = 7 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/php/package.toml b/packages/registry/php/package.toml index 709e8699c..e8b10e6c0 100644 --- a/packages/registry/php/package.toml +++ b/packages/registry/php/package.toml @@ -1,8 +1,8 @@ kind = "program" name = "php" -version = "8.3.2" +version = "8.3.15" kernel_abi = 7 -depends_on = ["zlib@1.3.1", "openssl@3.3.2", "sqlite@3.49.1", "libxml2@2.13.8"] +depends_on = ["zlib@1.3.1", "openssl@3.3.2", "sqlite@3.49.1", "libxml2@2.13.8", "libiconv@1.17"] # wasm32 only. PHP previously declared wasm64 too (per # memory/wasm64-build-policy.md), but no demo actually consumes # `programs/wasm64/php/...` — only mariadb needs the 4 GB address @@ -13,8 +13,8 @@ depends_on = ["zlib@1.3.1", "openssl@3.3.2", "sqlite@3.49.1", "libxml2@2.13.8"] arches = ["wasm32"] [source] -url = "https://www.php.net/distributions/php-8.3.2.tar.xz" -sha256 = "4ffa3e44afc9c590e28dc0d2d31fc61f0139f8b335f11880a121b9f9b9f0634e" +url = "https://www.php.net/distributions/php-8.3.15.tar.gz" +sha256 = "67073c3c9c56c86461e0715d9e1806af5ddffe8e6e2eb9781f7923bbb5bd67fa" [license] spdx = "PHP-3.01" @@ -48,3 +48,17 @@ wasm = "php-fpm.wasm" [[outputs]] name = "opcache" wasm = "opcache.so" + +# Phar ships as a normal shared PHP extension. PHPT tests and real +# applications can opt in with the standard `extension=phar.so` mechanism +# instead of relying on a test-only static module or harness special case. +[[outputs]] +name = "phar" +wasm = "phar.so" + +# zend_test is php-src's own engine-test extension. It is not loaded by +# default, but is available to PHPTs and callers that explicitly request +# `extension=zend_test.so`. +[[outputs]] +name = "zend_test" +wasm = "zend_test.so" diff --git a/packages/registry/php/test/php-concurrent-sqlite.test.ts b/packages/registry/php/test/php-concurrent-sqlite.test.ts index af8c1e2b1..16326dc10 100644 --- a/packages/registry/php/test/php-concurrent-sqlite.test.ts +++ b/packages/registry/php/test/php-concurrent-sqlite.test.ts @@ -3,7 +3,7 @@ * multiple PHP-Wasm processes sharing a SharedLockTable. * * Requires the PHP wasm binary at ../php-src/sapi/cli/php, - * recompiled with channel_syscall.c. + * recompiled with channel_syscall.c for centralized mode. * Skipped if the binary is not present. */ diff --git a/scripts/run-php-upstream-node-chunks.sh b/scripts/run-php-upstream-node-chunks.sh new file mode 100755 index 000000000..0b22c454a --- /dev/null +++ b/scripts/run-php-upstream-node-chunks.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PHP_SRC="${PHP_SOURCE_DIR:-$REPO_ROOT/packages/registry/php/php-src}" + +host="${PHP_TEST_HOST:-node}" +chunk_size="${PHP_TEST_CHUNK_SIZE:-500}" +jobs="${PHP_TEST_JOBS:-4}" +timeout_ms="${PHP_TEST_TIMEOUT_MS:-600000}" +host_reset_interval="${PHP_TEST_HOST_RESET_INTERVAL:-25}" +run_uid="${PHP_TEST_RUN_UID:-}" +run_gid="${PHP_TEST_RUN_GID:-}" +start_offset=0 +out_dir="" +force=0 +summary_only=0 +rebuild_vfs=0 + +die() { + echo "run-php-upstream-node-chunks: $*" >&2 + exit 2 +} + +usage() { + cat <<'USAGE' +Usage: scripts/run-php-upstream-node-chunks.sh [options] + +Run the full php-src PHPT suite on a Kandelo host in restartable chunks. +Each chunk invokes scripts/run-php-upstream-tests.sh in a fresh Node.js process. +This prevents long monolithic Node-host runs from accumulating host/Wasm memory +and gives browser-host runs resumable checkpoints. + +Options: + --host Kandelo host to run (default: PHP_TEST_HOST or node) + --chunk-size Tests per chunk (default: PHP_TEST_CHUNK_SIZE or 500) + --jobs PHPT concurrency inside each chunk (default: PHP_TEST_JOBS or 4) + --timeout Per PHPT section timeout (default: PHP_TEST_TIMEOUT_MS or 600000) + --host-reset-interval Kernel reboot interval per worker (default: PHP_TEST_HOST_RESET_INTERVAL or 25) + --run-uid Run guest PHP processes as uid n (default: PHP_TEST_RUN_UID) + --run-gid Run guest PHP processes as gid n (default: PHP_TEST_RUN_GID) + --start-offset Start at global sorted PHPT offset (default: 0) + --out-dir Output directory (default: /tmp/kandelo-php--chunks-) + --force Re-run chunks even if their .done marker exists + --summary-only Aggregate an existing --out-dir without running chunks + --rebuild-vfs Rebuild the browser PHPT VFS image before running + -h, --help Show this help + +Environment: + PHP_SOURCE_DIR php-src checkout (default: packages/registry/php/php-src) + PHP_TEST_HOST Host default for --host (node or browser) + PHP_WASM PHP wasm binary (default resolved by downstream harness) + PHP_OPCACHE_SO opcache.so path when testing opcache (recommended) + PHP_EXTENSION_DIR Directory of PHP .so side modules to include in the browser VFS + PHP_TEST_RUN_UID Optional guest uid for PHP processes + PHP_TEST_RUN_GID Optional guest gid for PHP processes + +Outputs: + chunk-.jsonl JSONL PHPT results for that chunk + chunk-.stderr Harness stderr for that chunk + chunk-.exit Harness exit status for that chunk + chunk-.done Marker written only after the chunk command succeeds + summary.json Aggregated status counts and untested count + summary.md Human-readable summary +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --host) [ "$#" -ge 2 ] || die "--host needs a value"; host="$2"; shift 2 ;; + --chunk-size) [ "$#" -ge 2 ] || die "--chunk-size needs a value"; chunk_size="$2"; shift 2 ;; + --jobs) [ "$#" -ge 2 ] || die "--jobs needs a value"; jobs="$2"; shift 2 ;; + --timeout) [ "$#" -ge 2 ] || die "--timeout needs a value"; timeout_ms="$2"; shift 2 ;; + --host-reset-interval) [ "$#" -ge 2 ] || die "--host-reset-interval needs a value"; host_reset_interval="$2"; shift 2 ;; + --run-uid) [ "$#" -ge 2 ] || die "--run-uid needs a value"; run_uid="$2"; shift 2 ;; + --run-gid) [ "$#" -ge 2 ] || die "--run-gid needs a value"; run_gid="$2"; shift 2 ;; + --start-offset) [ "$#" -ge 2 ] || die "--start-offset needs a value"; start_offset="$2"; shift 2 ;; + --out-dir) [ "$#" -ge 2 ] || die "--out-dir needs a value"; out_dir="$2"; shift 2 ;; + --force) force=1; shift ;; + --summary-only) summary_only=1; shift ;; + --rebuild-vfs) rebuild_vfs=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +for numeric in chunk_size jobs timeout_ms host_reset_interval start_offset; do + value="${!numeric}" + case "$value" in + ''|*[!0-9]*) die "$numeric must be a non-negative integer, got: $value" ;; + esac +done +for optional_numeric in run_uid run_gid; do + value="${!optional_numeric}" + case "$value" in + ''|*[!0-9]*) [ -z "$value" ] || die "$optional_numeric must be a non-negative integer, got: $value" ;; + esac +done +[ "$chunk_size" -gt 0 ] || die "chunk_size must be > 0" +[ "$jobs" -gt 0 ] || die "jobs must be > 0" +case "$host" in + node|browser) ;; + *) die "--host must be node or browser, got: $host" ;; +esac +[ -d "$PHP_SRC" ] || die "PHP_SOURCE_DIR not found: $PHP_SRC" + +if [ -z "$out_dir" ]; then + out_dir="/tmp/kandelo-php-$host-chunks-$(date -u +%Y%m%d%H%M%S)" +fi +mkdir -p "$out_dir" + +metadata="$out_dir/metadata.env" +{ + echo "REPO_ROOT=$REPO_ROOT" + echo "PHP_SOURCE_DIR=$PHP_SRC" + echo "HOST=$host" + echo "CHUNK_SIZE=$chunk_size" + echo "JOBS=$jobs" + echo "TIMEOUT_MS=$timeout_ms" + echo "HOST_RESET_INTERVAL=$host_reset_interval" + echo "RUN_UID=$run_uid" + echo "RUN_GID=$run_gid" + echo "START_OFFSET=$start_offset" + echo "STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$metadata" + +total=$(find "$PHP_SRC" -path '*/.git' -prune -o -path '*/.deps' -prune -o -path '*/.libs' -prune -o -name '*.phpt' -type f -print | wc -l | tr -d ' ') +echo "$total" > "$out_dir/total-tests.txt" + +echo "PHP source: $PHP_SRC" +echo "Host: $host" +echo "Total discovered PHPTs: $total" +echo "Output directory: $out_dir" + +if [ "$summary_only" -eq 0 ] && [ "$host" = browser ] && [ "$rebuild_vfs" -eq 1 ]; then + echo "Rebuilding browser PHPT VFS image..." + npx tsx "$REPO_ROOT/images/vfs/scripts/build-php-test-vfs-image.ts" +fi + +aggregate() { + python3 - "$out_dir" "$total" <<'PY' +import json +import sys +from collections import Counter +from pathlib import Path + +out_dir = Path(sys.argv[1]) +total = int(sys.argv[2]) +counts = Counter({ + "pass": 0, + "fail": 0, + "skip": 0, + "xfail": 0, + "xpass": 0, + "unsupported": 0, + "time": 0, +}) +results = {} +parse_errors = [] +for path in sorted(out_dir.glob("chunk-*.jsonl")): + with path.open("r", encoding="utf-8", errors="replace") as f: + for lineno, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError as exc: + parse_errors.append(f"{path.name}:{lineno}: {exc}") + continue + test = item.get("test") + status = item.get("status") + if not test or not status: + parse_errors.append(f"{path.name}:{lineno}: missing test/status") + continue + previous = results.get(test) + if previous: + counts[previous.get("status", "")] -= 1 + results[test] = item + counts[status] += 1 + +run_total = len(results) +summary = { + "total_discovered": total, + "run_total": run_total, + "untested": max(total - run_total, 0), + "counts": dict(counts), + "parse_errors": parse_errors, +} +(out_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") +nonpassing = [r for r in results.values() if r.get("status") not in {"pass", "xfail"}] +nonpassing.sort(key=lambda r: (r.get("status", ""), r.get("test", ""))) +lines = [ + "# PHP PHPT Chunked Run Summary", + "", + f"Output directory: `{out_dir}`", + f"Total discovered: {total}", + f"Run total: {run_total}", + f"Untested: {summary['untested']}", + "", + "| Status | Count |", + "|---|---:|", +] +for key in ["pass", "fail", "skip", "xfail", "xpass", "unsupported", "time"]: + lines.append(f"| {key} | {counts[key]} |") +lines.extend(["", "## Non-passing/non-xfail results", ""]) +for r in nonpassing: + detail = r.get("reason") or r.get("detail") or "" + lines.append(f"- {r.get('status')} `{r.get('test')}`" + (f": {detail}" if detail else "")) +if parse_errors: + lines.extend(["", "## Parse errors", ""]) + lines.extend(f"- {e}" for e in parse_errors) +(out_dir / "summary.md").write_text("\n".join(lines) + "\n") +print(json.dumps(summary, sort_keys=True)) +PY +} + +if [ "$summary_only" -eq 1 ]; then + aggregate + exit 0 +fi + +offset="$start_offset" +while [ "$offset" -lt "$total" ]; do + tag=$(printf "%05d" "$offset") + jsonl="$out_dir/chunk-$tag.jsonl" + stderr="$out_dir/chunk-$tag.stderr" + exit_file="$out_dir/chunk-$tag.exit" + done_file="$out_dir/chunk-$tag.done" + if [ "$force" -eq 0 ] && [ -f "$done_file" ]; then + echo "[$(date -u +%H:%M:%S)] chunk offset $offset already done; skipping" + offset=$((offset + chunk_size)) + continue + fi + rm -f "$jsonl" "$stderr" "$exit_file" "$done_file" + echo "[$(date -u +%H:%M:%S)] running host=$host chunk offset=$offset limit=$chunk_size jobs=$jobs timeout=$timeout_ms reset=$host_reset_interval" + extra_args=() + if [ -n "$run_uid" ]; then + extra_args+=(--run-uid "$run_uid") + fi + if [ -n "$run_gid" ]; then + extra_args+=(--run-gid "$run_gid") + fi + set +e + "$REPO_ROOT/scripts/run-php-upstream-tests.sh" \ + --host "$host" \ + --all \ + --offset "$offset" \ + --limit "$chunk_size" \ + --jobs "$jobs" \ + --timeout "$timeout_ms" \ + --host-reset-interval "$host_reset_interval" \ + "${extra_args[@]}" \ + --json \ + > "$jsonl" 2> "$stderr" + status=$? + set -e + echo "$status" > "$exit_file" + if [ "$status" -eq 0 ]; then + date -u +%Y-%m-%dT%H:%M:%SZ > "$done_file" + aggregate || true + offset=$((offset + chunk_size)) + else + echo "chunk offset $offset failed with status $status; see $stderr" >&2 + aggregate || true + exit "$status" + fi +done + +{ + echo "FINISHED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} >> "$metadata" +aggregate diff --git a/scripts/run-php-upstream-tests.sh b/scripts/run-php-upstream-tests.sh new file mode 100755 index 000000000..37a7c9a40 --- /dev/null +++ b/scripts/run-php-upstream-tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +exec npx tsx scripts/run-php-upstream-tests.ts "$@" diff --git a/scripts/run-php-upstream-tests.ts b/scripts/run-php-upstream-tests.ts new file mode 100644 index 000000000..f59396334 --- /dev/null +++ b/scripts/run-php-upstream-tests.ts @@ -0,0 +1,2382 @@ +/** + * Run php-src PHPT runtime tests on Kandelo, through either the Node.js host + * or the browser host. + * + * This is intentionally a small PHPT harness instead of a native `make test` + * wrapper: upstream run-tests.php assumes it can spawn a native PHP binary. + * Here each --SKIPIF-- / --FILE-- / --CLEAN-- section is executed as a PHP + * process inside Kandelo and the harness performs the expectation match. + */ +import { chromium, type Browser, type Page } from "playwright"; +import { spawn, type ChildProcess, execFileSync } from "node:child_process"; +import { runInNewContext } from "node:vm"; +import { setFlagsFromString } from "node:v8"; +import { + existsSync, + chmodSync, + cpSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { + basename, + delimiter, + dirname, + isAbsolute, + join, + relative, + resolve, +} from "node:path"; +import { NodeKernelHost } from "../host/src/node-kernel-host"; +import { tryResolveBinary } from "../host/src/binary-resolver"; +import { ABI_SYSCALL_NAMES } from "../host/src/generated/abi"; +import { ensureSourceExtract } from "../images/vfs/scripts/source-extract-helper"; + +const REPO_ROOT = resolve(new URL(".", import.meta.url).pathname, ".."); +const LOCAL_PHP_SRC = join(REPO_ROOT, "packages/registry/php/php-src"); +const PHP_TEST_VFS = join( + REPO_ROOT, + "apps/browser-demos/public/php-test.vfs.zst", +); +const BROWSER_DIR = join(REPO_ROOT, "apps/browser-demos"); +const VITE_HOST = "127.0.0.1"; +const VITE_PORT = Number(process.env.PHP_TEST_VITE_PORT ?? 5201); +const BROWSER_EXTENSION_DIR = "/usr/lib/php/extensions"; +const RUN_TESTS_BASE_INI = [ + "output_handler=", + "open_basedir=", + "disable_functions=", + "output_buffering=Off", + "error_reporting=32767", + "display_errors=1", + "display_startup_errors=1", + "log_errors=0", + "html_errors=0", + "track_errors=0", + "report_memleaks=1", + "report_zend_debug=0", + "docref_root=", + "docref_ext=.html", + "error_prepend_string=", + "error_append_string=", + "auto_prepend_file=", + "auto_append_file=", + "ignore_repeated_errors=0", + "precision=14", + "serialize_precision=-1", + "memory_limit=128M", + "opcache.fast_shutdown=0", + "opcache.file_update_protection=0", + "opcache.revalidate_freq=0", + "opcache.jit_hot_loop=1", + "opcache.jit_hot_func=1", + "opcache.jit_hot_return=1", + "opcache.jit_hot_side_exit=1", + "zend.assertions=1", + "zend.exception_ignore_args=0", +]; + +const FAILURE_SNIPPET_BYTES = Math.max( + 2000, + parseInt(process.env.PHP_TEST_FAILURE_SNIPPET_BYTES ?? "2000", 10) || 2000, +); +const BROWSER_WASM_STACK_JS_FLAGS = [ + // Chromium dedicated Web Workers expose only the default V8 native stack, + // which is too small for legitimate stack-heavy Wasm workloads. Keep + // browser-host PHPT runs on V8's secondary Wasm stack and raise that stack + // so deep guest recursion behaves like the Node host's larger worker stack. + "--stack-size=32768", + "--stress-wasm-stack-switching", + "--wasm-stack-switching-stack-size=32768", + "--experimental-wasm-growable-stacks", +].join(" "); + +type HostKind = "node" | "browser"; +type TestStatus = + | "pass" + | "fail" + | "skip" + | "xfail" + | "xpass" + | "unsupported" + | "time"; + +interface PhptTest { + path: string; + rel: string; + sections: Record; +} + +interface PhpRunResult { + exitCode: number; + stdout: string; + stderr: string; + output?: string; + error?: string; + durationMs: number; +} + +interface TestResult { + test: string; + status: TestStatus; + time_ms: number; + reason?: string; + detail?: string; +} + +interface PhpRunner { + loadExtensionIniArgs(requiredExtensions: string[]): string[]; + runScript(opts: { + test: PhptTest; + kind: "skipif" | "file" | "clean"; + script: string; + argv: string[]; + scriptArgs?: string[]; + env: string[]; + stdin?: string; + stdinIsPipe?: boolean; + pipeStdio?: number[]; + waitForChildOutput?: boolean; + timeoutMs: number; + }): Promise; + endTest?(): Promise; + close(): Promise; +} + +let tempCounter = 0; +let nodeToolDir: string | null = null; + +function ensureNodeToolDir(): string { + if (nodeToolDir) return nodeToolDir; + nodeToolDir = mkdtempSync(join(tmpdir(), "kandelo-php-tools-")); + writeFileSync( + join(nodeToolDir, "pgrep"), + `#!/bin/sh +if [ "$1" != "-P" ] || [ -z "$2" ]; then + exit 1 +fi +want_ppid=$2 +for stat in /proc/[0-9]*/stat; do + [ -r "$stat" ] || continue + line=$(cat "$stat" 2>/dev/null) || continue + pid=\${line%% *} + after=\${line#*) } + set -- $after + ppid=$2 + if [ "$ppid" = "$want_ppid" ]; then + printf '%s\\n' "$pid" + fi +done +`, + { mode: 0o755 }, + ); + writeFileSync( + join(nodeToolDir, "ps"), + `#!/bin/sh +pids= +format= +while [ "$#" -gt 0 ]; do + case "$1" in + -p) + shift + pids=$1 + ;; + -o) + shift + format=$1 + ;; + *) + ;; + esac + shift +done + +if [ -z "$pids" ]; then + exit 1 +fi + +[ -n "$format" ] || format=pid,command +header=1 +case "$format" in + *=*) + header=0 + ;; +esac + +fields=$(printf '%s' "$format" | tr ',' ' ') +pid_list=$(printf '%s' "$pids" | tr ',' ' ') + +if [ "$header" = 1 ]; then + out= + for field in $fields; do + field=\${field%=} + case "$field" in + pid) label=PID ;; + nice|ni) label=NICE ;; + command|comm|args) label=COMMAND ;; + *) label=$(printf '%s' "$field" | tr '[:lower:]' '[:upper:]') ;; + esac + out="$out\${out:+ }$label" + done + printf '%s\\n' "$out" +fi + +found=0 +for pid in $pid_list; do + stat=/proc/$pid/stat + [ -r "$stat" ] || continue + line=$(cat "$stat" 2>/dev/null) || continue + comm=\${line#*(} + comm=\${comm%)*} + after=\${line#*) } + set -- $after + nice=\${17:-0} + row= + for field in $fields; do + field=\${field%=} + case "$field" in + pid) value=$pid ;; + nice|ni) value=$nice ;; + command|comm|args) value=$comm ;; + *) value= ;; + esac + row="$row\${row:+ }$value" + done + printf '%s\\n' "$row" + found=1 +done + +[ "$found" = 1 ] +`, + { mode: 0o755 }, + ); + return nodeToolDir; +} + +const PASSTHROUGH_ENV_NAMES = [ + "NO_INTERACTION", + "RES_OPTIONS", + "SKIP_IO_CAPTURE_TESTS", + "SKIP_ONLINE_TESTS", + "SKIP_PERF_SENSITIVE", + "SKIP_SLOW_TESTS", + "TEST_FPM_DEBUG", + "TEST_FPM_RUN_AS_ROOT", + "FPM_RUN_RESOURCE_HEAVY_TESTS", + "TEST_NON_ROOT_USER", +]; + +function forceNodeGc(): void { + try { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + gc(); + } catch { + // Best-effort: Node may disable exposing gc in some embeddings. + } +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +function delay(ms: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} + +async function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + }), + ]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + +function resolvePhpBinary(): string { + const candidate = + process.env.PHP_WASM ?? + tryResolveBinary("programs/php/php.wasm") ?? + join(LOCAL_PHP_SRC, "sapi/cli/php"); + if (!candidate || !existsSync(candidate)) { + throw new Error( + "PHP wasm not found. Run: bash packages/registry/php/build-php.sh", + ); + } + return candidate; +} + +function resolvePhpFpmBinary(phpPath: string): string | null { + const explicit = process.env.PHP_FPM_WASM; + if (explicit) return resolve(explicit); + const resolved = tryResolveBinary("programs/php/php-fpm.wasm"); + if (resolved) return resolved; + const sibling = join(dirname(phpPath), "php-fpm.wasm"); + return existsSync(sibling) ? sibling : null; +} + +function resolvePhpSource(): string { + const explicit = process.env.PHP_SOURCE_DIR; + if (explicit) return resolve(explicit); + return ensureSourceExtract( + "php", + REPO_ROOT, + existsSync(LOCAL_PHP_SRC) ? LOCAL_PHP_SRC : undefined, + ); +} + +function parsePhpt(path: string, sourceRoot: string): PhptTest { + // PHPT files are byte-oriented. A few upstream tests intentionally contain + // non-UTF-8 PHP source/EXPECT bytes, so keep a one-code-point-per-byte + // representation and write/capture generated scripts the same way. + const text = readFileSync(path, "latin1"); + const marker = /^--([A-Z_]+)--[ \t]*\r?$/gm; + const matches = [...text.matchAll(marker)]; + const sections: Record = {}; + for (let i = 0; i < matches.length; i++) { + const name = matches[i][1]; + const start = (matches[i].index ?? 0) + matches[i][0].length; + const end = i + 1 < matches.length ? matches[i + 1].index! : text.length; + sections[name] = text.slice(start, end).replace(/^\r?\n/, ""); + } + return { path, rel: relative(sourceRoot, path), sections }; +} + +function walkPhpt(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if ( + entry.name === ".git" || + entry.name === ".deps" || + entry.name === ".libs" + ) + continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walkPhpt(full, out); + } else if (entry.isFile() && entry.name.endsWith(".phpt")) { + out.push(full); + } + } + return out; +} + +function discoverTests(sourceRoot: string, selectors: string[]): PhptTest[] { + const files: string[] = []; + if (selectors.length === 0) { + walkPhpt(sourceRoot, files); + } else { + for (const selector of selectors) { + const resolved = isAbsolute(selector) + ? selector + : resolve(sourceRoot, selector); + if (!existsSync(resolved)) + throw new Error(`PHPT selector not found: ${selector}`); + const st = statSync(resolved); + if (st.isDirectory()) walkPhpt(resolved, files); + else files.push(resolved); + } + } + return [...new Set(files)].sort().map((path) => parsePhpt(path, sourceRoot)); +} + +function splitArgs(input: string | undefined): string[] { + if (!input) return []; + const out: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escape = false; + for (const ch of input.trim()) { + if (escape) { + current += ch; + escape = false; + } else if (ch === "\\") { + escape = true; + } else if (quote) { + if (ch === quote) quote = null; + else current += ch; + } else if (ch === "'" || ch === '"') { + quote = ch; + } else if (/\s/.test(ch)) { + if (current) { + out.push(current); + current = ""; + } + } else { + current += ch; + } + } + if (current) out.push(current); + return out; +} + +function extraChromiumArgsFromEnv(): string[] { + const args = [ + ...splitArgs(process.env.PHP_TEST_CHROMIUM_ARGS), + ...splitArgs(process.env.KANDELO_CHROMIUM_ARGS), + ]; + if (process.env.PHP_TEST_DISABLE_BROWSER_WASM_STACK_FLAGS !== "1") { + args.unshift(`--js-flags=${BROWSER_WASM_STACK_JS_FLAGS}`); + } + return args; +} + +function guestTestDir(test: PhptTest): string { + const relDir = dirname(test.rel).split("\\").join("/"); + return relDir === "." ? "/php-src" : `/php-src/${relDir}`; +} + +function expandSectionPlaceholders(value: string, test: PhptTest): string { + return value + .replaceAll("{PWD}", guestTestDir(test)) + .replaceAll("{TMP}", "/tmp") + .replace(/\{MAIL:([^}]+)\}/g, (_match, path) => `tee ${path} >/dev/null`) + .replace(/\{ENV:([^}]+)\}/g, (_match, name) => process.env[name] ?? ""); +} + +function iniArgs(ini: string | undefined, test: PhptTest): string[] { + if (!ini) return []; + const args: string[] = []; + for (const raw of expandSectionPlaceholders(ini, test).split(/\r?\n/)) { + let line = raw.trim(); + if (!line || line.startsWith(";") || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + if (eq >= 0) { + const key = line.slice(0, eq).trim(); + const value = line.slice(eq + 1).trim(); + line = `${key}=${value}`; + } + args.push("-d", line); + } + return args; +} + +function envArgs(env: string | undefined, test: PhptTest): string[] { + if (!env) return []; + const args: string[] = []; + for (const raw of expandSectionPlaceholders(env, test).split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + // Upstream run-tests.php feeds --ENV-- through PHP's proc_open() + // environment array. proc_open's POSIX envp builder intentionally skips + // entries whose value is an empty string, so mirror that rather than + // passing NAME= directly to Kandelo. + if (eq >= 0 && line.slice(eq + 1).length === 0) continue; + args.push(line); + } + return args; +} + +function captureStdioFds(test: PhptTest): number[] { + const capture = test.sections.CAPTURE_STDIO; + if (capture === undefined) return []; + const fds: number[] = []; + if (/\bSTDIN\b/i.test(capture)) fds.push(0); + if (/\bSTDOUT\b/i.test(capture)) fds.push(1); + if (/\bSTDERR\b/i.test(capture)) fds.push(2); + return fds; +} + +function passthroughEnvArgs(): string[] { + return PASSTHROUGH_ENV_NAMES.flatMap((name) => + process.env[name] === undefined ? [] : [`${name}=${process.env[name]}`], + ); +} + +function defaultPhpTestEnvArgs(): string[] { + // Mirror upstream php-src run-tests.php's baseline CGI-ish environment for + // ordinary FILE tests. Several CLI PHPT fixtures intentionally inspect + // $_SERVER['REQUEST_METHOD'] / REQUEST_URI without using a CGI section. + return [ + "REDIRECT_STATUS=", + "QUERY_STRING=", + "PATH_TRANSLATED=", + "SCRIPT_FILENAME=", + "REQUEST_METHOD=GET", + "CONTENT_TYPE=", + "CONTENT_LENGTH=", + // Kandelo runs FPM and its helper clients under emulation, so PHP-FPM + // startup notices can legitimately take longer than php-src's native + // three-second tester default (especially with OPcache preloading). + // The fixture patch below teaches the FPM tester helper to honor this. + `TEST_FPM_LOG_TIMEOUT_SECONDS=${process.env.TEST_FPM_LOG_TIMEOUT_SECONDS ?? "20"}`, + `TEST_FPM_CHECK_CONNECTION_ATTEMPTS=${process.env.TEST_FPM_CHECK_CONNECTION_ATTEMPTS ?? "200"}`, + `TEST_FPM_READ_WRITE_TIMEOUT_MS=${process.env.TEST_FPM_READ_WRITE_TIMEOUT_MS ?? "20000"}`, + "TEST_FPM_EXTENSION_DIR=/usr/lib/php/extensions", + `TEST_NON_ROOT_USER=${process.env.TEST_NON_ROOT_USER ?? "nobody"}`, + "TZ=", + ]; +} + +function parseOptionalNonNegativeInt(value: string | undefined, name: string): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative integer, got ${value}`); + } + return parsed; +} + +function mergeEnvArgs(...groups: string[][]): string[] { + const merged = new Map(); + for (const group of groups) { + for (const entry of group) { + const eq = entry.indexOf("="); + if (eq <= 0) continue; + merged.set(entry.slice(0, eq), entry); + } + } + return [...merged.values()]; +} + +function isFlakyTest(test: PhptTest): boolean { + if (test.sections.FLAKY !== undefined) return true; + const file = test.sections.FILE ?? ""; + return /\b(?:disk_free_space|hrtime|microtime|sleep|usleep)\s*\(/i.test(file); +} + +function phptConflictTokens(test: PhptTest): string[] { + const tokens = new Set(); + const conflicts = test.sections.CONFLICTS ?? ""; + for (const token of conflicts.split(/[\s,]+/)) { + const normalized = token.trim(); + if (normalized) tokens.add(normalized); + } + + const source = [ + test.sections.SKIPIF, + test.sections.FILE, + test.sections.FILEEOF, + test.sections.CLEAN, + ] + .filter((section): section is string => section !== undefined) + .join("\n"); + + // Upstream run-tests.php uses --CONFLICTS-- to keep server-style PHPTs from + // running concurrently. Some php-src tests do not declare it even though + // they start helper servers or bind fixed loopback ports. Mirror the + // important resource constraints here so `--jobs` remains usable without + // producing false failures from EADDRINUSE or competing php_cli_server + // instances. + if ( + /\b(?:php_cli_server_start|php_cli_server_connect|PHP_CLI_SERVER_)/.test( + source, + ) || + /\bServerClientTestCase\.inc\b/.test(source) + ) { + tokens.add("server"); + } + + const loopbackPort = + /\b(?:127\.0\.0\.1|localhost|\[::1\]|::1):([0-9]{2,5})\b/g; + for (const match of source.matchAll(loopbackPort)) { + tokens.add(`tcp-port:${match[1]}`); + } + + return [...tokens]; +} + +function requiresExclusiveScheduling(conflicts: string[]): boolean { + // Server-style PHPTs commonly start a helper PHP process, sleep briefly, and + // then connect to a fixed loopback listener. The declared `server` conflict + // prevents port/helper overlap, but under Kandelo's Wasm host even unrelated + // concurrent PHPTs can consume enough CPU during PHP startup to turn those + // upstream timing assumptions into false connection-refused failures. Run + // server tests exclusively rather than skipping or patching them. + return conflicts.includes("server"); +} + +function isFlakyOutput(output: string): boolean { + return /\b(?:404: page not found|address already in use|connection refused|deadlock|mailbox already exists|timed out)\b/i.test(output); +} + +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function shellArgs(args: string[]): string { + return args.map(shellEscape).join(" "); +} + +function baseIniArgs(): string[] { + return RUN_TESTS_BASE_INI.flatMap((setting) => ["-d", setting]); +} + +function extensionArgs(extensions: string | undefined): string[] { + if (!extensions) return []; + return extensions + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); +} + +function normalizeExtensionName(extension: string): string { + const name = extension.trim().toLowerCase(); + if (name === "zend opcache") return "opcache"; + return name.replace(/^(?:php_)?(.+?)(?:\.so)?$/, "$1"); +} + +function sharedExtensionPathsForPhp(phpPath: string): Map { + const out = new Map(); + const extensionDirs = [ + dirname(phpPath), + ...((process.env.PHP_EXTENSION_DIR ?? "") + .split(delimiter) + .map((dir) => dir.trim()) + .filter(Boolean)), + ]; + for (const dir of extensionDirs) { + if (!existsSync(dir)) continue; + for (const entry of readdirSync(dir)) { + if (entry.endsWith(".so")) { + out.set(normalizeExtensionName(entry), join(dir, entry)); + } + } + } + const phpDir = dirname(phpPath); + const opcachePath = + process.env.PHP_OPCACHE_SO ?? + tryResolveBinary("programs/php/opcache.so") ?? + join(phpDir, "opcache.so"); + if (opcachePath && existsSync(opcachePath)) out.set("opcache", opcachePath); + return out; +} + +function staticExtensionsForPhpSource(sourceRoot: string): Set { + const out = new Set(); + for (const file of ["internal_functions.c", "internal_functions_cli.c"]) { + const internalFunctions = join(sourceRoot, "main", file); + if (!existsSync(internalFunctions)) continue; + const text = readFileSync(internalFunctions, "utf8"); + for (const match of text.matchAll(/\bphpext_([A-Za-z0-9_]+)_ptr\b/g)) { + out.add(normalizeExtensionName(match[1])); + } + } + return out; +} + +function preparePhpTestFixtures(sourceRoot: string): void { + // PHP 8.3.15's upstream SNI PHPT fixtures expired on 2026-04-02. Do not + // fake guest time to make them pass: that would compromise Kandelo as a + // general POSIX platform. Instead, treat this as test-fixture maintenance + // and copy equivalent long-lived certificates into the local test tree + // before discovery/VFS packaging. + const fixtureDir = join(REPO_ROOT, "tests/php-fixtures/openssl-sni-2036"); + const destDir = join(sourceRoot, "ext/openssl/tests"); + if (!existsSync(fixtureDir) || !existsSync(destDir)) return; + for (const entry of readdirSync(fixtureDir)) { + if (!entry.startsWith("sni_server_") || !entry.endsWith(".pem")) continue; + cpSync(join(fixtureDir, entry), join(destDir, entry)); + } + + // PHP 8.3.15's FPM test fixtures need small harness-side maintenance under + // Kandelo: + // - ext/opcache/tests/preload_user_004.phpt calls FPM\Tester::getLogLines(), + // but the shipped FPM tester helper does not define that method. + // - logreader.inc has a native three-second default that is too short for + // OPcache preload startup under emulation. + // - fcgi.inc has a native five-second client read/write timeout; under + // wasm emulation, OPcache preload requests can legitimately take longer + // while still producing the correct FastCGI response. + // + // These changes only affect the copied PHPT fixture tree used by the + // harness. They do not change PHP runtime behavior or Kandelo kernel + // behavior. + const fpmTester = join(sourceRoot, "sapi/fpm/tests/tester.inc"); + if (existsSync(fpmTester)) { + const text = readFileSync(fpmTester, "utf8"); + if (text.includes("class Tester")) { + const marker = " /**\n * Expect no log lines to be logged.\n"; + const method = ` /**\n * Return currently available FPM log lines.\n *\n * @param int $timeoutSeconds Seconds to wait for the first line.\n * @param int $timeoutMicroseconds Additional microseconds to wait for the first line.\n *\n * @return array\n * @throws \\Exception\n */\n public function getLogLines(int $timeoutSeconds = 3, int $timeoutMicroseconds = 0): array\n {\n $configuredTimeout = getenv('TEST_FPM_LOG_TIMEOUT_SECONDS');\n if ($configuredTimeout !== false && is_numeric($configuredTimeout)) {\n $timeoutSeconds = max($timeoutSeconds, (int) $configuredTimeout);\n }\n\n $lines = [];\n $line = $this->logReader->getLine($timeoutSeconds, $timeoutMicroseconds);\n while ($line !== null) {\n if ($line !== '') {\n $lines[] = $line;\n }\n $line = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000);\n }\n\n return $lines;\n }\n\n`; + let next = text; + if (text.includes("function getLogLines(")) { + const start = text.indexOf(" /**\n * Return currently available FPM log lines."); + const end = text.indexOf(marker, start); + if (start < 0 || end <= start) { + throw new Error( + `Unable to update PHP FPM tester fixture: getLogLines block not found in ${fpmTester}`, + ); + } + next = text.slice(0, start) + method + text.slice(end); + } else { + if (!text.includes(marker)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: marker not found in ${fpmTester}`, + ); + } + next = text.replace(marker, method + marker); + } + if (!next.includes("TEST_FPM_CHECK_CONNECTION_ATTEMPTS")) { + const from = ` ) {\n $i = 0;\n do {`; + const to = ` ) {\n $configuredAttempts = getenv('TEST_FPM_CHECK_CONNECTION_ATTEMPTS');\n if ($configuredAttempts !== false && is_numeric($configuredAttempts)) {\n $attempts = max($attempts, (int) $configuredAttempts);\n }\n\n $i = 0;\n do {`; + if (!next.includes(from)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: checkConnection marker not found in ${fpmTester}`, + ); + } + next = next.replace(from, to); + } + if (!next.includes("$cmd .= ' --allow-to-run-as-root';")) { + const from = `$cmd = self::findExecutable() . " -n $configTestArg -y $configFile 2>&1";`; + const to = `$cmd = self::findExecutable() . " -n $configTestArg -y $configFile";\n if (getenv('TEST_FPM_RUN_AS_ROOT')) {\n $cmd .= ' --allow-to-run-as-root';\n }\n $cmd .= " 2>&1";`; + if (!next.includes(from)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: testConfig command marker not found in ${fpmTester}`, + ); + } + next = next.replace(from, to); + } + if (!next.includes("file_exists($extensionDir . '/' . $extension . '.so')")) { + const from = ` foreach ($extensions as $extension) {\n $cmd[] = '-dextension=' . $extension;\n }`; + const to = ` foreach ($extensions as $extension) {\n if (file_exists($extensionDir . '/' . $extension . '.so')) {\n $cmd[] = '-dextension=' . $extension;\n }\n }`; + if (!next.includes(from)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: extension loading marker not found in ${fpmTester}`, + ); + } + next = next.replace(from, to); + } + if (next !== text) writeFileSync(fpmTester, next, "utf8"); + } + } + + const fpmLogReader = join(sourceRoot, "sapi/fpm/tests/logreader.inc"); + if (existsSync(fpmLogReader)) { + const text = readFileSync(fpmLogReader, "utf8"); + if (!text.includes("TEST_FPM_LOG_TIMEOUT_SECONDS")) { + const from = `if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) {\n $timeoutSeconds = 3;\n $timeoutMicroseconds = 0;\n }`; + const to = `if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) {\n $configuredTimeout = getenv('TEST_FPM_LOG_TIMEOUT_SECONDS');\n $timeoutSeconds = $configuredTimeout !== false && is_numeric($configuredTimeout)\n ? max(3, (int) $configuredTimeout)\n : 3;\n $timeoutMicroseconds = 0;\n }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP FPM logreader fixture: marker not found in ${fpmLogReader}`, + ); + } + writeFileSync(fpmLogReader, text.replace(from, to), "utf8"); + } + } + + const fpmFcgi = join(sourceRoot, "sapi/fpm/tests/fcgi.inc"); + if (existsSync(fpmFcgi)) { + const text = readFileSync(fpmFcgi, "utf8"); + if (!text.includes("TEST_FPM_READ_WRITE_TIMEOUT_MS")) { + const from = ` $this->transport = $transport;\n }`; + const to = ` $this->transport = $transport;\n\n $configuredTimeout = getenv('TEST_FPM_READ_WRITE_TIMEOUT_MS');\n if ($configuredTimeout !== false && is_numeric($configuredTimeout)) {\n $this->_readWriteTimeout = max($this->_readWriteTimeout, (int) $configuredTimeout);\n }\n }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP FPM FastCGI fixture: constructor marker not found in ${fpmFcgi}`, + ); + } + writeFileSync(fpmFcgi, text.replace(from, to), "utf8"); + } + } + + const fpmIpv4Fallback = join(sourceRoot, "sapi/fpm/tests/socket-ipv4-fallback.phpt"); + if (existsSync(fpmIpv4Fallback)) { + const text = readFileSync(fpmIpv4Fallback, "utf8"); + const from = "Address already in use \\(\\d+\\)"; + const to = "Address (?:already )?in use \\(\\d+\\)"; + if (text.includes(from) && !text.includes(to)) { + // musl's strerror(EADDRINUSE) is "Address in use" while glibc's is + // "Address already in use". Both describe the same POSIX errno, so make + // this fixture regex libc-portable rather than changing Kandelo/libc + // message strings to match one C library. + writeFileSync(fpmIpv4Fallback, text.replace(from, to), "utf8"); + } + } + + const mysqliFakeServer = join(sourceRoot, "ext/mysqli/tests/fake_server.inc"); + if (existsSync(mysqliFakeServer)) { + const text = readFileSync(mysqliFakeServer, "utf8"); + if (!text.includes("MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS")) { + const from = ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`; + const to = ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + + if ($data && $bytes_len > 1024) { + // Large reads in this fake MySQL server are used to drain the + // connection tail after the client reacts to a crafted packet. + // fread() on a POSIX stream may return as soon as any bytes are + // available; it is not required to wait for later client writes to + // coalesce into the same TCP segment. Native php-src runs usually + // see the final COM_STMT_CLOSE and COM_QUIT together after the + // fixed sleep above, but the browser host can schedule the guest + // peer more slowly. Keep draining for a short idle window and print + // one Received line so the fixture remains semantically identical + // without relying on transport coalescing. + $idleMs = getenv('MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS'); + $idleMs = $idleMs !== false && is_numeric($idleMs) ? max(0, (int) $idleMs) : 250; + $deadline = microtime(true) + ($idleMs / 1000); + $wasBlocking = stream_get_meta_data($this->conn)['blocked'] ?? true; + stream_set_blocking($this->conn, false); + try { + while (strlen($data) < $bytes_len && microtime(true) < $deadline) { + usleep(10000); + $chunk = fread($this->conn, $bytes_len - strlen($data)); + if ($chunk !== false && $chunk !== '') { + $data .= $chunk; + $deadline = microtime(true) + ($idleMs / 1000); + } + } + } finally { + stream_set_blocking($this->conn, $wasBlocking); + } + } + + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP mysqli fake_server fixture: read() marker not found in ${mysqliFakeServer}`, + ); + } + writeFileSync(mysqliFakeServer, text.replace(from, to), "utf8"); + } + } +} + +function loadExtensionIniArgs( + requiredExtensions: string[], + availableSharedExtensions: Set, + guestExtensionDir: string, +): string[] { + const args: string[] = []; + let emittedExtensionDir = false; + for (const extension of requiredExtensions) { + const name = normalizeExtensionName(extension); + if (!availableSharedExtensions.has(name)) continue; + if (!emittedExtensionDir) { + args.push("-d", `extension_dir=${guestExtensionDir}`); + emittedExtensionDir = true; + } + const directive = + name === "opcache" || name === "xdebug" ? "zend_extension" : "extension"; + args.push("-d", `${directive}=${guestExtensionDir}/${name}.so`); + if (name === "opcache") { + // The Kandelo PHP package builds opcache as a shared Zend extension but + // defaults opcache.enable to 0 for demos that do not opt in. Upstream + // php-src PHPTs that request --EXTENSIONS-- opcache assume the upstream + // default active extension unless a test's --INI-- overrides it. + args.push("-d", "opcache.enable=1"); + } + } + return args; +} + +function normalizeOutput(text: string): string { + // Upstream php-src run-tests.php normalizes CRLF and compares PHP + // trim($out) against trim(EXPECT*). PHP trim's default charlist includes + // NUL bytes, unlike JavaScript String#trim(). + return text + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/^[\x00\t\n\v\r ]+|[\x00\t\n\v\r ]+$/g, ""); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function replaceExpectfPlaceholders(text: string): string { + return text.replace(/%[easSAwidxfc0]/g, (token) => { + switch (token) { + case "%e": + return "[/\\\\]"; + case "%s": + return "[^\\r\\n]+"; + case "%S": + return "[^\\r\\n]*"; + case "%a": + return ".+"; + case "%A": + return "[\\s\\S]*"; + case "%w": + return "\\s*"; + case "%i": + return "[+-]?\\d+"; + case "%d": + return "\\d+"; + case "%x": + return "[0-9a-fA-F]+"; + case "%f": + return "[+-]?(?:(?:\\d+\\.\\d*)|(?:\\d*\\.\\d+)|(?:\\d+))(?:[Ee][+-]?\\d+)?"; + case "%c": + return "."; + case "%0": + return "\\x00"; + default: + return escapeRegExp(token); + } + }); +} + +function expectfToRegExp(expectf: string): RegExp { + let out = ""; + for (let i = 0; i < expectf.length; i++) { + if (expectf.startsWith("%r", i)) { + const end = expectf.indexOf("%r", i + 2); + if (end !== -1) { + out += `(${expectf.slice(i + 2, end)})`; + i = end + 1; + continue; + } + } + out += escapeRegExp(expectf[i]); + } + // Upstream run-tests.php first preg_quote()s non-%r sections, leaves %r + // regex spans raw, then applies EXPECTF %-placeholder substitutions to the + // whole pattern. Do not treat %% specially: literal percent signs remain + // literal unless followed by a recognized placeholder character. + return new RegExp(`^${replaceExpectfPlaceholders(out)}$`, "s"); +} + +function compareExpectation( + test: PhptTest, + actualRaw: string, +): { ok: boolean; detail?: string } { + const actual = normalizeOutput(actualRaw); + if (test.sections.EXPECT !== undefined) { + const expected = normalizeOutput(test.sections.EXPECT); + return { + ok: actual === expected, + detail: + actual === expected + ? undefined + : `expected exact output length ${expected.length}, got ${actual.length}`, + }; + } + if (test.sections.EXPECTF !== undefined) { + const expected = normalizeOutput(test.sections.EXPECTF); + const re = expectfToRegExp(expected); + return { + ok: re.test(actual), + detail: re.test(actual) ? undefined : "EXPECTF pattern did not match", + }; + } + if (test.sections.EXPECTREGEX !== undefined) { + const expected = normalizeOutput(test.sections.EXPECTREGEX); + const re = new RegExp(expected, "s"); + return { + ok: re.test(actual), + detail: re.test(actual) ? undefined : "EXPECTREGEX pattern did not match", + }; + } + return { ok: false, detail: "no supported EXPECT section" }; +} + +function failureSnippet(actualOutput: string): string { + return normalizeOutput(actualOutput) + .slice(0, FAILURE_SNIPPET_BYTES) + .replace(/\n/g, "\\n"); +} + +function unsupportedReason(test: PhptTest): string | null { + if (test.sections.REDIRECTTEST !== undefined) + return "REDIRECTTEST is not supported by the Kandelo PHPT harness yet"; + if ( + test.sections.PHPDBG !== undefined && + !process.env.TEST_PHPDBG_EXECUTABLE + ) { + return "phpdbg not available"; + } + const source = `${test.sections.SKIPIF ?? ""}\n${test.sections.FILE ?? ""}\n${test.sections.FILEEOF ?? ""}`; + if ( + /\b(?:dns_get_record|dns_get_mx|getmxrr|checkdnsrr|dns_check_record)\s*\(/.test( + source, + ) + ) { + return "PHP DNS record-query functions are not enabled in the Kandelo PHP build"; + } + if ( + test.rel.startsWith("Zend/tests/fibers/") || + /\b(?:new\s+\\?Fiber|\\?Fiber::|ReflectionFiber|_?ZendTestFiber)\b/.test(source) + ) { + return "PHP Fibers require ucontext/boost context switching, which the Kandelo PHP build does not support yet"; + } + const sapiOnly = [ + "POST", + "POST_RAW", + "PUT", + "GET", + "COOKIE", + "REQUEST", + "HEADERS", + "EXPECTHEADERS", + "GZIP_POST", + "DEFLATE_POST", + "CGI", + ].find((section) => test.sections[section] !== undefined); + if (sapiOnly) return `${sapiOnly} requires web/CGI PHPT handling`; + if ( + test.sections.FILE === undefined && + test.sections.FILEEOF === undefined && + test.sections.FILE_EXTERNAL === undefined + ) { + return "no FILE/FILEEOF/FILE_EXTERNAL section"; + } + if ( + test.sections.FILE_EXTERNAL !== undefined && + !existsSync(join(dirname(test.path), test.sections.FILE_EXTERNAL.trim())) + ) { + return `FILE_EXTERNAL target not found: ${test.sections.FILE_EXTERNAL.trim()}`; + } + if ( + test.sections.EXPECT === undefined && + test.sections.EXPECTF === undefined && + test.sections.EXPECTREGEX === undefined + ) { + return "no supported EXPECT section"; + } + return null; +} + +function testScript(test: PhptTest): string { + if (test.sections.FILE !== undefined) return test.sections.FILE; + if (test.sections.FILEEOF !== undefined) return test.sections.FILEEOF; + if (test.sections.FILE_EXTERNAL !== undefined) { + return readFileSync( + join(dirname(test.path), test.sections.FILE_EXTERNAL.trim()), + "latin1", + ); + } + return ""; +} + +function phptGeneratedScriptName(test: PhptTest, kind: string): string { + const base = basename(test.path, ".phpt"); + if (kind === "file") { + return `${base}.php`; + } + if (kind === "clean") { + return `${base}.clean.php`; + } + if (kind === "skipif") { + return `${base}.skip.php`; + } + return `.kandelo-phpt-${process.pid}-${tempCounter++}-${kind}.php`; +} + +function hostTestDir(test: PhptTest, sourceRoot: string): string { + const relDir = dirname(test.rel); + return relDir === "." ? sourceRoot : join(sourceRoot, relDir); +} + +function nodeTempPath(test: PhptTest, sourceRoot: string, scriptName: string): string { + return join(hostTestDir(test, sourceRoot), scriptName); +} + +function guestScriptPath( + test: PhptTest, + _sourceRoot: string, + scriptName: string, +): string { + const relDir = dirname(test.rel).split("\\").join("/"); + return relDir && relDir !== "." + ? `/php-src/${relDir}/${scriptName}` + : `/php-src/${scriptName}`; +} + +class NodePhpRunner implements PhpRunner { + private virtualPhpPath: string; + private host: NodeKernelHost | null = null; + private phpBytes: ArrayBuffer | null = null; + private fpmBytes: ArrayBuffer | null = null; + private binaryMountRoot: string | null = null; + private extensionMountRoot: string | null = null; + private testsSinceReset = 0; + private activeOutput: { stdout: string; stderr: string; output: string } | null = + null; + + constructor( + private sourceRoot: string, + private phpPath: string, + private phpFpmPath: string | null, + private sharedExtensionPaths: Map, + private ownsSourceRoot = false, + private hostResetInterval = 50, + private enableTcpNetwork = true, + private runUid?: number, + private runGid?: number, + ) { + this.virtualPhpPath = `/kandelo-bin/${basename(phpPath)}`; + } + + loadExtensionIniArgs(requiredExtensions: string[]): string[] { + return loadExtensionIniArgs( + requiredExtensions, + new Set(this.sharedExtensionPaths.keys()), + BROWSER_EXTENSION_DIR, + ); + } + + private ensureExtensionMountRoot(): string { + if (this.extensionMountRoot) return this.extensionMountRoot; + const root = mkdtempSync(join(tmpdir(), "kandelo-php-ext-")); + chmodSync(root, 0o755); + const destDir = join(root, "php", "extensions"); + mkdirSync(destDir, { recursive: true }); + chmodSync(join(root, "php"), 0o755); + chmodSync(destDir, 0o755); + for (const [name, srcPath] of this.sharedExtensionPaths) { + const destPath = join(destDir, `${name}.so`); + cpSync(srcPath, destPath); + chmodSync(destPath, 0o755); + } + this.extensionMountRoot = root; + return root; + } + + private ensureBinaryMountRoot(): string { + if (this.binaryMountRoot) return this.binaryMountRoot; + const root = mkdtempSync(join(tmpdir(), "kandelo-php-bin-")); + chmodSync(root, 0o755); + const phpDest = join(root, basename(this.phpPath)); + cpSync(this.phpPath, phpDest); + chmodSync(phpDest, 0o755); + if (this.phpFpmPath && existsSync(this.phpFpmPath)) { + const sbin = join(root, "sbin"); + mkdirSync(sbin, { recursive: true }); + chmodSync(sbin, 0o755); + // php-src's FPM PHPT helper searches for TEST_PHP_EXECUTABLE's + // prefix + /sbin/php-fpm (or /fpm/php-fpm). Provide that normal + // package layout in the guest rather than teaching individual tests + // about Kandelo's .wasm artifact name. + const fpmDest = join(sbin, "php-fpm"); + cpSync(this.phpFpmPath, fpmDest); + chmodSync(fpmDest, 0o755); + } + this.binaryMountRoot = root; + return root; + } + + private async ensureHost(): Promise { + if (this.host) return this.host; + this.phpBytes = loadBytes(this.phpPath); + this.fpmBytes = + this.phpFpmPath && existsSync(this.phpFpmPath) + ? loadBytes(this.phpFpmPath) + : null; + const binaryMountRoot = this.ensureBinaryMountRoot(); + const extensionMountRoot = this.ensureExtensionMountRoot(); + const host = new NodeKernelHost({ + maxWorkers: 4, + rootfsImage: "default", + enableTcpNetwork: this.enableTcpNetwork, + execPrograms: { + [this.virtualPhpPath]: this.phpPath, + "/kandelo-bin/php": this.phpPath, + ...(this.phpFpmPath + ? { + "/kandelo-bin/sbin/php-fpm": this.phpFpmPath, + "/kandelo-bin/fpm/php-fpm": this.phpFpmPath, + } + : {}), + }, + extraMounts: [ + { + mountPoint: "/php-src", + hostPath: this.sourceRoot, + uid: this.runUid, + gid: this.runGid ?? this.runUid, + }, + { + mountPoint: "/kandelo-bin", + hostPath: binaryMountRoot, + readonly: true, + }, + { + mountPoint: "/usr/lib", + hostPath: extensionMountRoot, + readonly: true, + }, + { + mountPoint: "/kandelo-test-bin", + hostPath: ensureNodeToolDir(), + readonly: true, + }, + ], + onStdout: (_pid, data) => { + if (this.activeOutput) { + const text = Buffer.from(data).toString("latin1"); + this.activeOutput.stdout += text; + this.activeOutput.output += text; + } + }, + onStderr: (_pid, data) => { + if (this.activeOutput) { + const text = Buffer.from(data).toString("latin1"); + this.activeOutput.stderr += text; + this.activeOutput.output += text; + } + }, + onResolveExec: (path) => { + const base = path.split("/").pop(); + if ( + base === "php" || + base === "php.wasm" || + base === basename(this.phpPath) + ) + return this.phpBytes; + if (base === "php-fpm" || base === "php-fpm.wasm") + return this.fpmBytes; + return null; + }, + }); + await host.init(); + if (process.env.PHP_TEST_SYSCALL_TRACE) { + const filters = new Set( + process.env.PHP_TEST_SYSCALL_TRACE.split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + host.subscribeSyscalls((event) => { + const name = ABI_SYSCALL_NAMES[event.nr] ?? `syscall_${event.nr}`; + if (filters.size === 0 || filters.has(name) || filters.has(String(event.nr))) { + console.error( + `[php-phpt-syscall t=${event.t.toFixed(3)}] pid=${event.pid} ${name}(${event.args.join(",")})`, + ); + } + }); + } + this.host = host; + return host; + } + + private async resetHost(host: NodeKernelHost): Promise { + if (this.host === host) this.host = null; + await host.destroy().catch(() => {}); + await delay(0); + } + + private async hasLiveProcesses(host: NodeKernelHost): Promise { + const processes = await withTimeout(host.enumProcs(), 1_000, "enumProcs"); + return processes.length > 0; + } + + private async terminateLiveProcesses(host: NodeKernelHost): Promise { + let processes: Array<{ pid: number }> = []; + try { + processes = await withTimeout(host.enumProcs(), 1_000, "enumProcs"); + } catch { + await this.resetHost(host); + return true; + } + if (processes.length === 0) return false; + const results = await Promise.allSettled( + processes.map((process) => + withTimeout( + host.terminateProcess(process.pid), + 1_000, + `terminate pid ${process.pid}`, + ), + ), + ); + if (results.some((result) => result.status === "rejected")) { + await this.resetHost(host); + return true; + } + return true; + } + + async runScript(opts: { + test: PhptTest; + kind: "skipif" | "file" | "clean"; + script: string; + argv: string[]; + scriptArgs?: string[]; + env: string[]; + stdin?: string; + stdinIsPipe?: boolean; + pipeStdio?: number[]; + waitForChildOutput?: boolean; + timeoutMs: number; + }): Promise { + const scriptName = phptGeneratedScriptName(opts.test, opts.kind); + const hostScriptPath = nodeTempPath(opts.test, this.sourceRoot, scriptName); + const scriptPath = guestScriptPath(opts.test, this.sourceRoot, scriptName); + const previousScript = existsSync(hostScriptPath) + ? readFileSync(hostScriptPath) + : null; + writeFileSync(hostScriptPath, opts.script, "latin1"); + const start = performance.now(); + const host = await this.ensureHost(); + if (!this.phpBytes) throw new Error("PHP wasm bytes not loaded"); + const output = { stdout: "", stderr: "", output: "" }; + this.activeOutput = output; + // PHPT execution provides a finite stdin stream. Tests without an + // explicit --STDIN-- section get immediate EOF, but keep fd 0 terminal-like + // unless the PHPT explicitly redirects/captures it. Upstream run-tests.php + // distinguishes "terminal with no input" from a pipe for isatty/fstat. + const stdin = Buffer.from(opts.stdin ?? "", "latin1"); + const stdinIsPipe = opts.stdinIsPipe ?? true; + let timeoutId: ReturnType | undefined; + let pid: number | null = null; + try { + const exitPromise = host.spawn( + this.phpBytes, + [ + this.virtualPhpPath, + ...opts.argv, + scriptPath, + ...(opts.scriptArgs ?? []), + ], + { + // php-src run-tests.php executes generated test files from the + // source root. Several PHPTs intentionally use source-root-relative + // paths such as ./ext/standard/tests/file. + cwd: "/php-src", + env: [ + "HOME=/tmp", + "USER=kandelo", + "USERNAME=kandelo", + "LOGNAME=kandelo", + "TMPDIR=/tmp", + "PATH=/kandelo-test-bin:/bin:/usr/bin:/usr/local/bin", + `TEST_PHP_SRCDIR=/php-src`, + `TEST_PHP_EXECUTABLE=${this.virtualPhpPath}`, + `TEST_PHP_EXECUTABLE_ESCAPED=${shellEscape(this.virtualPhpPath)}`, + ...opts.env, + ], + stdin, + stdinIsPipe, + pipeStdio: opts.pipeStdio, + uid: this.runUid, + gid: this.runGid, + onStarted: (startedPid) => { + pid = startedPid; + }, + }, + ); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("TIMEOUT")), + opts.timeoutMs, + ); + }); + const exitCode = await Promise.race([exitPromise, timeoutPromise]); + // A PHP process may fork short-lived children that inherit the same + // stdio and produce PHPT-observed output after the original parent exits + // (matching native run-tests.php process-tree behavior). Only enable + // this bounded grace period for PHPTs that actually exercise fork-like + // APIs; doing it unconditionally would add seconds to every test. + if (opts.waitForChildOutput) { + await delay(1_000); + } + // Process exit and stdio notifications are delivered over separate host + // messages. Wait for output to quiesce before freezing the capture so + // data written immediately before _exit() is not lost. A fixed short + // sleep still flaked on buffered CLI/file PHPTs under full-suite load. + let lastOutputLength = -1; + let stablePolls = 0; + for (let waitedMs = 0; waitedMs < 500 && stablePolls < 3; waitedMs += 25) { + await delay(25); + const outputLength = output.output.length; + if (waitedMs >= 100 && outputLength === lastOutputLength) { + stablePolls++; + } else { + stablePolls = 0; + } + lastOutputLength = outputLength; + } + return { + exitCode, + stdout: output.stdout, + stderr: output.stderr, + output: output.output, + durationMs: Math.round(performance.now() - start), + }; + } catch (err: any) { + const message = err?.message || String(err); + if (message.includes("TIMEOUT") && pid !== null) { + await this.resetHost(host); + } + return { + exitCode: -1, + stdout: output.stdout, + stderr: output.stderr, + output: output.output, + error: message.includes("TIMEOUT") ? "TIMEOUT" : message, + durationMs: Math.round(performance.now() - start), + }; + } finally { + if (this.host === host) { + const hadLiveProcesses = await this.terminateLiveProcesses(host); + // A PHPT that leaves children behind can also leave pipes, sockets, or + // stdio delivery state behind. Upstream run-tests.php gets a fresh OS + // process tree for every PHP invocation; mirror that isolation when + // Kandelo reports leftover processes after a section completes. + if (hadLiveProcesses && this.host === host) { + await this.resetHost(host); + } + } + this.activeOutput = null; + if (timeoutId) clearTimeout(timeoutId); + forceNodeGc(); + if (previousScript) { + writeFileSync(hostScriptPath, previousScript); + } else { + rmSync(hostScriptPath, { force: true }); + } + } + } + + async endTest(): Promise { + if (this.hostResetInterval <= 0 || !this.host) return; + this.testsSinceReset++; + if (this.testsSinceReset < this.hostResetInterval) return; + const host = this.host; + this.testsSinceReset = 0; + await this.resetHost(host); + } + + async close(): Promise { + const host = this.host; + this.host = null; + if (host) await host.destroy().catch(() => {}); + if (this.ownsSourceRoot) { + rmSync(this.sourceRoot, { recursive: true, force: true }); + } + if (this.extensionMountRoot) { + rmSync(this.extensionMountRoot, { recursive: true, force: true }); + this.extensionMountRoot = null; + } + if (this.binaryMountRoot) { + rmSync(this.binaryMountRoot, { recursive: true, force: true }); + this.binaryMountRoot = null; + } + } +} + +function copySourceRootForNodeRunner(sourceRoot: string, index: number): string { + const copyRoot = mkdtempSync(join(tmpdir(), `kandelo-php-src-${index}-`)); + rmSync(copyRoot, { recursive: true, force: true }); + cpSync(sourceRoot, copyRoot, { + recursive: true, + dereference: false, + filter: (path) => { + const base = basename(path); + return base !== ".git" && base !== ".deps" && base !== ".libs"; + }, + }); + return copyRoot; +} + +function makeSourceTreeWritableByGuest(sourceRoot: string): void { + const stack = [sourceRoot]; + while (stack.length > 0) { + const current = stack.pop()!; + const st = lstatSync(current); + if (st.isSymbolicLink()) continue; + if (st.isDirectory()) { + chmodSync(current, 0o777); + for (const entry of readdirSync(current)) { + stack.push(join(current, entry)); + } + } else { + // Non-root PHPT runs still generate per-test .php/.ini/.log fixtures in + // the mounted php-src checkout. Make the copied fixture tree writable + // to the guest user instead of weakening kernel credential checks. + chmodSync(current, (st.mode & 0o111) | 0o666); + } + } +} + +async function startViteServer(): Promise { + return new Promise((resolvePromise, reject) => { + const viteBin = join(BROWSER_DIR, "node_modules", ".bin", "vite"); + const useLocalVite = existsSync(viteBin); + const proc = spawn( + useLocalVite ? viteBin : "npx", + [ + ...(useLocalVite ? [] : ["vite"]), + "--config", + join(BROWSER_DIR, "vite.config.ts"), + "--host", + VITE_HOST, + "--port", + String(VITE_PORT), + "--strictPort", + ], + { + cwd: BROWSER_DIR, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, KANDELO_BROWSER_DEMO_INPUTS: "php-test" }, + }, + ); + let started = false; + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + if (!started) { + proc.kill(); + reject( + new Error( + `Vite server did not start within 30s${ + stdout || stderr + ? `: +${stdout}${stderr}` + : "" + }`, + ), + ); + } + }, 30_000); + proc.stderr!.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + proc.stdout!.on("data", (data: Buffer) => { + stdout += data.toString(); + if ( + !started && + (stdout.includes("Local:") || stdout.includes("ready in")) + ) { + started = true; + clearTimeout(timeout); + setTimeout(() => resolvePromise(proc), 500); + } + }); + proc.on("exit", (code) => { + if (!started) { + clearTimeout(timeout); + reject( + new Error( + `Vite exited with code ${code}${ + stderr + ? `: +${stderr}` + : "" + }`, + ), + ); + } + }); + }); +} + +class BrowserPhpRunner implements PhpRunner { + private vite: ChildProcess | null = null; + private browser: Browser | null = null; + private page: Page | null = null; + private runs = 0; + + constructor( + private sourceRoot: string, + private rebuildVfs: boolean, + private availableSharedExtensions: Set, + private runUid?: number, + private runGid?: number, + ) {} + + loadExtensionIniArgs(requiredExtensions: string[]): string[] { + return loadExtensionIniArgs( + requiredExtensions, + this.availableSharedExtensions, + BROWSER_EXTENSION_DIR, + ); + } + + async init(): Promise { + if (this.rebuildVfs || !existsSync(PHP_TEST_VFS)) { + execFileSync( + "bash", + [join(REPO_ROOT, "images/vfs/scripts/build-php-test-vfs-image.sh")], + { + cwd: REPO_ROOT, + stdio: "inherit", + env: { ...process.env, PHP_SOURCE_DIR: this.sourceRoot }, + }, + ); + } + this.vite = await startViteServer(); + await this.launchBrowser(); + await this.reloadPage(); + } + + private async launchBrowser(): Promise { + await this.browser?.close().catch(() => {}); + this.browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: [ + "--enable-features=SharedArrayBuffer", + ...extraChromiumArgsFromEnv(), + ], + }); + } + + private async reloadPage(): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + if (!this.browser || !this.browser.isConnected()) { + await this.launchBrowser(); + } + try { + const context = await this.browser!.newContext(); + this.page = await context.newPage(); + this.page.on("console", (msg) => { + if (msg.type() === "error") console.error(`[browser] ${msg.text()}`); + }); + await this.page.goto( + `http://${VITE_HOST}:${VITE_PORT}/pages/php-test/`, + ); + await this.page.waitForFunction( + () => (window as any).__phpTestReady === true, + {}, + { timeout: 120_000 }, + ); + return; + } catch (err) { + if (attempt === 0) { + await this.launchBrowser(); + continue; + } + throw err; + } + } + } + + async runScript(opts: { + test: PhptTest; + kind: "skipif" | "file" | "clean"; + script: string; + argv: string[]; + scriptArgs?: string[]; + env: string[]; + stdin?: string; + stdinIsPipe?: boolean; + pipeStdio?: number[]; + waitForChildOutput?: boolean; + timeoutMs: number; + }): Promise { + if (!this.page) throw new Error("browser page not ready"); + if (this.runs > 0 && this.runs % 20 === 0) { + await this.page.context().close(); + await this.reloadPage(); + } + this.runs++; + + const scriptName = phptGeneratedScriptName(opts.test, opts.kind); + const scriptPath = guestScriptPath(opts.test, this.sourceRoot, scriptName); + const request = { + scriptPath, + script: opts.script, + argv: [...opts.argv, scriptPath, ...(opts.scriptArgs ?? [])], + cwd: "/php-src", + env: [ + "PATH=/bin:/usr/bin:/usr/local/bin", + "USER=kandelo", + "USERNAME=kandelo", + "LOGNAME=kandelo", + "TEST_PHP_SRCDIR=/php-src", + "TEST_PHP_EXECUTABLE=/usr/local/bin/php", + "TEST_PHP_EXECUTABLE_ESCAPED='/usr/local/bin/php'", + ...opts.env, + ], + uid: this.runUid, + gid: this.runGid, + stdin: opts.stdin ?? "", + stdinIsPipe: opts.stdinIsPipe ?? true, + pipeStdio: opts.pipeStdio, + waitForChildOutput: opts.waitForChildOutput, + timeoutMs: opts.timeoutMs, + }; + + for (let attempt = 0; attempt < 2; attempt++) { + const start = performance.now(); + try { + const evaluatePromise = this.page.evaluate( + async ({ request }) => (window as any).__runPhpScript(request), + { request }, + ); + void evaluatePromise.catch(() => {}); + return await withTimeout( + evaluatePromise, + opts.timeoutMs + 10_000, + "browser PHPT run", + ); + } catch (err: any) { + const message = err?.message || String(err); + const timedOut = /browser PHPT run timed out/.test(message); + if (timedOut) { + await this.page + ?.context() + .close() + .catch(() => {}); + this.page = null; + await this.reloadPage().catch(() => {}); + return { + exitCode: -1, + stdout: "", + stderr: "", + error: "TIMEOUT", + durationMs: Math.round(performance.now() - start), + }; + } + const recoverable = + /Execution context was destroyed|Target page, context or browser has been closed|Navigation failed/i.test( + message, + ); + if (attempt === 0 && recoverable) { + await this.page + ?.context() + .close() + .catch(() => {}); + try { + await this.reloadPage(); + } catch (reloadErr: any) { + return { + exitCode: -1, + stdout: "", + stderr: "", + error: reloadErr?.message || String(reloadErr), + durationMs: Math.round(performance.now() - start), + }; + } + continue; + } + return { + exitCode: -1, + stdout: "", + stderr: "", + error: message, + durationMs: Math.round(performance.now() - start), + }; + } + } + throw new Error("unreachable"); + } + + async close(): Promise { + if (this.page) + await this.page + .context() + .close() + .catch(() => {}); + if (this.browser) await this.browser.close().catch(() => {}); + if (this.vite) { + const vite = this.vite; + if (vite.exitCode === null && vite.signalCode === null) { + vite.kill("SIGTERM"); + } + await new Promise((resolveDone) => { + if (vite.exitCode !== null || vite.signalCode !== null) { + resolveDone(); + return; + } + const killTimer = setTimeout(() => { + if (vite.exitCode === null && vite.signalCode === null) { + vite.kill("SIGKILL"); + } + resolveDone(); + }, 2000); + vite.once("exit", () => { + clearTimeout(killTimer); + resolveDone(); + }); + }); + this.vite = null; + } + } +} + +async function runPhpt( + test: PhptTest, + runner: PhpRunner, + availableExtensions: Set, + timeoutMs: number, +): Promise { + const start = performance.now(); + const unsupported = unsupportedReason(test); + if (unsupported) { + return { + test: test.rel, + status: "unsupported", + time_ms: 0, + reason: unsupported, + }; + } + + const commonEnv = mergeEnvArgs( + passthroughEnvArgs(), + defaultPhpTestEnvArgs(), + envArgs(test.sections.ENV, test), + ); + const defaultIniArgs = baseIniArgs(); + const testIniArgs = iniArgs(test.sections.INI, test); + const args = splitArgs(test.sections.ARGS); + const pipeStdio = captureStdioFds(test); + const stdinIsPipe = + test.sections.STDIN !== undefined || + test.sections.CAPTURE_STDIO === undefined || + pipeStdio.includes(0); + + const requiredExtensions = extensionArgs(test.sections.EXTENSIONS); + const extensionIniArgs = runner.loadExtensionIniArgs(requiredExtensions); + const missingRequiredExtensions = requiredExtensions.filter( + (extension) => !availableExtensions.has(normalizeExtensionName(extension)), + ); + if (missingRequiredExtensions.length > 0) { + return { + test: test.rel, + status: "skip", + time_ms: Math.round(performance.now() - start), + reason: `skip required extension(s) not loaded: ${missingRequiredExtensions.join(", ")}`, + }; + } + const preTestArgv = [...extensionIniArgs, ...defaultIniArgs]; + const testArgv = [...preTestArgv, ...testIniArgs]; + const envWithExtraArgs = [ + ...commonEnv, + `TEST_PHP_EXTRA_ARGS=${shellArgs(testArgv)}`, + ]; + if (test.sections.SKIPIF !== undefined) { + const skip = await runner.runScript({ + test, + kind: "skipif", + script: test.sections.SKIPIF, + // Upstream run-tests.php executes SKIPIF before applying the test's + // --INI-- block. Keep that ordering so resource-probing SKIPIF sections + // are not distorted by settings meant only for the main FILE body. + argv: preTestArgv, + env: envWithExtraArgs, + pipeStdio, + stdinIsPipe, + timeoutMs, + }); + const skipOutput = normalizeOutput(`${skip.stdout}${skip.stderr}`); + if (/^(?:skip|skipped)\b/i.test(skipOutput)) { + return { + test: test.rel, + status: "skip", + time_ms: Math.round(performance.now() - start), + reason: skipOutput, + }; + } + if (/^xfail\b/i.test(skipOutput)) { + return { + test: test.rel, + status: "xfail", + time_ms: Math.round(performance.now() - start), + reason: skipOutput, + }; + } + if (skip.error === "TIMEOUT") { + return { + test: test.rel, + status: "time", + time_ms: skip.durationMs, + reason: "SKIPIF timed out", + }; + } + } + + const runMain = () => + runner.runScript({ + test, + kind: "file", + script: testScript(test), + argv: testArgv, + scriptArgs: args, + env: envWithExtraArgs, + stdin: test.sections.STDIN, + stdinIsPipe, + pipeStdio, + waitForChildOutput: /\b(?:pcntl_fork|pcntl_rfork|forkx|proc_open|popen)\s*\(/.test( + test.sections.FILE ?? "", + ), + timeoutMs, + }); + + let main = await runMain(); + + let ok = false; + let detail = main.error; + let actualOutput = main.output ?? `${main.stdout}${main.stderr}`; + if (main.error === "TIMEOUT" && actualOutput) { + const snippet = failureSnippet(actualOutput); + detail = `TIMEOUT; partial actual: ${snippet}`; + } + if (main.error !== "TIMEOUT") { + const compared = compareExpectation(test, actualOutput); + // PHPTs often intentionally trigger fatal errors; upstream run-tests.php + // treats matching output as the authority rather than requiring exit 0. + ok = compared.ok; + detail = compared.detail; + if (!ok && detail) { + const snippet = failureSnippet(actualOutput); + const errorDetail = main.error ? `; error=${main.error}` : ""; + detail = `${detail}; exit=${main.exitCode}${errorDetail}; actual: ${snippet}`; + } + } + + if ( + !ok && + main.error !== "TIMEOUT" && + (isFlakyTest(test) || isFlakyOutput(actualOutput)) + ) { + main = await runMain(); + actualOutput = main.output ?? `${main.stdout}${main.stderr}`; + detail = main.error; + if (main.error !== "TIMEOUT") { + const compared = compareExpectation(test, actualOutput); + ok = compared.ok; + detail = compared.detail; + if (!ok && detail) { + const snippet = failureSnippet(actualOutput); + const errorDetail = main.error ? `; error=${main.error}` : ""; + detail = `${detail}; exit=${main.exitCode}${errorDetail}; actual: ${snippet}`; + } + } + } + + if (test.sections.CLEAN !== undefined) { + await runner + .runScript({ + test, + kind: "clean", + script: test.sections.CLEAN, + // CLEAN runs with the same pre-test INI baseline as SKIPIF upstream. + argv: preTestArgv, + env: envWithExtraArgs, + pipeStdio, + stdinIsPipe, + timeoutMs: Math.min(timeoutMs, 30_000), + }) + .catch(() => {}); + } + + const isXfail = test.sections.XFAIL !== undefined; + let status: TestStatus; + if (main.error === "TIMEOUT") status = isXfail ? "xfail" : "time"; + else if (ok) status = isXfail ? "xpass" : "pass"; + else status = isXfail ? "xfail" : "fail"; + + return { + test: test.rel, + status, + time_ms: Math.round(performance.now() - start), + reason: + status === "xfail" + ? normalizeOutput(test.sections.XFAIL ?? "expected failure") + : undefined, + detail, + }; +} + +function printUsage(): void { + console.error(`Usage: npx tsx scripts/run-php-upstream-tests.ts [options] [test-or-dir ...] + +Options: + --host node|browser Host runtime to use (default: node) + --all Run every .phpt test under php-src (default when no tests are passed) + --timeout Per PHPT section timeout (default: 60000) + --shard / Run 1-based shard i of n after discovery sorting + --offset Skip the first n selected tests + --limit Run only the first n discovered tests + --jobs Number of PHPTs to run concurrently (Node host only; default: 1) + --run-uid Run guest PHP processes as uid n + (default: PHP_TEST_RUN_UID; root when unset) + --run-gid Run guest PHP processes as gid n + (default: PHP_TEST_RUN_GID; root when unset) + --host-reset-interval + Reboot each Node-host Kandelo kernel after n PHPTs + per worker to reclaim host-side Wasm memory + (default: PHP_TEST_HOST_RESET_INTERVAL or 50; 0 disables) + --disable-tcp-network Disable Node-host outbound TCP/DNS bridging + (enabled by default; set PHP_TEST_ENABLE_TCP_NETWORK=0 + for the same effect) + --json Emit JSON lines + --report Write docs/php-upstream-test-report.md + --rebuild-vfs Rebuild php-test.vfs.zst before browser runs + +Environment: + PHP_WASM Path to php.wasm + PHP_FPM_WASM Optional path to php-fpm.wasm for FPM PHPTs + PHP_EXTENSION_DIR Additional directory/directories to scan for shared + extensions when PHP_WASM is outside the package bin dir + PHP_SOURCE_DIR Path to a php-src checkout/extract + PHP_TEST_RUN_UID Optional guest uid for PHP processes + PHP_TEST_RUN_GID Optional guest gid for PHP processes +`); +} + +async function main() { + // Upstream run-tests.php expects TEST_NON_ROOT_USER to be available for + // root-run preloading tests that use --INI-- placeholders before the guest + // process is spawned. Provide the portable account that Kandelo rootfs/VFS + // images carry by default rather than requiring every harness invocation to + // remember this environment variable. + process.env.TEST_NON_ROOT_USER ??= "nobody"; + + const args = process.argv.slice(2); + let host: HostKind = "node"; + let timeoutMs = 60_000; + let shard: { index: number; total: number } | null = null; + let offset = 0; + let limit: number | null = null; + let jobs = 1; + let runUid = parseOptionalNonNegativeInt(process.env.PHP_TEST_RUN_UID, "PHP_TEST_RUN_UID"); + let runGid = parseOptionalNonNegativeInt(process.env.PHP_TEST_RUN_GID, "PHP_TEST_RUN_GID"); + let hostResetInterval = parseInt( + process.env.PHP_TEST_HOST_RESET_INTERVAL ?? "50", + 10, + ); + let enableTcpNetwork = process.env.PHP_TEST_ENABLE_TCP_NETWORK !== "0"; + let json = false; + let report = false; + let rebuildVfs = false; + const selectors: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help" || arg === "-h") { + printUsage(); + return; + } else if (arg === "--host" && args[i + 1]) { + const value = args[++i]; + if (value !== "node" && value !== "browser") + throw new Error(`invalid host: ${value}`); + host = value; + } else if (arg === "--all") { + // Default mode; accepted for clarity. + } else if (arg === "--timeout" && args[i + 1]) { + timeoutMs = parseInt(args[++i], 10); + } else if (arg === "--shard" && args[i + 1]) { + const value = args[++i]; + const match = /^(\d+)\/(\d+)$/.exec(value); + if (!match) throw new Error(`invalid shard: ${value}`); + shard = { + index: parseInt(match[1], 10), + total: parseInt(match[2], 10), + }; + if (shard.total < 1 || shard.index < 1 || shard.index > shard.total) { + throw new Error(`invalid shard: ${value}`); + } + } else if (arg === "--offset" && args[i + 1]) { + offset = parseInt(args[++i], 10); + if (!Number.isFinite(offset) || offset < 0) { + throw new Error(`invalid offset: ${offset}`); + } + } else if (arg === "--limit" && args[i + 1]) { + limit = parseInt(args[++i], 10); + if (!Number.isFinite(limit) || limit < 0) { + throw new Error(`invalid limit: ${limit}`); + } + } else if (arg === "--jobs" && args[i + 1]) { + jobs = parseInt(args[++i], 10); + if (!Number.isFinite(jobs) || jobs < 1) { + throw new Error(`invalid jobs: ${jobs}`); + } + } else if (arg === "--run-uid" && args[i + 1]) { + runUid = parseOptionalNonNegativeInt(args[++i], "--run-uid"); + } else if (arg === "--run-gid" && args[i + 1]) { + runGid = parseOptionalNonNegativeInt(args[++i], "--run-gid"); + } else if (arg === "--host-reset-interval" && args[i + 1]) { + hostResetInterval = parseInt(args[++i], 10); + if (!Number.isFinite(hostResetInterval) || hostResetInterval < 0) { + throw new Error(`invalid host reset interval: ${hostResetInterval}`); + } + } else if (arg === "--disable-tcp-network") { + enableTcpNetwork = false; + } else if (arg === "--json") { + json = true; + } else if (arg === "--report") { + report = true; + } else if (arg === "--rebuild-vfs") { + rebuildVfs = true; + } else if (arg.startsWith("--")) { + throw new Error(`unknown option: ${arg}`); + } else { + selectors.push(arg); + } + } + + const sourceRoot = resolvePhpSource(); + preparePhpTestFixtures(sourceRoot); + const phpPath = resolvePhpBinary(); + const phpFpmPath = resolvePhpFpmBinary(phpPath); + const sharedExtensionPaths = sharedExtensionPathsForPhp(phpPath); + const availableSharedExtensions = new Set(sharedExtensionPaths.keys()); + const availableExtensions = new Set([ + ...staticExtensionsForPhpSource(sourceRoot), + ...availableSharedExtensions, + ]); + let tests = discoverTests(sourceRoot, selectors); + if (shard !== null) { + tests = tests.filter((_, idx) => idx % shard!.total === shard!.index - 1); + } + if (offset > 0) tests = tests.slice(offset); + if (limit !== null) tests = tests.slice(0, limit); + if (host === "browser" && jobs !== 1) { + throw new Error("--jobs is currently supported only by the node host"); + } + + if (!json) { + console.error("===== PHP PHPT runtime tests ====="); + console.error(`Host: ${host}`); + console.error(`php-src: ${sourceRoot}`); + console.error(`PHP wasm: ${phpPath}`); + if (phpFpmPath) { + console.error(`PHP-FPM wasm: ${phpFpmPath}`); + } + if (availableSharedExtensions.size > 0) { + console.error( + `Shared extensions: ${[...availableSharedExtensions].join(", ")}`, + ); + } + if (shard !== null) { + console.error(`Shard: ${shard.index}/${shard.total}`); + } + if (offset > 0) console.error(`Offset: ${offset}`); + if (jobs > 1) console.error(`Jobs: ${jobs}`); + if (host === "node") { + console.error(`Node host reset interval: ${hostResetInterval}`); + console.error( + `Node TCP/DNS bridge: ${enableTcpNetwork ? "enabled" : "disabled"}`, + ); + } + if (runUid !== undefined || runGid !== undefined) { + console.error( + `Guest credentials: uid=${runUid ?? 0} gid=${runGid ?? runUid ?? 0}`, + ); + } + console.error(`Tests: ${tests.length}`); + console.error(""); + } + + const runners: PhpRunner[] = []; + if (host === "browser") { + const runner = new BrowserPhpRunner( + sourceRoot, + rebuildVfs, + availableSharedExtensions, + runUid, + runGid, + ); + await runner.init(); + runners.push(runner); + } else { + for (let i = 0; i < jobs; i++) { + const runnerSourceRoot = + jobs === 1 && runUid === undefined && runGid === undefined + ? sourceRoot + : copySourceRootForNodeRunner(sourceRoot, i + 1); + if (runUid !== undefined || runGid !== undefined) { + makeSourceTreeWritableByGuest(runnerSourceRoot); + } + runners.push( + new NodePhpRunner( + runnerSourceRoot, + phpPath, + phpFpmPath, + sharedExtensionPaths, + runnerSourceRoot !== sourceRoot, + hostResetInterval, + enableTcpNetwork, + runUid, + runGid, + ), + ); + } + } + + const counts: Record = { + pass: 0, + fail: 0, + skip: 0, + xfail: 0, + xpass: 0, + unsupported: 0, + time: 0, + }; + const results: TestResult[] = new Array(tests.length); + let completed = 0; + const pendingTests = new Set(tests.map((_test, index) => index)); + const activeConflicts = new Set(); + let activeTests = 0; + let exclusiveActive = false; + let schedulerWaiters: Array<() => void> = []; + + async function acquireTest(): Promise<{ + index: number; + conflicts: string[]; + } | null> { + while (true) { + if (pendingTests.size === 0) return null; + for (const index of pendingTests) { + const conflicts = phptConflictTokens(tests[index]); + const exclusive = requiresExclusiveScheduling(conflicts); + if (exclusiveActive || (exclusive && activeTests > 0)) { + continue; + } + if (conflicts.some((conflict) => activeConflicts.has(conflict))) { + continue; + } + pendingTests.delete(index); + for (const conflict of conflicts) activeConflicts.add(conflict); + activeTests++; + if (exclusive) exclusiveActive = true; + return { index, conflicts }; + } + await new Promise((resolve) => schedulerWaiters.push(resolve)); + } + } + + function releaseTest(conflicts: string[]) { + for (const conflict of conflicts) activeConflicts.delete(conflict); + if (requiresExclusiveScheduling(conflicts)) exclusiveActive = false; + activeTests = Math.max(0, activeTests - 1); + const waiters = schedulerWaiters; + schedulerWaiters = []; + for (const wake of waiters) wake(); + } + + try { + await Promise.all( + runners.map(async (runner) => { + while (true) { + const acquired = await acquireTest(); + if (acquired === null) break; + const { index, conflicts } = acquired; + let result: TestResult; + try { + result = await runPhpt( + tests[index], + runner, + availableExtensions, + timeoutMs, + ); + } finally { + releaseTest(conflicts); + } + counts[result.status]++; + results[index] = result; + completed++; + await runner.endTest?.(); + if (json) { + console.log(JSON.stringify(result)); + } else { + const label = result.status.toUpperCase().padEnd(11); + console.error( + `[${completed}/${tests.length}] ${label} ${result.test} (${result.time_ms}ms)`, + ); + } + } + }), + ); + } finally { + await Promise.all( + runners.map((runner) => + runner.close().catch(() => { + // Keep shutdown best-effort so one wedged worker does not hide + // already-recorded PHPT results. + }), + ), + ); + } + + const completedResults = results.filter((result): result is TestResult => { + return result !== undefined; + }); + if (completedResults.length !== tests.length) { + for (let i = 0; i < tests.length; i++) { + if (results[i] === undefined) { + const result: TestResult = { + test: tests[i].rel, + status: "time", + time_ms: 0, + reason: "harness did not record a result", + }; + results[i] = result; + counts.time++; + } + } + } + + if (report) { + const reportPath = join(REPO_ROOT, "docs/php-upstream-test-report.md"); + mkdirSync(dirname(reportPath), { recursive: true }); + const lines = [ + "# PHP PHPT Runtime Test Report", + "", + `Host: ${host}`, + `Generated: ${new Date().toISOString()}`, + "", + "| Status | Count |", + "|--------|-------|", + ...Object.entries(counts).map( + ([status, count]) => `| ${status.toUpperCase()} | ${count} |`, + ), + `| **TOTAL** | **${results.length}** |`, + "", + "## Non-Passing Results", + "", + ...results + .filter((r) => !["pass", "skip", "xfail"].includes(r.status)) + .map( + (r) => + `- ${r.status.toUpperCase()} \`${r.test}\`${r.reason ? `: ${r.reason}` : ""}${r.detail ? ` (${r.detail})` : ""}`, + ), + "", + ]; + writeFileSync(reportPath, `${lines.join("\n")}\n`); + if (!json) console.error(`Report written to: ${reportPath}`); + } + + if (!json) { + console.error(""); + console.error("===== Results ====="); + for (const status of [ + "pass", + "fail", + "skip", + "xfail", + "xpass", + "unsupported", + "time", + ] as const) { + console.error(`${status.toUpperCase().padEnd(11)} ${counts[status]}`); + } + console.error(`TOTAL ${results.length}`); + } + + if (counts.fail > 0 || counts.xpass > 0 || counts.time > 0) { + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_ca.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_ca.pem new file mode 100644 index 000000000..7d12f30a4 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_ca.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmzCCAoOgAwIBAgIUbFxrb9gyAFWFxJbfO2zE80o58NUwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBVMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDEQMA4GA1UECwwHb3BlbnNzbDEQ +MA4GA1UEAwwHcGhwLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOPtB+W9PFt25KBpeOZxECDFqfoyJSUdQP1Id5NKWm9E1Xv3C/BSKuH0CKUJOv15 +Ar88GK8LitUA5Lux4eJ6KHu9LNe6Q1bn62bEdYaK/r/yIkDEBw4fq3IKfZwr0aL8 +FD0Celxb8+tgNcms1NgwaKDyr4ZV63eagQyzglFC+a0B1ebI5SEI1+1TshZm4wK0 +9nkCxg5k9g2HykxweL2txntj6cq1DCRWcfSQGoYuyxv0ZUqlKpJ9Db+KootXWbVw +GQNEoqmjLr/BhNcIcDaeWl88rYqdF5Fyjis4TgvWeFmaqwzjhrv5Xdi/hAK69WKR +SCwSANR4phUgT3YUECiaZhkCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFIoUhHItIO0KRg/VqCuif9w6CinhMB8GA1Ud +IwQYMBaAFIoUhHItIO0KRg/VqCuif9w6CinhMA0GCSqGSIb3DQEBCwUAA4IBAQCG +SgcPVF6eUUtPNZywAZvHRBFkBp/8iklG8cTzw/krrOEjsCu5gkvePaMWpS8jYf+D +ogTvUXuO574Do0vDu6QRg2bq/NzAObjjW7NDpKYJYPDJpZy8EVkhygCVoJlca8iN +fTHCyipIpcwulLlTzNVLcHWBeImrrbWbWBLanCX3PMXdcw/gleLmDqOPQIUx/fT/ +7zNAc226ElMVRsrLaQaox9eT7LiWCE89tQPYuhCE+ot7USYAk9Mejzqj2QIMCgql +v9ZRbAHjzEC0XtU2lxrskaJfmw+wrbu38YbCBrGgoYu3GsLfGmV5TCGF6Ywo2m1k +vgBaIImPacDxAnw958yv +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_cs.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_cs.pem new file mode 100644 index 000000000..17dc7162d --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_cs.pem @@ -0,0 +1,50 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCngdE7dj025OjC +S+49EVxUVRSf/B12p4307zCVQvrWxTMI8gjyEuVaENvGkEz1E1bcCMQuE6vq3EAd +4XcUuSaKj8KX8xyB8MVqF9ZncKrXFx2SNkDgi4asj0DH0ESiZRncwY2OMFUuBdU8 +c6rJyiqmFA2dHJRGEDdQb4+0xYqezJv0TxpK0dho155YrIMR5hdBLzII0iYPN7eM +QSI6u98FkZRNyO/2JbO0ohXnyFf9g0DqaJoS6SklBWoy+afT2JHuG2nrhxLvglwB +VP4OG8zTxa6lzN46pYHShr8mU9hGl5DpwxFaUT4xN8MpB8j68GE0EoFpfDgdVpVX +6YfRYXp5AgMBAAECggEAIOs+wthMVG7tHU4NzluMkRg9WumRosFwAPxGyHam1DFi +A7aJMoWrJCWfA2lVbm50Cf8BCusbxKavRRALtntiiyWQgQsqhZDbQyvXG3IFaQrm +aVcXLenNuDjY6svPyThkOkGhf5mZC9finaQ/orOD6SqUuFUnAJPsQvcBv214/hb2 +2xcNf4r5mZPI3CpLtk8x70YoFfPPd4szhZ9cV6qCQuRcDkYBQMHkppytcvmleMdb +0MFKQKEPNE+iJDRBuwYDhDbbZAfjtt6Av4VPtAgusMPu4dZp/sD3TCBm4MgdFeKC +pTDvXXwiBd/7/1Y3nwWka4qRm8xYkSy7g4cs+R27OQKBgQDrPEXJl29HjYnJJZpm +kq1PowVw4ss+DDr06KQn2tLYYD4LrSo5Y8TrpZI2HZ4n359DPrE/g5hKThEyIbMb +pRkCGaseOlMjLmCcy7NQ+SBim/MNmdd+3ZKkOXSKOLFyJVyiGoS3oN63u3zOqS+D +AEeHOv+MKijfkwqua7Wn1BTypQKBgQC2Sw4ajhtqNzITkrauf4B9T1nMhAkBP3LO +Sk9pFiQzK9aj9Hzkh/W34RS+IruYjLSpuEvFk6cZJmx0BLSnr6moNr2aeaHmDzjz +UL3yL5bQtmdzqcW7VDq6HdAMLXzqFxnoSTluD0jGPlqlUyDVYjiZjKXQGcokBqXt +2ro16POERQKBgQC6M4d6PyiSlvLOCbniH5CYTQ5tgNgoUT4JxmzKL2heZjA2xed6 +bgLeFL1boK4kGdrTO7jJ8byGdK0f5ZUE+PaGtxLAZqKQYpGPC57xJYIBDDikN3Zg +sbr1y5T4JFAxbmmY1yzevbQN85ajb0BwjbQQ0x6dMHISJy55SkEMi8/ZEQKBgC2K +Cnfv0EF0kHn7SM/G4l5rmE9THLzHEPMf9T6XXWu7I9/Jj/m3TVcmT3xASQUPAYL0 +m1k8TZ4mzykck9TgOU3gfvU8NYm3e86s+QarhM2XA7kNTGxD2nfsQrEIHBCOvNYe +lM492zxLD3IFlko4Q1N4o2OdTbY6QvFsluAo1czRAoGARaokauDWHRN1vJRwT/Tv +oCnKdGDT62Fe7zyC2uBfMEd5ikpO6+g8lw5TPcvThrPbG+mFeryHXCFZos9FZJag +gvBSU+biH8GAZUiZmTPFdLvJTmBaujhjrWDJW6wo8UYHvxOzeIeUrz/56/nbhiuC +LPXVIJrV8dz3rDoc60SsuO4= +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUgwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKY3MucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKeB0Tt2PTbk6MJL7j0R +XFRVFJ/8HXanjfTvMJVC+tbFMwjyCPIS5VoQ28aQTPUTVtwIxC4Tq+rcQB3hdxS5 +JoqPwpfzHIHwxWoX1mdwqtcXHZI2QOCLhqyPQMfQRKJlGdzBjY4wVS4F1TxzqsnK +KqYUDZ0clEYQN1Bvj7TFip7Mm/RPGkrR2GjXnlisgxHmF0EvMgjSJg83t4xBIjq7 +3wWRlE3I7/Yls7SiFefIV/2DQOpomhLpKSUFajL5p9PYke4baeuHEu+CXAFU/g4b +zNPFrqXM3jqlgdKGvyZT2EaXkOnDEVpRPjE3wykHyPrwYTQSgWl8OB1WlVfph9Fh +enkCAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggpjcy5waHAubmV0MB0GA1UdDgQWBBTw +ZzGQ1lMAdYvJIyf6WOjBYOiqzzAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAKYbB8i+zeJjjN+9UdXIHhgosHeaTUpvf +KdoMkHGVVIch2a0wnizKB5ovXvH3FIceTui2/gr6RFryuRCL942wiAi70tH3IWKw +g8H9yATVrWPD/11Myzm3328FqwC8YOsuqx9Q5cVdbPF1ucIeJ2w73htAvrtZdUWI +nY3z7D/4M77MQ8JwqzIaWD7EXXnU3ed1XyhIZ6xwXsB2ovRD5TG/Dvl4w8egebOz +1/9F9uIEEnZZ84tuL4zoawXGhWsDgGzsf+yxmwjitaVzUG8yhUNILl1mwLmcZl1F +/6Fx49f8nUUR8Fx1j476Xti+Yo3K9AedClwvs8WVCmVD/FD66FK/ZA== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_cs_cert.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_cert.pem new file mode 100644 index 000000000..3b27490e5 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUgwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKY3MucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKeB0Tt2PTbk6MJL7j0R +XFRVFJ/8HXanjfTvMJVC+tbFMwjyCPIS5VoQ28aQTPUTVtwIxC4Tq+rcQB3hdxS5 +JoqPwpfzHIHwxWoX1mdwqtcXHZI2QOCLhqyPQMfQRKJlGdzBjY4wVS4F1TxzqsnK +KqYUDZ0clEYQN1Bvj7TFip7Mm/RPGkrR2GjXnlisgxHmF0EvMgjSJg83t4xBIjq7 +3wWRlE3I7/Yls7SiFefIV/2DQOpomhLpKSUFajL5p9PYke4baeuHEu+CXAFU/g4b +zNPFrqXM3jqlgdKGvyZT2EaXkOnDEVpRPjE3wykHyPrwYTQSgWl8OB1WlVfph9Fh +enkCAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggpjcy5waHAubmV0MB0GA1UdDgQWBBTw +ZzGQ1lMAdYvJIyf6WOjBYOiqzzAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAKYbB8i+zeJjjN+9UdXIHhgosHeaTUpvf +KdoMkHGVVIch2a0wnizKB5ovXvH3FIceTui2/gr6RFryuRCL942wiAi70tH3IWKw +g8H9yATVrWPD/11Myzm3328FqwC8YOsuqx9Q5cVdbPF1ucIeJ2w73htAvrtZdUWI +nY3z7D/4M77MQ8JwqzIaWD7EXXnU3ed1XyhIZ6xwXsB2ovRD5TG/Dvl4w8egebOz +1/9F9uIEEnZZ84tuL4zoawXGhWsDgGzsf+yxmwjitaVzUG8yhUNILl1mwLmcZl1F +/6Fx49f8nUUR8Fx1j476Xti+Yo3K9AedClwvs8WVCmVD/FD66FK/ZA== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_cs_key.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_key.pem new file mode 100644 index 000000000..e0a8efcbb --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCngdE7dj025OjC +S+49EVxUVRSf/B12p4307zCVQvrWxTMI8gjyEuVaENvGkEz1E1bcCMQuE6vq3EAd +4XcUuSaKj8KX8xyB8MVqF9ZncKrXFx2SNkDgi4asj0DH0ESiZRncwY2OMFUuBdU8 +c6rJyiqmFA2dHJRGEDdQb4+0xYqezJv0TxpK0dho155YrIMR5hdBLzII0iYPN7eM +QSI6u98FkZRNyO/2JbO0ohXnyFf9g0DqaJoS6SklBWoy+afT2JHuG2nrhxLvglwB +VP4OG8zTxa6lzN46pYHShr8mU9hGl5DpwxFaUT4xN8MpB8j68GE0EoFpfDgdVpVX +6YfRYXp5AgMBAAECggEAIOs+wthMVG7tHU4NzluMkRg9WumRosFwAPxGyHam1DFi +A7aJMoWrJCWfA2lVbm50Cf8BCusbxKavRRALtntiiyWQgQsqhZDbQyvXG3IFaQrm +aVcXLenNuDjY6svPyThkOkGhf5mZC9finaQ/orOD6SqUuFUnAJPsQvcBv214/hb2 +2xcNf4r5mZPI3CpLtk8x70YoFfPPd4szhZ9cV6qCQuRcDkYBQMHkppytcvmleMdb +0MFKQKEPNE+iJDRBuwYDhDbbZAfjtt6Av4VPtAgusMPu4dZp/sD3TCBm4MgdFeKC +pTDvXXwiBd/7/1Y3nwWka4qRm8xYkSy7g4cs+R27OQKBgQDrPEXJl29HjYnJJZpm +kq1PowVw4ss+DDr06KQn2tLYYD4LrSo5Y8TrpZI2HZ4n359DPrE/g5hKThEyIbMb +pRkCGaseOlMjLmCcy7NQ+SBim/MNmdd+3ZKkOXSKOLFyJVyiGoS3oN63u3zOqS+D +AEeHOv+MKijfkwqua7Wn1BTypQKBgQC2Sw4ajhtqNzITkrauf4B9T1nMhAkBP3LO +Sk9pFiQzK9aj9Hzkh/W34RS+IruYjLSpuEvFk6cZJmx0BLSnr6moNr2aeaHmDzjz +UL3yL5bQtmdzqcW7VDq6HdAMLXzqFxnoSTluD0jGPlqlUyDVYjiZjKXQGcokBqXt +2ro16POERQKBgQC6M4d6PyiSlvLOCbniH5CYTQ5tgNgoUT4JxmzKL2heZjA2xed6 +bgLeFL1boK4kGdrTO7jJ8byGdK0f5ZUE+PaGtxLAZqKQYpGPC57xJYIBDDikN3Zg +sbr1y5T4JFAxbmmY1yzevbQN85ajb0BwjbQQ0x6dMHISJy55SkEMi8/ZEQKBgC2K +Cnfv0EF0kHn7SM/G4l5rmE9THLzHEPMf9T6XXWu7I9/Jj/m3TVcmT3xASQUPAYL0 +m1k8TZ4mzykck9TgOU3gfvU8NYm3e86s+QarhM2XA7kNTGxD2nfsQrEIHBCOvNYe +lM492zxLD3IFlko4Q1N4o2OdTbY6QvFsluAo1czRAoGARaokauDWHRN1vJRwT/Tv +oCnKdGDT62Fe7zyC2uBfMEd5ikpO6+g8lw5TPcvThrPbG+mFeryHXCFZos9FZJag +gvBSU+biH8GAZUiZmTPFdLvJTmBaujhjrWDJW6wo8UYHvxOzeIeUrz/56/nbhiuC +LPXVIJrV8dz3rDoc60SsuO4= +-----END PRIVATE KEY----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_uk.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_uk.pem new file mode 100644 index 000000000..1471e11da --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_uk.pem @@ -0,0 +1,50 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCdOoxfvHn15Ozy +pykYYG/DuTMU4Qe1Ku+XdtnsGpygAhOkAdA6Wa/D/ZeUzq58SjkjV93HU4HHZFCF +eMCtlimszwiuSCYbYQobUuqf0+D/N/apNlR+EdIkg/jDPDv+/PnV+1DOEOL2t+zk +QO4i9TV+6bdqgvMTAUTwLH8aUW8b+gQI9O+/etTQt3kZwsC1H7hpIfZdo3dgr5cb +08+XooXRJBQ/PoA4ybCEF+oW+Ws0qhNcyYPhLxB8ijUnEiU9zNOSC0mwxZX7AlZN +kH45S2a5CQ9A7EbqbRp1AnwgToy8a5hqLOeIX55TWQY+4qnqW0xmhROZp9HPyX0F +6hVAnVBdAgMBAAECgf8ld6ZhxDGZIRQ3vCpXz28J265UsqCul+jxVFpaxBg5ioG1 +kjpug/ZajfRFnq3+Iejvbovlot5T+Zqh/M3dE/rMqHOAhXylQ25Hiv2P5zxMzzav +fIOgeYisMHm4akX9Vb8rU4KaEaXo1Wo5xiwMtPRAQHy4UclhPgZXfR9Acf4tbjx4 +9dHFwPCdrEfbssQ9S9k/G1/tY/qAZKUdk3GbQCWpgTukE/4ce1OyxRhEg+cOcTnz +K/VcNEB1Ud9AXj++CHjrv88pYeGPyR/Bvvt+7bb3sJH3J3ub+55BPd/2NrDcAwfa +ovNbupOAmhkSg7s+vbuhaYn9dVYETJMV6VFR4mMCgYEA3VD/G5UImDlr2SODU4/X +jApYv4tGPzeoaBrSC07BRySgnD6CT5xqkSXy8FqRDSnujOeUnr2JSLY+7it3978M +AzZ/grjqnOHQJIvjKqVpUydMEqDZXozR5nfde0liQdn7ieHJy7JtlICfY7VWKHdH +u4BfQuCq21NGh0VD4ExKwI8CgYEAtd5qJY9CDrrxI0WMMmjng2lfhqJkSuj/NsBG +9fFz17u2vSwrFLhup9nS3T3lvyjktUu3RsMr3VDVizOg4GRHTUSefBnc9aIkJi9f +Xe7/RE7z1RQzi3tCh/qHY51+rqie27ldaIUORDBIYufmlKRrcXf1oq3mLyDu7/Ea +AzT9/lMCgYEAiFFugN/ARnr/6eRM7LhpzNFGrtyrbR7sNQtoGxzsQdTWtMZv33d5 +0GOuistOEuykCdhOm6QlHkta4bqWj1v2mzgDPFKH+A/C8+/SAZ+XC2fmHIdEvE9C +rpFgM6MUyXjpzZjsgfIqOtEq2/CC3DP0VBKTGo9lzegyRfmtAEgdtxMCgYBSPR/c +mhox1QDjhThvNxaxSr3igJ4/bXqyhGHSJvvOKtjoVAerF/cZuZrrZmj0dwZsoK9g +tTKpojrd9luh/FZtr0DHN16+SDJ2fedu73rIpbcGvFrOkM818+iy9+3oBuHG09xU +Pg6EcRzadKjEwFDBf5A4ntA+sXK6V1j+6ECOdwKBgDJ/gjilnFni0VECZKulaWNo +3uYD8F9vZooSFUr7+amu+Diue/PUPA4NDFhXvrfzxIuBvUWuNJXTXuyw4XpWKC1H +WwXzuj9P0Ssjg5YYP8htl4YH1rn7lFJ16lraerynZdZS+1vyggUChqkrezzrnX5u +BkYpQzVIQ+JKuHbWG/iX +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUkwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdWsucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ06jF+8efXk7PKnKRhg +b8O5MxThB7Uq75d22ewanKACE6QB0DpZr8P9l5TOrnxKOSNX3cdTgcdkUIV4wK2W +KazPCK5IJhthChtS6p/T4P839qk2VH4R0iSD+MM8O/78+dX7UM4Q4va37ORA7iL1 +NX7pt2qC8xMBRPAsfxpRbxv6BAj077961NC3eRnCwLUfuGkh9l2jd2CvlxvTz5ei +hdEkFD8+gDjJsIQX6hb5azSqE1zJg+EvEHyKNScSJT3M05ILSbDFlfsCVk2QfjlL +ZrkJD0DsRuptGnUCfCBOjLxrmGos54hfnlNZBj7iqepbTGaFE5mn0c/JfQXqFUCd +UF0CAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1ay5waHAubmV0MB0GA1UdDgQWBBS/ +TlI4W/dtnINRF6NZMr690pcz1jAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAqKLfMtAUd4tNlHT6ujOZdRVGARR1r9tz +0y48X2rwV/vQ5mg98Sb4Tq515pY0t9+TAwMmoEAbZcv29BiQfWXYBLeqvmGTNBEQ ++tU2OAQ284rIui9KV5tBH65x79sbg93KRbgdBUXzqQN1TvINDLtnS2IWmzCuf7YT +qe/1SV1bKSXTsqMfzS7MswpXjhzSn4M4CCiNypBGH05bTFOtK6fZtuY8eAuXr+k7 +pi0eUOureItWM/8fh6KEUbIB9AnFWdT7vZSAuNnznfdUVxQ3LLjbvUdgJJ3RCYTs +ttPJGQDmXsDt6IWsWUwBsWP6H6iA0apRUfC165vV2UKa36CsOk3Cfw== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_uk_cert.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_cert.pem new file mode 100644 index 000000000..6562c60eb --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUkwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdWsucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ06jF+8efXk7PKnKRhg +b8O5MxThB7Uq75d22ewanKACE6QB0DpZr8P9l5TOrnxKOSNX3cdTgcdkUIV4wK2W +KazPCK5IJhthChtS6p/T4P839qk2VH4R0iSD+MM8O/78+dX7UM4Q4va37ORA7iL1 +NX7pt2qC8xMBRPAsfxpRbxv6BAj077961NC3eRnCwLUfuGkh9l2jd2CvlxvTz5ei +hdEkFD8+gDjJsIQX6hb5azSqE1zJg+EvEHyKNScSJT3M05ILSbDFlfsCVk2QfjlL +ZrkJD0DsRuptGnUCfCBOjLxrmGos54hfnlNZBj7iqepbTGaFE5mn0c/JfQXqFUCd +UF0CAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1ay5waHAubmV0MB0GA1UdDgQWBBS/ +TlI4W/dtnINRF6NZMr690pcz1jAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAqKLfMtAUd4tNlHT6ujOZdRVGARR1r9tz +0y48X2rwV/vQ5mg98Sb4Tq515pY0t9+TAwMmoEAbZcv29BiQfWXYBLeqvmGTNBEQ ++tU2OAQ284rIui9KV5tBH65x79sbg93KRbgdBUXzqQN1TvINDLtnS2IWmzCuf7YT +qe/1SV1bKSXTsqMfzS7MswpXjhzSn4M4CCiNypBGH05bTFOtK6fZtuY8eAuXr+k7 +pi0eUOureItWM/8fh6KEUbIB9AnFWdT7vZSAuNnznfdUVxQ3LLjbvUdgJJ3RCYTs +ttPJGQDmXsDt6IWsWUwBsWP6H6iA0apRUfC165vV2UKa36CsOk3Cfw== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_uk_key.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_key.pem new file mode 100644 index 000000000..b6932ec1d --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCdOoxfvHn15Ozy +pykYYG/DuTMU4Qe1Ku+XdtnsGpygAhOkAdA6Wa/D/ZeUzq58SjkjV93HU4HHZFCF +eMCtlimszwiuSCYbYQobUuqf0+D/N/apNlR+EdIkg/jDPDv+/PnV+1DOEOL2t+zk +QO4i9TV+6bdqgvMTAUTwLH8aUW8b+gQI9O+/etTQt3kZwsC1H7hpIfZdo3dgr5cb +08+XooXRJBQ/PoA4ybCEF+oW+Ws0qhNcyYPhLxB8ijUnEiU9zNOSC0mwxZX7AlZN +kH45S2a5CQ9A7EbqbRp1AnwgToy8a5hqLOeIX55TWQY+4qnqW0xmhROZp9HPyX0F +6hVAnVBdAgMBAAECgf8ld6ZhxDGZIRQ3vCpXz28J265UsqCul+jxVFpaxBg5ioG1 +kjpug/ZajfRFnq3+Iejvbovlot5T+Zqh/M3dE/rMqHOAhXylQ25Hiv2P5zxMzzav +fIOgeYisMHm4akX9Vb8rU4KaEaXo1Wo5xiwMtPRAQHy4UclhPgZXfR9Acf4tbjx4 +9dHFwPCdrEfbssQ9S9k/G1/tY/qAZKUdk3GbQCWpgTukE/4ce1OyxRhEg+cOcTnz +K/VcNEB1Ud9AXj++CHjrv88pYeGPyR/Bvvt+7bb3sJH3J3ub+55BPd/2NrDcAwfa +ovNbupOAmhkSg7s+vbuhaYn9dVYETJMV6VFR4mMCgYEA3VD/G5UImDlr2SODU4/X +jApYv4tGPzeoaBrSC07BRySgnD6CT5xqkSXy8FqRDSnujOeUnr2JSLY+7it3978M +AzZ/grjqnOHQJIvjKqVpUydMEqDZXozR5nfde0liQdn7ieHJy7JtlICfY7VWKHdH +u4BfQuCq21NGh0VD4ExKwI8CgYEAtd5qJY9CDrrxI0WMMmjng2lfhqJkSuj/NsBG +9fFz17u2vSwrFLhup9nS3T3lvyjktUu3RsMr3VDVizOg4GRHTUSefBnc9aIkJi9f +Xe7/RE7z1RQzi3tCh/qHY51+rqie27ldaIUORDBIYufmlKRrcXf1oq3mLyDu7/Ea +AzT9/lMCgYEAiFFugN/ARnr/6eRM7LhpzNFGrtyrbR7sNQtoGxzsQdTWtMZv33d5 +0GOuistOEuykCdhOm6QlHkta4bqWj1v2mzgDPFKH+A/C8+/SAZ+XC2fmHIdEvE9C +rpFgM6MUyXjpzZjsgfIqOtEq2/CC3DP0VBKTGo9lzegyRfmtAEgdtxMCgYBSPR/c +mhox1QDjhThvNxaxSr3igJ4/bXqyhGHSJvvOKtjoVAerF/cZuZrrZmj0dwZsoK9g +tTKpojrd9luh/FZtr0DHN16+SDJ2fedu73rIpbcGvFrOkM818+iy9+3oBuHG09xU +Pg6EcRzadKjEwFDBf5A4ntA+sXK6V1j+6ECOdwKBgDJ/gjilnFni0VECZKulaWNo +3uYD8F9vZooSFUr7+amu+Diue/PUPA4NDFhXvrfzxIuBvUWuNJXTXuyw4XpWKC1H +WwXzuj9P0Ssjg5YYP8htl4YH1rn7lFJ16lraerynZdZS+1vyggUChqkrezzrnX5u +BkYpQzVIQ+JKuHbWG/iX +-----END PRIVATE KEY----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_us.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_us.pem new file mode 100644 index 000000000..97641069a --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_us.pem @@ -0,0 +1,50 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDM4i6AxiqeCuRN +Olom7e6v5WvYajTNjHTmM49wCeCTS6qzv7SK9mB5rvRJPveSipBp1QngzuJ5fUdW +c1bmrDN8CtoGgxKtb8TJnznF1/O5Rc5eUgLblB+eVwlokcE0iA3xa6nwhOOJIvxy +WvrlqmRA1Vbio67uzyK0dbIc+2Wl/bcFWig0ngUHmWcb7M70y3COh3XREu7dZVBH +ddkW9mrQa5nV5A+hjSpSjzt2BkhWQlLJoFaAQJcc+VZdkV7UOLjq3mqklm1xGjd8 +Hz6285opd81x7lnb3cKxcP9nOe+YItLhkHOZDtbCGn6eC3vNUb61SiWWYz3jysd0 +zs9W/OkRAgMBAAECggEAEenMqxGodDz7jQ0HCNRQxCxI65kNmNMo/V+QsPkMG5TI +DgM649N6x+jWDvlkZ/mZesdXn8I25uxfKl3XQT/ZGIVSXrNjFKrrB73WLUgeVRqX +WoPNICmpQgzWE8B6pf8nSO5i41abFkhqzgzKVv442bpNaG9rQhH2NNT9h68rSgHm +r4TL3mjYsHH3KhsE3AZ6hkistmHFrI34gqgiVVJc9Wkc1PUXrQv0MIailOAA0pci +vd+NCylS5YrZuPA+yvYnNhZg8YI31Jz6NlJX1gef54mQHI9777kVZ621nmyu6z+J +4421PKljuAa6McFZ68ZaoKDePeZ18i9oPT9C+pgMgQKBgQD23dLMIVqOh0JWOJGm +0m/i8rtGPfmGQ5nzL9lfE0btfdfg9MgxNcJgEjX+sBHCccSgBZkJMDcMDvd++uPK +ezqGUipHZn6IHO3qDej38GoAkXS4vfvokQZwFmbY2YZZHHeWcvXfCXUrCeWFTiyh +EF+bNoGOKYoD9iXZFLK8Wk1BaQKBgQDUdrhDic4AldhhCSUN6CDSpTcwEENQMUQI +n9kDu9BFIPtzoGt0h6/ttB1o76hRPIG/xDG0TmmYr+2uHPuv53iEE6xoOqVfhz9j +LMemGZJcEO6iac0R/JxMZ/f0Jtdb3qcqEMzDGRapEKXRhxyc2sckKouGDl6jmwU8 +cRv5hwzNaQKBgF8Y6IFWP+oz1KweMo2O9yb31oiiiyLbm8yAnD3x0RrfpW+1HAFI +8k3L9hzceprq9JGoVmGhvzLX+SNjRqjTe6IOuNa0dv28FzJYlWwdotgBMHOSTB40 +78Px/UB/2y8stiywsGJw5D/mGhKWIL8S7aF/B71Z5x7LjZzoSkoghy4xAoGAIlGS +7gXiaqHJs9LgU2C9fIkHYxIdbWSn0qTPTxUVTTVIqKKu+MG6HzFK1tpvW7/kTW+J +f9ByqiEwyUERPparFtp+rM9cSxPznzdqgF/9DjYF9eIVJ+Sf8o+Qs7VacBKE+fyA +trrkuCZiQngKnSORqzvcEalcjBzMObkjhajU3pECgYA9tsxLCCKS/MCPPLoKg0mQ +dFlR5wlXA1PXvOQrKg2LXedhtyyRGoxz23+dsCAMO7LPPoPYAvygCCPiLvv1pdf8 +Iu3YB/LUMw7GNzy2JAa2tTSUmta6fpEwA5lxTyWL/SlRmqUR5e4YNLiFAdMrM6M/ +DwDIoMrnaONIW7ZYbMvYqA== +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUowDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUzWhcNMzYwNjA5MTYxOTUzWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdXMucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMziLoDGKp4K5E06Wibt +7q/la9hqNM2MdOYzj3AJ4JNLqrO/tIr2YHmu9Ek+95KKkGnVCeDO4nl9R1ZzVuas +M3wK2gaDEq1vxMmfOcXX87lFzl5SAtuUH55XCWiRwTSIDfFrqfCE44ki/HJa+uWq +ZEDVVuKjru7PIrR1shz7ZaX9twVaKDSeBQeZZxvszvTLcI6HddES7t1lUEd12Rb2 +atBrmdXkD6GNKlKPO3YGSFZCUsmgVoBAlxz5Vl2RXtQ4uOreaqSWbXEaN3wfPrbz +mil3zXHuWdvdwrFw/2c575gi0uGQc5kO1sIafp4Le81RvrVKJZZjPePKx3TOz1b8 +6RECAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1cy5waHAubmV0MB0GA1UdDgQWBBRd +iVHN0GR/1hByJ6K1z0TMxERm9zAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAfCpLwnWxEGycUvviyrborc+oxABVbWV8 +WjWnqmrVfC71KEkj0F3dDutN/FrIETMIMPL+OqYLU+DtSPFtQT98KQD/4nUSGvmC +ceyaLiuWA+GpC7d+KrsqJyGr0vK04ueGXSKaTSpvsf/+EJjhyaqBq3EKH7YAytjo +7uekke04UQ5wYzmNc3dBN4xXXocIRima5nPfSfCtApa9mWVHlNMDt3BR4/i6tkrA +VT5rjgqy2/IZtyMksb0DCEz+Ek+JmJq/vPUzFB5Aeqlp7AcnKuErlUzFecQzeD/M +806UzOgn5ALPF+fgwxFxIlXPAdqhzCGQAYvYipb7RiY1J2ZErlJNog== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_us_cert.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_us_cert.pem new file mode 100644 index 000000000..65019fa65 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_us_cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUowDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUzWhcNMzYwNjA5MTYxOTUzWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdXMucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMziLoDGKp4K5E06Wibt +7q/la9hqNM2MdOYzj3AJ4JNLqrO/tIr2YHmu9Ek+95KKkGnVCeDO4nl9R1ZzVuas +M3wK2gaDEq1vxMmfOcXX87lFzl5SAtuUH55XCWiRwTSIDfFrqfCE44ki/HJa+uWq +ZEDVVuKjru7PIrR1shz7ZaX9twVaKDSeBQeZZxvszvTLcI6HddES7t1lUEd12Rb2 +atBrmdXkD6GNKlKPO3YGSFZCUsmgVoBAlxz5Vl2RXtQ4uOreaqSWbXEaN3wfPrbz +mil3zXHuWdvdwrFw/2c575gi0uGQc5kO1sIafp4Le81RvrVKJZZjPePKx3TOz1b8 +6RECAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1cy5waHAubmV0MB0GA1UdDgQWBBRd +iVHN0GR/1hByJ6K1z0TMxERm9zAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAfCpLwnWxEGycUvviyrborc+oxABVbWV8 +WjWnqmrVfC71KEkj0F3dDutN/FrIETMIMPL+OqYLU+DtSPFtQT98KQD/4nUSGvmC +ceyaLiuWA+GpC7d+KrsqJyGr0vK04ueGXSKaTSpvsf/+EJjhyaqBq3EKH7YAytjo +7uekke04UQ5wYzmNc3dBN4xXXocIRima5nPfSfCtApa9mWVHlNMDt3BR4/i6tkrA +VT5rjgqy2/IZtyMksb0DCEz+Ek+JmJq/vPUzFB5Aeqlp7AcnKuErlUzFecQzeD/M +806UzOgn5ALPF+fgwxFxIlXPAdqhzCGQAYvYipb7RiY1J2ZErlJNog== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_us_key.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_us_key.pem new file mode 100644 index 000000000..f53ba7f75 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_us_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDM4i6AxiqeCuRN +Olom7e6v5WvYajTNjHTmM49wCeCTS6qzv7SK9mB5rvRJPveSipBp1QngzuJ5fUdW +c1bmrDN8CtoGgxKtb8TJnznF1/O5Rc5eUgLblB+eVwlokcE0iA3xa6nwhOOJIvxy +WvrlqmRA1Vbio67uzyK0dbIc+2Wl/bcFWig0ngUHmWcb7M70y3COh3XREu7dZVBH +ddkW9mrQa5nV5A+hjSpSjzt2BkhWQlLJoFaAQJcc+VZdkV7UOLjq3mqklm1xGjd8 +Hz6285opd81x7lnb3cKxcP9nOe+YItLhkHOZDtbCGn6eC3vNUb61SiWWYz3jysd0 +zs9W/OkRAgMBAAECggEAEenMqxGodDz7jQ0HCNRQxCxI65kNmNMo/V+QsPkMG5TI +DgM649N6x+jWDvlkZ/mZesdXn8I25uxfKl3XQT/ZGIVSXrNjFKrrB73WLUgeVRqX +WoPNICmpQgzWE8B6pf8nSO5i41abFkhqzgzKVv442bpNaG9rQhH2NNT9h68rSgHm +r4TL3mjYsHH3KhsE3AZ6hkistmHFrI34gqgiVVJc9Wkc1PUXrQv0MIailOAA0pci +vd+NCylS5YrZuPA+yvYnNhZg8YI31Jz6NlJX1gef54mQHI9777kVZ621nmyu6z+J +4421PKljuAa6McFZ68ZaoKDePeZ18i9oPT9C+pgMgQKBgQD23dLMIVqOh0JWOJGm +0m/i8rtGPfmGQ5nzL9lfE0btfdfg9MgxNcJgEjX+sBHCccSgBZkJMDcMDvd++uPK +ezqGUipHZn6IHO3qDej38GoAkXS4vfvokQZwFmbY2YZZHHeWcvXfCXUrCeWFTiyh +EF+bNoGOKYoD9iXZFLK8Wk1BaQKBgQDUdrhDic4AldhhCSUN6CDSpTcwEENQMUQI +n9kDu9BFIPtzoGt0h6/ttB1o76hRPIG/xDG0TmmYr+2uHPuv53iEE6xoOqVfhz9j +LMemGZJcEO6iac0R/JxMZ/f0Jtdb3qcqEMzDGRapEKXRhxyc2sckKouGDl6jmwU8 +cRv5hwzNaQKBgF8Y6IFWP+oz1KweMo2O9yb31oiiiyLbm8yAnD3x0RrfpW+1HAFI +8k3L9hzceprq9JGoVmGhvzLX+SNjRqjTe6IOuNa0dv28FzJYlWwdotgBMHOSTB40 +78Px/UB/2y8stiywsGJw5D/mGhKWIL8S7aF/B71Z5x7LjZzoSkoghy4xAoGAIlGS +7gXiaqHJs9LgU2C9fIkHYxIdbWSn0qTPTxUVTTVIqKKu+MG6HzFK1tpvW7/kTW+J +f9ByqiEwyUERPparFtp+rM9cSxPznzdqgF/9DjYF9eIVJ+Sf8o+Qs7VacBKE+fyA +trrkuCZiQngKnSORqzvcEalcjBzMObkjhajU3pECgYA9tsxLCCKS/MCPPLoKg0mQ +dFlR5wlXA1PXvOQrKg2LXedhtyyRGoxz23+dsCAMO7LPPoPYAvygCCPiLvv1pdf8 +Iu3YB/LUMw7GNzy2JAa2tTSUmta6fpEwA5lxTyWL/SlRmqUR5e4YNLiFAdMrM6M/ +DwDIoMrnaONIW7ZYbMvYqA== +-----END PRIVATE KEY-----