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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1202,8 +1202,31 @@ silent:
- **itcl** — done. 10 failures, all upstream or Tcl 9's, two settled at
source, none ours.

**php — ***ALL 514 TRANSLATION UNITS COMPILE***, measured on the HOST,
and the notes are in `docs/notes/php.md`.** 8.5.12, vendored at
**php — ***IT LINKS AND IT RUNS***: `PHP 8.5.12 (cli) (NTS)`, Zend
Engine v4.5.12, printed by the binary on the VM. Notes in
`docs/notes/php.md`.** **`php -v` then FAULTED at exit and the cause
is a platform consequence**: `ZEND_EXTENSIONS_SUPPORT` is 0 without
dlopen (`zend_portability.h:200`), so `zend_extension_startup`'s body
is compiled out and **no zend_extension's startup ever runs here** --
while opcache's MSHUTDOWN is an ordinary MODULE shutdown, gated by
nothing, and destroys globals its ctor never built:
`zend_string_free(NULL)`, `GC_FLAGS` reading offset 4, which is what
`fault read addr=0x4` said. Guarded, as the next line of the same
function and `zend_accel_blacklist_shutdown` already are; upstream's,
and the second upstream php bug this campaign.
***THE DIAGNOSIS COST ONE COMMAND AND NO REBUILD***: `php -h` returns
before `module_started`/`sapi_started` are set (`php_cli.c:1318`
against `:1337`/`:1359`), so it runs `exit()` and neither shutdown --
it exits clean, which cleared libap's teardown and left two functions
standing where nine calls had been candidates. Then `acid 6.out` and
`src(pc)` named `zend_string.h:401` outright. *A CLI with an early
exit path is a bisect someone else already wrote.*
**Also `mk install` could not copy the binary**: `mkone`'s rule is
`cp $prereq $BIN/$TARG` and nothing set `BIN`, which in rc is a null
list in a concatenation rather than an empty string -- every other
APE command mkfile assigns it, and nothing could notice until a link
succeeded.
**ALL 515 TRANSLATION UNITS COMPILE**, measured on the HOST. 8.5.12, vendored at
`sys/src/external/php`; `--compile` answers `compiled 515, errors 0,
missing 0` and **`--undefined` says the program wants 283 symbols
from outside php, every one of which libap has** (273 the sweep
Expand Down
72 changes: 72 additions & 0 deletions docs/notes/php.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,78 @@ 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.

### It links, it runs, and the first fault was opcache's

`6.out --version` prints

PHP 8.5.12 (cli) (built: Nov 5 2026 16:19:35) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.5.12, Copyright (c) Zend Technologies

and then dies: `suicide: sys: trap: fault read addr=0x4 pc=0x7ef0f7`.

**Two things were wrong and they are unrelated.** `mk install` could
not copy the binary at all -- `null list in concatenation`, because
nothing set `BIN` and `mkone`'s rule is `cp $prereq $BIN/$TARG`; the
mkfile now assigns it like every other APE command's. The fault is
below.

***THE DIAGNOSIS COST ONE COMMAND AND NO REBUILD, BECAUSE THE CLI HAS
AN EXIT PATH THAT SKIPS THE SHUTDOWNS.*** `php_cli.c:1318`'s `-h`
arm does `php_cli_usage(); goto out;` while `module_started` and
`sapi_started` are still 0 -- they are set at `:1337` and `:1359`,
after `exit_loop` -- so `-h` runs `exit()` and neither
`php_module_shutdown()` nor `sapi_shutdown()`. **`./6.out -h` exits
clean**, which cleared libap's `exit`, the stdio teardown and
`php_ini_builder_deinit` in one line and left two functions standing.
*Two outcomes, one command, no acid and no rebuild* -- and the
alternative was nine candidate calls to read.

Then `acid 6.out` and `src(0x7ef0f7)`:

Zend/zend_string.h:401
>401 if (!ZSTR_IS_INTERNED(s)) {

`zend_string_free(NULL)`. **The address agrees with the source**:
`zend_refcounted_h` is `{uint32_t refcount; union {uint32_t
type_info;} u;}`, so `GC_FLAGS` reads **offset 4**, and `addr=0x4` is
a null `zend_string *`. *`zend_string_free` is `zend_always_inline`
and kencc does not inline*, so it is a real static function in every
TU that uses it -- which is why there was a pc to resolve at all.

**The caller is `accel_globals_dtor`** (`ZendAccelerator.c:2974`),
found by listing every `zend_string_free` call site in php -- 91 of
them -- and keeping the ones whose enclosing function name looks like
teardown. Exactly one did.

***AND THE MECHANISM IS A PLATFORM CONSEQUENCE, NOT AN ACCIDENT.***
`accel_globals->key` is allocated by `accel_globals_ctor`, whose only
caller is `accel_startup` -- the **zend_extension's** startup. And
`zend_extensions.c:194`'s `zend_extension_startup` has its entire
body inside `#if ZEND_EXTENSIONS_SUPPORT`, which
`zend_portability.h:191-200` sets to 1 for dlopen and for Windows and
**0 otherwise**. Plan 9 has no dlopen, so no zend_extension's startup
ever runs here. opcache's **MSHUTDOWN is an ordinary MODULE
shutdown**, gated by nothing, and calls `accel_shutdown`, which takes
its `!ZCG(enabled) || !accel_startup_ok` arm and destroys the globals
anyway. *A platform that constructs nothing destroys regardless.*

Guarded, which is what the **next statement in the same function**
already does for `preloaded_internal_run_time_cache` and what
`zend_accel_blacklist_shutdown` opens with four lines earlier in the
same call. **Upstream's, and the second upstream php bug this
campaign** after the `zend_atomic.h` typo; both are worth reporting.

*The rest of that path was checked for the same shape rather than
left*: `zend_jit_shutdown` is `#ifdef HAVE_JIT` and we answer no, the
blacklist has its own guard, and `UNREGISTER_INI_ENTRIES` over
entries that were never registered walks an empty set.

**Prediction, written down before the run**: `php -v` now exits 0.
**What would refute it**: a fault at a different address or line --
which would be the next member of the same family, since every
zend_extension on this platform is constructed by nobody.

### The eight, and what each turned out to be

Three were kencc's, three were APE headers', one was php's and one was
Expand Down
24 changes: 24 additions & 0 deletions sys/src/ape/cmd/php/mkfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@
APEXPROOT=../../../../..
TARG=php

# ***`mkone's INSTALL RULE IS `cp $prereq $BIN/$TARG' AND NOTHING HERE
# SET `BIN'.*** rc answered
#
# /rc/lib/rcmain:24 *eval*:1: null list in concatenation
# mk: cp 6.out $BIN/php : exit status=rc 57534: error
#
# -- `$BIN/php' is a CONCATENATION in rc, and concatenating an empty
# list with a string is an error rather than an empty result. *The
# message names the shell and the line inside `rcmain', not the
# mkfile*, which is the "mk names the wrong line" shape a third time.
#
# `sys/src/ape/config' supplies `APEBIN' and not `BIN', so **every
# other APE command mkfile assigns this itself** -- `cmd/bzip2',
# `cmd/m4' and the rest all carry the same line. This file includes
# `</$objtype/mkfile' directly rather than `<$APE/config', and set
# `CC' and `LD' by hand, so the one variable that only `mkone' reads
# was the one nobody noticed was missing. *It had to get as far as a
# successful LINK before anything could.*
#
# It must come before the `<mkone' include below, because `install:V:
# $BIN/$TARG' is a PREREQUISITE and so is expanded when the file is
# parsed, not when the recipe runs.
BIN=$APEXPROOT/$objtype/bin/ape

PHPSRC=../../../external/php

# ***THE OBJECT LIST IS DERIVED FROM UPSTREAM'S OWN CONFIGURE, NOT
Expand Down
29 changes: 28 additions & 1 deletion sys/src/external/php/ext/opcache/ZendAccelerator.c
Original file line number Diff line number Diff line change
Expand Up @@ -2971,7 +2971,34 @@ static void accel_globals_ctor(zend_accel_globals *accel_globals)

static void accel_globals_dtor(zend_accel_globals *accel_globals)
{
zend_string_free(accel_globals->key);
/*
* APExp: `key' is allocated by `accel_globals_ctor', which is
* called ONLY from `accel_startup' -- the zend_extension's
* startup -- and `zend_extension_startup' in
* `Zend/zend_extensions.c:194' has its whole body inside
* `#if ZEND_EXTENSIONS_SUPPORT'. That macro is 1 for dlopen and
* for Windows and **0 otherwise** (`zend_portability.h:200'), and
* Plan 9 has no dlopen -- so `accel_startup' never runs.
*
* opcache's MSHUTDOWN is an ordinary MODULE shutdown and is not
* gated by anything, so it still calls `accel_shutdown', which
* takes its `!ZCG(enabled) || !accel_startup_ok' arm and calls
* this function. **A platform that constructs nothing destroys
* anyway**, and `zend_string_free' requires a non-NULL argument:
*
* zend_string.h:401 if (!ZSTR_IS_INTERNED(s))
* 6.out: suicide: sys: trap: fault read addr=0x4
*
* -- `GC_FLAGS' reading `gc.u.type_info', which is at offset 4.
* Every `php -v' died this way, after printing the version.
*
* The guard is the one the next statement already has, and the
* one `zend_accel_blacklist_shutdown' opens with four lines
* earlier in the same call. Upstream.
*/
if (accel_globals->key) {
zend_string_free(accel_globals->key);
}
if (accel_globals->preloaded_internal_run_time_cache) {
pefree(accel_globals->preloaded_internal_run_time_cache, 1);
}
Expand Down
Loading