Release: generate the extension load list instead of hardcoding it (Phase 2.5) - #24418
Merged
Conversation
…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
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>
This was referenced Jul 28, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Next Phase 2.5 increment, after the declarations in #24387.
What changes
Runtime::loadCoreModules()carried 76 hardcodednew ext\X\Modulecalls, so adding anextension 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, andRuntimeiterates 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:
test/unit/ExtensionRegistryOrderTest.phpfreezes the first 25 entries against a literal list, soa regeneration or hand edit that reorders anything fails and names the position.
Ordering here is load-bearing (
libxml→dom→xsl) and only partly declared viaModule::getExtensionDependencies(), so deriving a fresh order from dependencies remains aseparate step that has to be proven equivalent first.
Two constraints worth knowing about
Literal
newexpressions, not dynamic instantiation. The AOT compiler resolvesnew \PHPCompiler\ext\spl\Module()statically. Anew $classNameloop would leave every moduleunreferenced and uncompiled — the registry would load nothing into an AOT binary.
--checkmode.php script/generate-extension-registry.php --checkfails when the committedfile 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,msgpackand others expose their functions as part ofext/standardinphp-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.phpandscript/extension-inventory.phpboth parsedRuntime::loadCoreModules(). After this change that method has no list to parse — the inventoryreported 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
Runtimefor 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:
aot-smoke--repeat 2--repeat 3Plus
ExtensionRegistryOrderTest(2 tests, 231 assertions),check-extension-dependenciesok,generate-extension-registry --checkok.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.