Skip to content

Release: generate the extension load list instead of hardcoding it (Phase 2.5) - #24418

Merged
PurHur merged 1 commit into
masterfrom
release/extension-registry
Jul 28, 2026
Merged

Release: generate the extension load list instead of hardcoding it (Phase 2.5)#24418
PurHur merged 1 commit into
masterfrom
release/extension-registry

Conversation

@PurHur

@PurHur PurHur commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Next Phase 2.5 increment, after the declarations in #24387.

What changes

Runtime::loadCoreModules() carried 76 hardcoded new ext\X\Module calls, so adding an
extension meant editing a core file — the opposite of the plan's "a new extension is a directory,
not a core edit"
. The list now lives in generated lib/ExtensionRegistry.php, and Runtime
iterates it.

Order is unchanged, and that is checked three ways

This is the safety argument for the whole refactor, so it is not left to inspection:

  1. the generator copies the order verbatim from the hardcoded list;
  2. verified identical at generation time — 76 == 76, same sequence;
  3. test/unit/ExtensionRegistryOrderTest.php freezes the first 25 entries against a literal list, so
    a regeneration or hand edit that reorders anything fails and names the position.

Ordering here is load-bearing (libxmldomxsl) and only partly declared via
Module::getExtensionDependencies(), so deriving a fresh order from dependencies remains a
separate step that has to be proven equivalent first.

Two constraints worth knowing about

Literal new expressions, not dynamic instantiation. The AOT compiler resolves
new \PHPCompiler\ext\spl\Module() statically. A new $className loop would leave every module
unreferenced and uncompiled — the registry would load nothing into an AOT binary.

--check mode. php script/generate-extension-registry.php --check fails when the committed
file is out of date, so the generated file cannot drift from its generator.

A finding from writing the test

20 modules deliberately report getExtensionName() === 'standard'zip, intl, gd,
bcmath, bz2, ftp, msgpack and others expose their functions as part of ext/standard in
php-src terms.

So the reported extension name is not unique and cannot identify a registry entry. The ext/
directory can. The test and both tools now key on the directory, and anything that later selects
extensions per build will need the same distinction — it would otherwise silently conflate 20
modules into one.

Tooling that would have gone blind

script/check-extension-dependencies.php and script/extension-inventory.php both parsed
Runtime::loadCoreModules(). After this change that method has no list to parse — the inventory
reported 0 loaded and all 76 "not loaded", which is exactly the kind of quiet breakage that makes
a check useless. Both now read the registry, with a fallback to Runtime for pre-registry trees.

Verified after the fix: 76 loaded, no phantoms, no unloaded directories — identical to before.

Gate

Branch vs a clean master checkout, sequential, both cold, each sweep allowed to complete
before diffing:

branch master
cold aot-smoke 8/8 8/8
VM --repeat 2 110/110, exit 0 110/110, exit 0
AOT --repeat 3 13 failing 13 failing
regressions (branch-only): NONE
fixed (master-only)      : NONE

Plus ExtensionRegistryOrderTest (2 tests, 231 assertions), check-extension-dependencies ok,
generate-extension-registry --check ok.

Still not selectable

Every module loads, isDefaultEnabled() is true everywhere, and the registry is the default set.
Filtering on it is the next step — this change only moves the list somewhere it can be generated and
checked.

…hase 2.5)

Runtime::loadCoreModules() carried 76 hardcoded `new ext\X\Module` calls, so adding an extension
meant editing a core file — the opposite of Phase 2.5's "a new extension is a directory, not a core
edit". The list now lives in generated lib/ExtensionRegistry.php and Runtime iterates it.

Order is UNCHANGED. That is the safety argument for the refactor, and it is checked three ways:

  - the generator copies the order verbatim from the hardcoded list;
  - verified identical at generation time (76 == 76, same sequence);
  - test/unit/ExtensionRegistryOrderTest.php freezes the first 25 entries against a literal list, so
    a regeneration or hand edit that reorders anything fails and names the position.

Ordering here is load-bearing (libxml before dom before xsl) and only partly declared via
Module::getExtensionDependencies(), so deriving a fresh order from dependencies stays a separate
step that must be proven equivalent first.

Two deliberate constraints in the generator:

  - it emits literal `new \PHPCompiler\ext\<name>\Module()` expressions, NOT dynamic instantiation
    from strings. The AOT compiler resolves these statically; `new $className` would leave every
    module unreferenced and uncompiled.
  - `--check` fails when the generated file is out of date, so the file cannot drift from the
    generator.

Found while writing the test: 20 modules deliberately report getExtensionName() === 'standard'
(zip, intl, gd, bcmath, bz2, ftp, msgpack and others expose their functions as part of ext/standard
in php-src terms). So the reported name is not unique and cannot identify a registry entry — the
ext/ directory can, and both the test and the tooling now key on that. Anything selecting extensions
per build will need the same distinction.

script/check-extension-dependencies.php and script/extension-inventory.php parsed
Runtime::loadCoreModules() and would have gone silently blind; both now read the registry, with a
fallback to Runtime for pre-registry trees. Verified: inventory reports 76 loaded, no phantoms, no
unloaded directories — same as before the change.

