From 73b7ffa308f9fe04db3a85377c12683fcb7ccdee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:04:12 +0000 Subject: [PATCH] php: a smoke test, and correct the PLAS stack write-up The port had run `php -v' and nothing else. A version string is printed before the engine evaluates an opcode, so it cannot tell an interpreter that works from one that computes nothing right -- and the first real program put on it, the Phoronix Test Suite, died with Hard memory limit of 2147483648 bytes exhausted (tried to allocate 4294971392 bytes) 4294971392 is 2**32 + 4096: a 64-bit quantity whose low half is sane and whose high half holds a 1, page-rounded by the allocator. The 2GB limit is Phoronix's own memory_limit, so only the second number is ours. The line it names is `display_text_table', printing the table of missing extensions that appears just above it, and that loop is $m = max($min, 1 + $extra + $widths[$c]) - strlen($cell); if ($m > 0) $line .= str_repeat(' ', $m); so `$m' came out near 2**32 and str_repeat was the victim. The suspect is the integer width: zend_long is 64 bits because this port defines __x86_64__, while kencc's long is 32 and php_config.h answers SIZEOF_LONG 4. Both are correct and they disagree. sys/lib/tests/php-smoke.php asks that in order -- PHP_INT_SIZE and PHP_INT_MAX, then strlen, then the exact padding expression, then str_repeat, then the whole failing loop reduced to the table php had printed -- and it has a real reference implementation, the build host's own php, with nothing newer than PHP 8.0 used. Its first host run found a fault in the test (expected padding 14, answer 15); 0 failures on 8.3.6 now. plas_stack_readme.md is rewritten against the tree. Three of its claims were wrong and one was inverted: * it describes a binary with an embedded SQLite engine. There is no PDO and no sqlite3 extension in this build; the Phoronix run says so itself. SQLite 3.53.4 IS ported as libsqlite.a and is in the default build, along with libxml2, libzip, zlib, curl, LibreSSL and oniguruma, so the extensions are mkfile work. * it claims 9P guarantees coherent locking, so several machines can write one database file safely. The opposite holds: lib/ap/fcntl/fcntl.c:180 answers EINVAL to F_GETLK, F_SETLK and F_SETLKW, and SQLite's default unix VFS takes byte-range locks on every write transaction. Answering EINVAL is the safe answer -- a lock stub returning success is how a database gets corrupted -- but it means the open question is whether a SINGLE writer works, and `sqlite3 /tmp/t.db 'create table t(a);'' settles that in one command. If it is the blocker, the remedy is SQLITE_ENABLE_LOCKING_STYLE and the dot-file VFS, which is at 0 in the vendored amalgamation and unset in lib/sqlite/mkfile. * static linking is presented as a property of the stack. Plan 9 has no dynamic linker at all, which is also why ZEND_EXTENSIONS_SUPPORT is 0, why dl() cannot work, and what caused the opcache shutdown crash fixed in the previous commit. * a forked namespace is not a restricted one; rfork gives a private copy, and the restricting is work the handler script must do. The CGI section is marked unverified, because this build is --disable-cgi, header() is a no-op under the CLI SAPI, and I cannot check 9front's rc-httpd layout from the source tree. The /net and /dev reading script -- the half that needs no extension at all -- is corrected and kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WGAwvvTwDg2yknFkmZ3qzs --- docs/notes/php.md | 53 ++++++ docs/notes/plas_stack_readme.md | 297 ++++++++++++++++++++++++++++++++ sys/lib/tests/php-smoke.php | 194 +++++++++++++++++++++ 3 files changed, 544 insertions(+) create mode 100644 docs/notes/plas_stack_readme.md create mode 100644 sys/lib/tests/php-smoke.php diff --git a/docs/notes/php.md b/docs/notes/php.md index 84ab9d04a..22cf4f185 100644 --- a/docs/notes/php.md +++ b/docs/notes/php.md @@ -746,6 +746,59 @@ general form is worth keeping**: a header-only helper that calls into a file the port does not compile is a link error here and silence on gcc. +### The first real program: Phoronix, and a 2**32 + +`php -v` works, `php -h` works, and **the port has still never been +measured evaluating anything** -- a version string is printed before +the engine runs an opcode. The first real program put on it, the +Phoronix Test Suite, said two things. + +**The expected one**: `MISSING DOM`, `MISSING ZIP`, `MISSING +SimpleXML`. That is `--disable-all` working as intended and is not a +fault; what it costs is written up in `plas_stack_readme.md`, together +with the fact that **libxml2, libzip, zlib, SQLite, curl, LibreSSL and +oniguruma are all already ported in `sys/src/ape/lib`** -- so every +one of those extensions is mkfile work against a library that exists. + +***THE OTHER ONE IS A REAL BUG AND THE NUMBER NAMES ITS SHAPE.*** + + Hard memory limit of 2147483648 bytes exhausted + (tried to allocate 4294971392 bytes) in pts_client.php on line 854 + +**4294971392 is 2**32 + 4096.** No program asks for that; it is a +64-bit quantity whose low half is sane and whose high half holds a 1, +page-rounded on the way through the allocator. The 2GB limit is +Phoronix's own `memory_limit`, so only the second number is ours. + +Line 854 is `pts_user_io::display_text_table($exts)` -- *printing the +very table of missing extensions that appears above it* -- and that +function's inner loop is six lines: + + $m = max($min, 1 + $extra + $widths[$c]) - strlen($cell); + if ($m > 0) $line .= str_repeat(' ', $m); + +So `$m` came out near 2**32 and `str_repeat` was the victim rather +than the culprit. **The fault is in `strlen` or in integer +arithmetic**, which is the first place to look anyway: `zend_long` is +chosen from `__x86_64__` and is 64 bits, while kencc's `long` is 32 +and `php_config.h` answers `SIZEOF_LONG 4`. *Both of those are +correct and they disagree with each other.* + +`sys/lib/tests/php-smoke.php` is the instrument that should have +existed before Phoronix was ever started: PHP_INT_SIZE and +PHP_INT_MAX first, then `strlen`, then the exact padding expression, +then `str_repeat`, then the whole failing loop reduced to the table +php had just printed. **It has a real reference implementation** -- +the build host's own php -- and nothing in it is newer than PHP 8.0, +so a case that fails on the host is the test's bug. *Its first host +run found one: my own expected padding was 14 and the answer is 15.* +0 failures on 8.3.6 now. + +**Also seen and not diagnosed**: `warning: process exceeds 100 file +descriptors`, twice, from the kernel. Phoronix walks directories, so +it may be ordinary; it may also be an `opendir` that never closes. +Recorded, unmeasured. + ### It links, it runs, and the first fault was opcache's `6.out --version` prints diff --git a/docs/notes/plas_stack_readme.md b/docs/notes/plas_stack_readme.md new file mode 100644 index 000000000..4f9549efe --- /dev/null +++ b/docs/notes/plas_stack_readme.md @@ -0,0 +1,297 @@ +# The PLAS stack: Plan 9, Listener, SQLite, PHP + +A web stack with no dynamic linker, no package manager and no database +server — PHP and SQLite on 9front, served by Plan 9's own listener. + +**STATUS: PART MEASURED, PART NOT, AND ONE PART IS KNOWN BROKEN.** The +architecture below is attractive and most of it is reachable. It does +not work today, and this file says which pieces are which, because a +guide that reads as instructions when it is a plan wastes the reader's +afternoon. Every claim marked **measured** was run; every one marked +**unmeasured** is a prediction with the command that would settle it. + +| piece | state | +|---|---| +| PHP 8.5.12 CLI binary | **measured**: builds, installs, runs, `php -v` exits 0 | +| SQLite 3.53.4 as `libsqlite.a` | **measured**: builds, in the default build | +| PHP *bound to* SQLite (PDO / sqlite3) | **absent**: no extension is compiled in | +| DOM, SimpleXML, ZIP | **absent**: same reason | +| POSIX file locking under SQLite | **BROKEN**: `fcntl` answers `EINVAL` | +| rc-httpd / listener wiring | **unmeasured** | +| per-request namespace isolation | **overstated** — see §4 | + +--- + +## 1. What exists today + +`php -v` on 9front: + +``` +PHP 8.5.12 (cli) (built: Nov 5 2026 16:46:25) (NTS) +Copyright (c) The PHP Group +Zend Engine v4.5.12, Copyright (c) Zend Technologies +``` + +That is a real interpreter: 515 translation units, the Zend engine, +opcache, pcre2, the standard library, date, hash, json, lexbor, +random, reflection, spl and uri. It is built `--disable-all`, which in +PHP 8.5 still leaves eleven extensions because they are +unconditional. + +**What `--disable-all` leaves out is everything this stack needs.** +Running the Phoronix Test Suite under it says so in its own words: + +``` +MISSING DOM REQUIRED - The Document Object Model is required for XML operations. +MISSING ZIP REQUIRED - ZIP support is required for file (de)compression. +MISSING SimpleXML REQUIRED - SimpleXML is required for XML operations. +``` + +So *the binary is finished and the build is not*. There is no `PDO`, +no `sqlite3` driver, and `new PDO('sqlite:...')` in a script would +throw before touching a database. + +### The good news: the libraries are already here + +`sys/src/ape/lib` already carries ports of everything the missing +extensions link against, which is the hard half: + +| extension | library it needs | in this tree? | +|---|---|---| +| `sqlite3`, `pdo_sqlite` | SQLite 3.53.4 | **yes** — `lib/sqlite`, and a `cmd/sqlite` shell | +| `dom`, `simplexml`, `xml` | libxml2 | **yes** — `lib/xml2` | +| `zip` | libzip + zlib | **yes** — `lib/zip`, `lib/z` | +| `curl` | libcurl | **yes** — `lib/curl` | +| `openssl` | LibreSSL | **yes** — `lib/libressl` | +| `mbstring` | oniguruma | **yes** — `lib/onig` | + +Adding one is three edits to `sys/src/ape/cmd/php/mkfile` and its +`cfg/`: the extension's objects into `OFILES`, its `phpext_*_ptr` into +`internal_functions_cli.c`, and `HAVE_*` into `cfg/main/php_config.h`. +*It has not been done, so none of it is measured.* + +--- + +## 2. The database layer has a blocker underneath it + +***THE DOCUMENT THIS REPLACES CLAIMED THE OPPOSITE OF THE TRUTH HERE, +AND IT IS THE CLAIM MOST WORTH GETTING RIGHT***, because believing it +is how someone ends up with a corrupted database rather than an error +message. + +The claim was: *"SQLite is traditionally bound to local contexts due to +POSIX network file-locking vulnerabilities. Plan 9's native 9P protocol +guarantees coherent file locking and atomic transactions, allowing +multiple terminal nodes to safely write to a single centralized +.sqlite file."* + +**9P has no byte-range locking, and APExp has none either.** +`sys/src/ape/lib/ap/fcntl/fcntl.c:180` is: + +```c +case F_GETLK: +case F_SETLK: +case F_SETLKW: + err = EINVAL; + break; +``` + +SQLite's default unix VFS takes a SHARED lock to read and escalates +through RESERVED to EXCLUSIVE to write, **all with `fcntl` byte-range +locks**, on every transaction. With `EINVAL` coming back, those calls +fail. Plan 9's own exclusive-use bit (`DMEXCL`) is whole-file and is +not what SQLite asks for. + +*Answering `EINVAL` is the right answer* — a lock stub that returned +success would let two writers both believe they held the file, which +is the corruption the original text promised to prevent. But it means +the question is not "can several machines share a database over 9P", +it is **"does a single process get a write transaction at all"**, and +that has never been run. + +**The measurement is one command** and needs nothing built: + +```rc +sqlite3 /tmp/t.db 'create table t(a); insert into t values(1); select * from t;' +``` + +- prints `1` → the write path works, and the open question is only + concurrency. +- an `SQLITE_IOERR`/`disk I/O error` → locking is the blocker, as + predicted here. + +**If it is the blocker, the remedy is known and is a build flag, not +new code.** SQLite ships alternative locking styles — dot-file locking +in particular, which needs only `create`/`remove` and therefore works +over 9P. They are compiled only under `SQLITE_ENABLE_LOCKING_STYLE`, +and the amalgamation in this tree has it at **0** +(`sys/src/external/sqlite/sqlite3.c:169`), while +`lib/sqlite/mkfile` passes no `-DSQLITE_*` at all. Turning it on and +opening with the `unix-dotfile` VFS is the first thing to try; a +Plan 9 VFS under `SQLITE_OS_OTHER` is the thorough version. + +--- + +## 3. What "statically linked" does and does not buy + +The stack is statically linked, and **that is not a property of this +port — Plan 9 has no dynamic linker at all.** Every Plan 9 binary is +static. So: + +- The "no `glibc`/`musl` disparity, no shared-library conflicts" point + is true, and it is true of every program on the system. +- The same fact removes capability. **PHP cannot load an extension at + run time here**: `dl()` and `extension=` in `php.ini` do nothing, + because `zend_portability.h:200` sets `ZEND_EXTENSIONS_SUPPORT` to 0 + when there is no `dlopen`. Every extension must be compiled in. +- *That same line caused the first crash this port had*: opcache's + module shutdown destroyed globals whose constructor — reached only + through the zend-extension startup that is compiled out — had never + run. See `docs/notes/php.md`. + +"Deployment is copying two files" is fair, with the caveat that the +binary is around the size of everything it statically contains. + +--- + +## 4. The namespace claim, narrowed + +The original said Plan 9 "forks a localized namespace for incoming +requests", so a compromised application is "isolated within a +transient, severely restricted process space". + +**A new namespace is not a restricted one.** `rfork n` gives the child +a *copy* of the parent's namespace — the same files, now privately +mutable. It becomes a sandbox only when the service script then does +the restricting itself: unmounting what it does not need, binding a +subtree over `/`, dropping the network directory. Plan 9 makes that +unusually easy and unusually cheap, and it is a genuine advantage over +a Unix CGI — but it is work the handler script has to do, and none of +the scripts below do it. + +The honest version: *Plan 9 gives you the cheapest per-request sandbox +of any system here, and you still have to build it.* + +--- + +## 5. Serving it — unverified, and flagged as such + +I have not run a web server on this system, and the 9front specifics +below are the part of this document I am least able to check from the +source tree. Treat §5 as a sketch to verify, not a recipe. + +**What I can state:** + +- APExp **does not install into `/`**. `mk install` puts binaries in + `$APEXPROOT/$objtype/bin/ape`, and `apexp-sh` union-mounts them over + `/bin`. So `cp /sys/src/cmd/php/php ...` is wrong; inside `apexp-sh` + the binary is just `php`, and outside it the path is + `/amd64/bin/ape/php`. +- **This build has no CGI SAPI.** It is configured `--disable-cgi`, so + there is no `php-cgi` binary. Under the **CLI** SAPI `header()` is a + no-op: PHP does not emit HTTP headers, so a CGI bridge must print + the status line and headers itself before running the script — which + is what the wrapper below does, and why the example scripts in §6 + must not rely on `header()`. +- `aux/listen1 tcp!*!8080 ` hands the program a *raw connection* + on its standard input and output. It does not set the CGI + environment, so a program behind `listen1` has to read and parse the + HTTP request itself. Using rc-httpd instead is what gets you CGI + variables. +- **Which 9front HTTP server and how it is configured, I did not + verify.** 9front ships `rc-httpd`, a set of rc scripts, and it is + configured by editing its own handler-selection script rather than + by an `httpd.conf`; the `cgi .php ...` line and `ip/httpd -h` in the + original look like the older Plan 9 `ip/httpd`, and may not apply. + **Check `/rc/bin/rc-httpd/` on the machine before copying anything + from here.** + +A CLI-SAPI bridge, written to the constraints above: + +```rc +#!/bin/rc +# php-cgi -- run a .php file under the CLI SAPI and supply the headers +# ourselves, because the CLI SAPI emits none. + +if(! test -f $PATH_TRANSLATED){ + echo 'Status: 404 Not Found' + echo 'Content-Type: text/plain' + echo + echo 'not found' + exit +} +echo 'Content-Type: text/html; charset=utf-8' +echo +exec php -f $PATH_TRANSLATED +``` + +*`rc` note*: `echo` with no argument prints the blank line that ends +the headers. Do not write `key=value` arguments anywhere in an rc +script — `=` is a syntax character and is legal only as a leading +assignment. + +--- + +## 6. The example scripts, corrected + +### 6.1 Reading Plan 9's synthetic files — the part that already works + +This is the genuinely interesting half, it needs **no extension at +all**, and every function in it is in the build we have. + +```php + 'PLAS', 'metrics' => []]; + +if (is_readable('/dev/sysstat')) { + $out['metrics']['sysstat'] = preg_split('/\s+/', trim(file_get_contents('/dev/sysstat'))); +} + +$conns = []; +foreach (glob('/net/tcp/[0-9]*') as $dir) { + $status = @file_get_contents($dir . '/status'); + if ($status !== false && trim($status) !== '') { + $conns[] = ['id' => basename($dir), 'status' => trim($status)]; + } +} +$out['tcp'] = ['count' => count($conns), 'connections' => $conns]; + +echo json_encode($out, JSON_PRETTY_PRINT), "\n"; +``` + +Changed from the original: `glob('/net/tcp/[0-9]*')` rather than `*`, +because `/net/tcp` also holds `clone` and `stats`, which are not +connection directories; `is_readable` rather than `file_exists`, +because a synthetic file can exist and refuse to be read; and no +`header()`, which does nothing under the CLI SAPI. + +**Note for anyone extending this**: reading files under `/net` and +`/proc` is the whole appeal of the idea, and it is also where a long +run will meet Plan 9's `warning: process exceeds 100 file descriptors` +— seen already in the Phoronix run. Close what you open. + +### 6.2 The SQLite script — does not run yet + +The original's `new PDO('sqlite:...')` needs `pdo_sqlite`, which is +not compiled in (§1), and would then need write locking, which is +`EINVAL` (§2). It is kept here as the target rather than as an +instruction, and it is worth attempting only after both of those are +dealt with, in that order. + +--- + +## 7. The shortest path from here + +1. `sqlite3 /tmp/t.db 'create table t(a);'` — does the database layer + work at all? One command, nothing to build. **Do this first**: if it + fails, everything downstream is blocked on one build flag. +2. Add `ext/sqlite3` and `ext/pdo_sqlite` to `cmd/php/mkfile` against + the `libsqlite.a` already in the tree. +3. Add `ext/dom`, `ext/simplexml` and `ext/zip` against `libxml2` and + `libzip` — which is also what makes Phoronix runnable, and would + make it a real test of the port rather than of its extension list. +4. Only then the server wiring, and verify §5 against the machine + rather than against this file. + +*The order matters: steps 2 and 3 are mkfile work with known inputs, +and step 1 can invalidate step 2 before it is started.* diff --git a/sys/lib/tests/php-smoke.php b/sys/lib/tests/php-smoke.php new file mode 100644 index 000000000..d41624e79 --- /dev/null +++ b/sys/lib/tests/php-smoke.php @@ -0,0 +1,194 @@ + $colw[$c]) { + $colw[$c] = strlen($table[$r][$c]); + } + } +} +eq("column widths", $colw, [11, 8, 26]); +$out = ''; +$maxpad = 0; +for ($r = 0; $r < count($table); $r++) { + for ($c = 0; $c < count($table[$r]); $c++) { + $out .= $table[$r][$c]; + $m = max(0, 1 + 0 + $colw[$c]) - strlen($table[$r][$c]); + if ($m > $maxpad) $maxpad = $m; + if ($m > 0) $out .= str_repeat(' ', $m); + } + $out .= "\n"; +} +eq("the largest padding the loop asked for", $maxpad, 15); +eq("the table rendered to a sane length", strlen($out) < 200, true); + +/* ---------------------------------------------------------------- */ +echo "\nSection 6: the rest of the language, briefly\n"; +eq("array count", count([1, 2, 3]), 3); +eq("sort", (function () { $a = [3, 1, 2]; sort($a); return $a; })(), [1, 2, 3]); +eq("foreach sums", (function () { $s = 0; foreach ([1, 2, 3] as $v) $s += $v; return $s; })(), 6); +eq("sprintf %d", sprintf("%d", 42), "42"); +eq("sprintf %s width", sprintf("[%-6s]", "ab"), "[ab ]"); +eq("sprintf a wide int", sprintf("%d", 4294971392), "4294971392"); +eq("substr", substr("abcdef", 2, 3), "cde"); +eq("string offset", "abcdef"[2], "c"); +eq("float arithmetic", 0.1 + 0.2 > 0.29999 && 0.1 + 0.2 < 0.30001, true); +eq("intdiv", intdiv(7, 2), 3); +eq("string to int", (int)"123", 123); +eq("associative array", (function () { $a = []; $a['k'] = 'v'; return $a['k']; })(), "v"); +eq("preg_match (pcre2 is linked in)", preg_match('/^M(I)SSING$/', "MISSING", $m), 1); +eq("preg capture", $m[1], "I"); +eq("json_encode", json_encode(['a' => 1]), '{"a":1}'); +eq("json_decode round trip", json_decode('{"a":1}', true), ['a' => 1]); + +/* ---------------------------------------------------------------- */ +echo "\nSection 7: the platform's own files, which are the point of the port\n"; +/* + * Not a language rule, so these are reported and only the TYPE is + * asserted: a Plan 9 machine has /net and a Linux build host does + * not, and the test must pass on both. + */ +$net = @file_get_contents('/net/tcp/stats'); +if ($net === false) { + echo " INFO /net/tcp/stats unreadable (expected off Plan 9)\n"; +} else { + echo " INFO /net/tcp/stats: " . strlen($net) . " bytes\n"; + eq("a synthetic file's length is sane", strlen($net) < 65536, true); +} +eq("getcwd returns a string", is_string(getcwd()), true); +eq("PHP_OS is a string", is_string(PHP_OS), true); +echo " INFO PHP_OS = " . PHP_OS . ", PHP_VERSION = " . PHP_VERSION . "\n"; + +printf("\nphp-smoke: %d failures\n", $fails); +exit($fails);