Skip to content

Shadow the closure, operator and control-flow handlers natively - #6532

Merged
ondrejmirtes merged 13 commits into
2.3.xfrom
turbo-native-control-flow-handlers
Sep 22, 2026
Merged

ondrejmirtes merged 13 commits into
2.3.xfrom
turbo-native-control-flow-handlers

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Shadows the closure path, the operator handlers and the control-flow statement handlers natively. Apart from the #[ShadowedByTurboExtension] / #[ReferencedByTurboExtension] attributes, the PHP twins are unchanged.

Closures and arrow functions

  • ClosureTypeResolver, ClosureParameterResolver and ContextualClosureParameterResolver:
    • getClosureType() with its body walks;
    • getDeclaredClosureType();
    • the free-variable roots;
    • the per-file type cache.
  • ClosureProcessor, ClosureHandler and ArrowFunctionHandler, plus their result classes, including the by-reference use convergence and the replay of recorded passes.

Operators

  • BooleanAndHandler, BooleanOrHandler, BooleanNotHandler and TernaryHandler. The ternary's (cond && if) || (!cond && else) decomposition builds its operand callbacks natively.
  • BinaryOpHandler. The operator kind is looked up once per class, and a concatenation's type is written out so that deep . chains recurse without an engine frame per level.
  • CoalesceHandler and CoalesceCompositionHelper, which AssignOpHandler's ??= now calls directly.

The type callbacks continue on a fresh C stack when the current one runs low.

Control flow

  • BreakContinueHandler, LabelHandler and GotoHandler.
  • WhileHandler, DoWhileHandler and ForHandler, including the convergence passes, the record and replay of the fixpoint pass, and the post-loop narrowing.
  • SwitchHandler and TryCatchHandler.
  • ForeachHandler, including the unrolled constant-array iterations, the post-loop iteratee rewrite and the destructuring holders.

Every re-walk goes through NodeScopeResolver's direct entries, so the fresh-stack guard stays on the recursion path: loops around 6,000 nested ifs analyse identically with the extension on and off.

Fixes and coverage

  • Describing $this of an interface whose template bound refers to static of that same interface (bug-13801) recursed without end. The PHP twin hits the engine's stack check and throws "Maximum call stack size reached", but the native recursion overflowed the C stack instead. Native GenericObjectType::describe() now checks the stack at the point where the twin enters its closure, and throws the same Error.
  • smoke.php checks that a TypeCombinatorCache memo hit hands back the operand of the call at hand. The walk-trace corpus gains the reduced reproducer.
  • walk-trace.php keeps files the parser rejects out of the container's analysed paths. Such a file broke the walk of every other file that looked up a class. Each rejected file still gets its own walk.

🤖 Generated with Claude Code

https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN

ondrejmirtes and others added 13 commits September 22, 2026 14:40
…osureParameterResolver natively

