Repository navigation
fix(codegen): rebuild closure materializations when regenerating a closure - #152
Merged
matyhtf merged 1 commit intoOct 8, 2026
Merged
Conversation
…osure An expression may be lowered several times: an outer ?? re-parses its left operand, parseChainedExpr() warms the operand up, and the chain walk lowers it again. Every lowering regenerates each closure the expression contains, and every generated php::ClosureFn is delivered to the enclosing function, so the discarded copies end up in the output too. parseValueSelection() marks a materialized node with the "replace" attribute so a later parse inside the same context reuses the existing temporary instead of emitting the statement twice; within one lambda body that deduplication is correct. Inside a regenerated closure, however, the temporary recorded by the previous generation lives in the previous, discarded lambda body. Honouring the stale attribute made the live copy assign that never-initialized temporary, so ($m[1] ?? '') !== '' compared null against '' and the guard was always true: the wrong branch ran and captured match groups silently vanished from the output. The isset() spelling kept working because it inlines php::exists() without a replace attribute. Drop the replace shortcuts from the closure subtree on every generation so each copy rebuilds its own materialization.
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.
What
Fixes Bug A:
??inside a closure was compiled to a constant guard — the generated C++ never read the captured value, so branches silently inverted at runtime.When an outer expression (e.g.
preg_replace_callback(...) ?? $text) re-parses an operand containing a closure, the closure body is lowered multiple times. Each lowering marks inner??nodes with areplaceattribute so repeated parses in the same emission context reuse the same temp instead of re-emitting the materialization. But generations 2+ of a closure are fresh C++ lambda contexts: they reused generation 1's temp name without ever emitting its assignment. The live copy (wired at the call site) then compared an uninitialized temp (null) against'', the guard inverted, and$m[1]was never read.Fix: clear
replaceattributes from the closure subtree atdoGenClosureentry, so every generation re-lowers its own materializations. Same-context dedup outside closures is preserved (blindly clearing everywhere would re-execute side-effecting left operands likef() ?? x).Repro
$a(??)$b(isset()control)string(23) "[ONE#X] <THREE#-mid-> Z"string(16) "[ONE#X] [ONE#] Z"❌string(23) "[ONE#X] <THREE#-mid-> Z"✅Pre-fix codegen: closure emitted 3×; only generation 1 contained
tmp = php::exists(m, {1}, ...) ? ... : '', while the live generation 3contained
tmp_var_0 = tmp_var_1;withtmp_var_1never assigned.isset()is immune because it inlinesphp::exists(...)in every generation.Trigger conditions (narrowed by bisection)
replace(??;isset()sets nothing)?? $text)Not required: backreference
\2holes (PCRE pads unparticipated groups with''),$this/self::inside the closure, multiple returns.Changes
src/Generator/ClosureGenerator.php—doGenClosurenow calls a newclearReplaceAttributes()helper (NodeFinder walk over the closure subtree)before generating the body; docblock documents the cross-generation
invalidation mechanism and why same-context dedup must be preserved.
tests/compiler/coalesce/closure-repeated-parse.phpt— runtime regression(outer
??+ closure + inner??guard; expects[X] <q-mid-> Z).phpunit/src/ClosureRepeatedCoalesceCodegenTest.php+phpunit/code/closure-repeated-coalesce-codegen.php— codegen regression:every emitted closure generation must carry its own
php::existsmaterialization.Verification
ClosureGenerator.phpfails both new tests(
Failed asserting that 1 is identical to 3; PHPT output[X] [] Z),with the fix both pass.
tests/compiler, 1275 tests): 15 failed vs baseline 16 —failure-set diff is exactly the new test (red → green); zero regressions.
(
SapiApplicationLinkerTest,CompilationStatisticsTest,BashCompletionTest,CompilerBaseApiTest) — identical to baseline.--filter 'Coalesce|Closure'52/52; PHPT coalesce+closure+native-class+object_property 44/44.
Notes
attempted optimizations that skipped the warm-up parses were reverted —
those parses are load-bearing for
nativePropertyAccessannotation(
tests/compiler/native-class/array-access.phptregresses). Dedupinggenerations is a possible follow-up, tracked as fix direction 2 in the
original issue.
variable" (Bug B) is consistent with this root cause but was not
independently reproduced; documented in the issue, request for
.ccattachments from anyone who hits it.
the prebuilt
./tpcbinary is out of sync withsrc/— full builds failwith
undefined reference to typephp_opcode_table_install; drive thecompiler via
php bin/tpc.php.