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-----