Ports the closure type and parameter resolution: getClosureType() with its body walks
(native node callbacks capturing the gathered lists by reference), the two
build*() methods, getDeclaredClosureType(), freeVariableRoots() over the native node
finder, and the per-file $cachedTypes cache (twin's shape, updated in place). Exports
pt_closure_type_resolver_*(), pt_closure_parameter_resolver_resolve() /
_resolve_callable_type_for_scope() and pt_contextual_closure_parameter_resolver_*();
native callers get the two parameter lists without the ClosureParameterTypes object
(class-map key closureParameterTypes, built for PHP callers only). MutatingScope,
ArgumentsHandler and FuncCallHandler (getDeclaredClosureType()) call the entries; adds MutatingScope entries (getFunctionType,
getClosureScopeCacheKey, enter*WithoutReflection, getKeepVoidType, ...), the
NativeParameterReflection toOptional()/union() entries and ThrowPoint / ImpurePoint /
StatementResult readers. SimpleThrowPoint, InitializerExprTypeResolver/Context and
TemplateArgumentStats stay PHP (cached sites).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…ir result classes natively

Ports the closure / arrow function walk: processClosureNode() with the by-ref use
convergence and recorded-pass replay, processArrowFunctionNode(),
processImmediatelyCalledCallable(), the two handlers (registered handler entries) and
the ProcessClosureResult / ProcessArrowFunctionResult value classes (differential in
tests/analyser-values.php). The gatherer frames are native closures (the closure
gatherer captures its lists and $closureScope by reference; its scope identity check
compares MutatingScope slots). Exports pt_closure_processor_*(),
pt_process_*_result_new() and slot readers, pt_closure_handler_get_variable_flow(),
pt_arrow_function_handler_get_variable_flow(); adds MutatingScope
enterAnonymousFunction/enterArrowFunction/processClosureScope/
withAnonymousFunctionReflection, storage mergeResults, NodeScopeResolver
isReplayableConvergenceBody/replayRecording and VariableFlow::arrow entries.
ArgumentsHandler now calls the whole closure cluster directly, FuncCallHandler
processImmediatelyCalledCallable() too. Class-map keys added:
inClosureNode, inArrowFunctionNode (closureReturnStatementsNode reused); removed:
closureHandler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…naryHandler natively

The boolean operator and ternary handlers with their type and specify-types
callbacks as native closures, registered as handler entries; the ternary's
(cond && if) || (!cond && else) decomposition builds its operand callbacks
natively, the $aFalseyScope memo as a by-reference capture. Shared operand
reads, array_merge and boolean verdicts (exact ConstantBooleanType /
BooleanType answered without a call) live in OperatorHandlers.h; type
callbacks continue on a fresh stack when the C stack runs low. New entries:
pt_expression_result_get_specified_types / _answers_on_scope and
pt_ternary_handler_get_captured_results, which AssignHandler now calls.
Class-map keys booleanAndNode, booleanOrNode, binaryOpExpr, booleanNotExpr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu

# Conflicts:
#	turbo-ext/config.w32
#	turbo-ext/src/main.cpp
#	turbo-ext/src/support.cpp
#	turbo-ext/src/support.h
#	turbo-ext/tests/smoke.php

# Conflicts:
#	build/PHPStan/Build/TurboAttributeCollector.php
#	turbo-ext/config.w32
#	turbo-ext/src/main.cpp
#	turbo-ext/src/support.cpp
#	turbo-ext/src/support.h
#	turbo-ext/tests/smoke.php

# Conflicts:
#	turbo-ext/config.w32
#	turbo-ext/src/main.cpp
#	turbo-ext/src/support.cpp
#	turbo-ext/src/support.h
#	turbo-ext/tests/smoke.php

# Conflicts:
#	turbo-ext/config.w32
#	turbo-ext/src/main.cpp
#	turbo-ext/src/support.cpp
#	turbo-ext/src/support.h
#	turbo-ext/tests/smoke.php
The arithmetic, bitwise, concatenation and comparison handler with its type,
operand-reader, specify-types and identical-type callbacks as native closures,
registered as the handler entry. The operator is classified once per class
entry (the twin's instanceof chain, memoized per request); a concatenation's
type spells out getConcatType() (both operand reads, then resolveConcatType())
so deep `.` chains recurse without an engine frame per level, and every type
callback continues on a fresh C stack when the current one runs low. Call
arguments are read through pt_call_like_args() in the twin's order.
InitializerExprTypeResolver, RicherScopeGetTypeHelper,
ImplicitToStringCallHelper and CountNarrowingHelper stay cached sites.
Class-map keys for the BinaryOp subclasses the twin tests (smallerExpr ..
pipeExpr; Plus, Minus and NotIdentical reuse binaryOpPlus / binaryOpMinus /
binaryOpNotIdentical).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu

# Conflicts:
#	turbo-ext/config.w32

# Conflicts:
#	turbo-ext/config.w32

# Conflicts:
#	turbo-ext/config.w32

# Conflicts:
#	turbo-ext/config.w32
The `??` handler with its type, specify-types, create-types and isSet() verdict
callbacks as native closures, registered as the handler entry, and the
composition helper it shares with AssignOpHandler's `??=`, which now calls it
directly (composeType()'s $leftIsSetType inlined, its resolution on a fresh C
stack when the current one runs low). New entries:
pt_coalesce_composition_helper_get_falsey_specified_types /
_get_right_side_scope_specified_types / _compose_type.
EnsuredNonNullabilityResult is read through property sites,
IssetabilityResolution::isSet() through a cached site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu

# Conflicts:
#	turbo-ext/config.w32
The three jump-statement handlers register their processStmt() bodies as
statement-handler entries. break/continue walk their level expression
through NodeScopeResolver's direct entry and build the exit point, the
break/continue exit flow and the result through the value classes' direct
entries; goto's opaque flow through the new pt_variable_flow_all_opaque().
No twin bugs found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
The three loop handlers run their convergence passes, record/replay of the
fixpoint pass and the post-loop narrowing natively; every body and
condition re-walk goes through NodeScopeResolver's direct entries, so the
fresh-stack guard stays on the recursion (nested loops around 6,000 nested
ifs analyse identically with the extension on and off). Shared pieces live
in LoopHandlerCalls.h; LoopWrittenVariableNames::collect() stays PHP
(generalization passes only). New entries: pt_mutating_scope_generalize_with_names,
_enter_foreach, _enter_foreach_key, _enter_catch_type, _process_finally_scope,
_process_always_iterable_foreach_scope_without_pollute;
pt_node_scope_resolver_narrow_scope_with_condition; pt_variable_flow_loop,
_loop_statement, _switch, _try_catch; pt_variable_flow_builder_child, _writes
(the replay, storage-merge and answersOnScope entries of the closure and
operator ports are reused). Class map: breaklessWhileLoopNode,
doWhileLoopConditionNode, loopWrittenVariableNames (smallerExpr / greaterExpr
reused). No twin bugs found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
switch walks its subject, case conditions and case bodies through
NodeScopeResolver's direct entries and composes each case's `==` narrowing
through IdenticalNarrowingHelper's (looked up from the container per case,
like the twin), with narrowScopeWithCondition() as the miss seam.
try/catch matches throw points to catch types, recomputes the remaining
throw points and merges the finally scope through InternalThrowPoint,
InternalStatementExitPoint, MutatingScope (enterCatchType(),
processFinallyScope()) and the Type kernel directly. Class map:
switchConditionArm, switchConditionNode, catchWithUnthrownExceptionNode,
finallyExitPointsNode (equalExpr reused). No twin bugs found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
processStmt() runs natively: the `iteratee !== []` narrowing (its type
callback a native closure), the unrolled constant-array iterations, the
convergence passes with record/replay, the post-loop iteratee rewrite
(mapValueType()/mapKeyType() replacements as native closures), the
traversable throw point, the tagged-union destructuring holders and the
binding flows. Walks go through NodeScopeResolver's direct entries (foreach
around 6,000 nested ifs analyses identically with the extension on and off);
VarAnnotationProcessor, AssignHandler::processVirtualAssign(),
IdenticalNarrowingHelper and MutatingScope::enterForeach()/enterForeachKey()
through theirs. $stmt->getDocComment() is read off the comments attribute.
Class map: inForeachNode, foreachValueByRefExpr. Mirrored twin quirk: the
post-loop rewrite reads the key's "native" loop type off the PHPDoc scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
The fix itself is in place; this observes it. smoke.php checks that a
memo hit hands back the operand of the call at hand — for a union() of
equal shapes and a remove() of nothing — now that the Type family is
native and the checks can be written against it, and the walk-trace
corpus gains the reduced reproducer: nested ??= on dim fetches in a
foreach, which is what reaches ArrayType::setExistingOffsetValueType()
and tells "nothing was written" by identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN
…ericObjectType

Describing $this of an interface whose template bound mentions `static` of
the interface itself (bug-13801: `@template TCast of Cast<static>`) recurses
without end. The twin describes its type arguments through an array_map()
closure, where the engine's C stack check throws "Maximum call stack size
... reached. Infinite recursion?"; the native recursion is direct C++ calls
with no such check and overflowed the C stack (SIGSEGV, also on the pre-port
Type kernel). The native describe() now checks the stack at the point the
twin enters the closure and throws the same Error. The reproducer joins the
walk-trace default corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
A file the parser rejected with the name resolver's PhpParser\Error
(bug-10358's clashing use alias) broke the walk of every other file in the
run that looked up a class: the reflection's source locators parse the
analysed files on a lookup, and FileNodesFetcher only catches
ParserErrorsException. A parse-check child now parses the corpus up front
the way the walk does and hands the rejected files to the shard children,
which leave them out of the container's analysed paths; each rejected file
still gets its own walk, recording FILE-EX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
@ondrejmirtes
ondrejmirtes force-pushed the turbo-native-control-flow-handlers branch from 77395a4 to 2c67472 Compare September 22, 2026 12:40
@ondrejmirtes
ondrejmirtes merged commit 2c67472 into 2.3.x Sep 22, 2026
469 of 473 checks passed
@ondrejmirtes
ondrejmirtes deleted the turbo-native-control-flow-handlers branch September 22, 2026 12:41
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.

1 participant