Gate, branch vs a clean master checkout, sequential, both cold, each sweep allowed to COMPLETE
before diffing:

  cold aot-smoke : 8/8 both sides
  VM  --repeat 2 : 110/110 both sides, exit 0
  AOT --repeat 3 : master 13 failing, branch 13 failing
                   regressions (branch-only): NONE
                   fixed (master-only)      : NONE

Nothing is selectable yet: every module still loads, isDefaultEnabled() is true everywhere, and the
registry is the whole default set. Filtering on it is the next step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PurHur
PurHur merged commit ffc48fd into master Jul 28, 2026
PurHur pushed a commit that referenced this pull request Jul 28, 2026
…ceptance test

Adds --only= / --without= to script/generate-extension-registry.php, with a dependency closure so a
selected extension never silently loses one (--only=standard,spl,dom keeps libxml automatically, and
says so). Identity is the ext/ DIRECTORY, never getExtensionName() — 20 modules report 'standard'.

Selection is at GENERATION time, not runtime, and that is forced by the architecture: the registry
emits literal `new` expressions which the AOT compiler resolves statically, so a referenced module is
compiled in regardless of any runtime filter. Dropping the cost means dropping the reference.

Then ran the acceptance test the plan names — "a script that never calls into an extension pays
nothing for it" — instead of assuming it:

  registry            hello-world binary   cold build
  all 76 extensions   17,148,584 B         6 s
  6 extensions        17,148,944 B         6 s     <- 360 bytes LARGER, i.e. no change

Two measurements explain it:

  - nm finds ZERO curl/mongodb/snmp/ldap symbols in a hello-world binary built with the full
    76-extension registry. Unused extensions are not linked in at all.
  - a script that DOES call an extension grows the binary ~572 KB (bcadd), so demand-driven linking
    already works.

The binary is dominated by .text at ~15 MB — runtime and compiler core, not extensions.

CONSEQUENCE, recorded in the plan: Phase 2.5 must not be justified by user binary size or user build
time, because that benefit already exists. What survives is (1) extensibility — a new extension is a
directory, not a core edit, delivered by the generated registry in #24418 — and (2) the compiler's
OWN build cost, the 6,519-file spine as a single translation unit, which is the multi-hour gen-0
problem the per-module TU split addresses.

This matters because the plan sized this phase at "37% fewer files" and implied a proportional win
for users. Measured, that win is not there to collect, and weeks aimed at it would have produced no
user-visible change.

No behaviour change: lib/ExtensionRegistry.php is regenerated identical (76 extensions,
--check ok), check-extension-dependencies ok, ExtensionRegistryOrderTest 2 tests / 231 assertions ok.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PurHur added a commit that referenced this pull request Jul 28, 2026
…ceptance test (#24421)

Adds --only= / --without= to script/generate-extension-registry.php, with a dependency closure so a
selected extension never silently loses one (--only=standard,spl,dom keeps libxml automatically, and
says so). Identity is the ext/ DIRECTORY, never getExtensionName() — 20 modules report 'standard'.

Selection is at GENERATION time, not runtime, and that is forced by the architecture: the registry
emits literal `new` expressions which the AOT compiler resolves statically, so a referenced module is
compiled in regardless of any runtime filter. Dropping the cost means dropping the reference.

Then ran the acceptance test the plan names — "a script that never calls into an extension pays
nothing for it" — instead of assuming it:

  registry            hello-world binary   cold build
  all 76 extensions   17,148,584 B         6 s
  6 extensions        17,148,944 B         6 s     <- 360 bytes LARGER, i.e. no change

Two measurements explain it:

  - nm finds ZERO curl/mongodb/snmp/ldap symbols in a hello-world binary built with the full
    76-extension registry. Unused extensions are not linked in at all.
  - a script that DOES call an extension grows the binary ~572 KB (bcadd), so demand-driven linking
    already works.

The binary is dominated by .text at ~15 MB — runtime and compiler core, not extensions.

CONSEQUENCE, recorded in the plan: Phase 2.5 must not be justified by user binary size or user build
time, because that benefit already exists. What survives is (1) extensibility — a new extension is a
directory, not a core edit, delivered by the generated registry in #24418 — and (2) the compiler's
OWN build cost, the 6,519-file spine as a single translation unit, which is the multi-hour gen-0
problem the per-module TU split addresses.

This matters because the plan sized this phase at "37% fewer files" and implied a proportional win
for users. Measured, that win is not there to collect, and weeks aimed at it would have produced no
user-visible change.

No behaviour change: lib/ExtensionRegistry.php is regenerated identical (76 extensions,
--check ok), check-extension-dependencies ok, ExtensionRegistryOrderTest 2 tests / 231 assertions ok.

Co-authored-by: PurHur <tedyyyyy@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
PurHur added a commit that referenced this pull request Jul 28, 2026
…ry in spine. (#24442)

Unblocks release-readiness Pillar 1 — helper-runtime-prelink --strict and
north-star5-verify-fast were red after DateTimeFormat/RangeInt/Strtotime
fingerprint drift and the post-#24418 ExtensionRegistry inventory file.

Co-authored-by: PurHur <PurHur@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@PurHur
PurHur deleted the release/extension-registry branch July 29, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants