From 132d81e99b319c35a99b140b9582e26e92d270bd Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 13:55:00 +0200 Subject: [PATCH 1/2] fix(gen_stub): register enum-case class constants as persistent AST constants A class constant valued by an enum case was registered with the folded scalar (the backing value, or the case name for pure cases), so constant('K::CB'), $cls::CB and reflection observed an int/string where PHP has the case object, and K::CB === E::B was false on every dynamic path. Enum case objects have request lifetime and can never sit in the persistent class-entry tables, in any request-init rebinding scheme least of all: writing a request-owned object into the shared table is unsafe under concurrent ZTS requests. Reuse the engine's own mechanism for internal enums instead: the constant is declared as a persistent IS_CONSTANT_AST holding the Enum::Case fetch, so Zend separates the class constants table into request-local mutable storage on first access, evaluates the fetch there, and cleans it up at request shutdown. Identity is preserved for static access, constant(), dynamic class access and reflection, with no module-lifecycle hooks and no registration-order sensitivity. Case identity flows through compile-time constant evaluation as an EnumCaseRef value instead of a scalar, so it also survives constant expressions (true ? E::A : E::B), constant chains, typed class constants (declared type and AST value are registered together), and internal enum cases such as RoundingMode::HalfEven, which previously aborted stub generation. The runtime expression path (php::getEnumCase) is unchanged. The preprocessor also no longer reads the raw ->value property off arbitrary case expressions (`case A = 1 + 1;` warned and was recorded as a pure case): only literal backing values are recorded eagerly, and no compile-time consumer needs the evaluated scalar - gen_stub evaluates the registration value from the AST itself. --- phpunit/code/enum-case-class-constant.php | 24 ++++++++ phpunit/src/EnumCaseClassConstantTest.php | 61 +++++++++++++++++++ src/Entity/EnumCaseRef.php | 25 ++++++++ src/Preprocessor.php | 11 +++- src/Resolver/ClassConstantValueTrait.php | 19 ++++-- src/gen_stub.php | 49 +++++++++++++++ .../enum/enum-case-class-constant.phpt | 57 +++++++++++++++++ 7 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 phpunit/code/enum-case-class-constant.php create mode 100644 phpunit/src/EnumCaseClassConstantTest.php create mode 100644 src/Entity/EnumCaseRef.php create mode 100644 tests/compiler/enum/enum-case-class-constant.phpt diff --git a/phpunit/code/enum-case-class-constant.php b/phpunit/code/enum-case-class-constant.php new file mode 100644 index 00000000..053e551d --- /dev/null +++ b/phpunit/code/enum-case-class-constant.php @@ -0,0 +1,24 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $compiler->convertFile($source); + $this->arginfo = file_get_contents( + TYPEPHP_ROOT_PATH . '/' . 'build/include/' . basename($compiler->getArgInfoHeaderFile($source)) + ); + } + + public function testDirectCaseRegistersConstantAst(): void + { + self::assertStringContainsString('const_CB_value_fetch_ast->kind = ZEND_AST_CLASS_CONST;', $this->arginfo); + self::assertStringContainsString('zend_string_init_interned("CodegenEnum", sizeof("CodegenEnum") - 1, 1)', $this->arginfo); + self::assertStringNotContainsString('ZVAL_LONG(&const_CB_value', $this->arginfo); + } + + public function testConstantExpressionFoldsToCaseIdentity(): void + { + // true ? A : B folds to the A case identity, not to a scalar. + self::assertMatchesRegularExpression( + '/const_PICKED_value_case_name = zend_string_init_interned\("A"/', + $this->arginfo, + ); + } + + public function testTypedConstantKeepsDeclaredTypeAndAstValue(): void + { + self::assertStringContainsString('const_CASE_VALUE_value_fetch_ast->kind = ZEND_AST_CLASS_CONST;', $this->arginfo); + self::assertStringContainsString('zend_declare_typed_class_constant(class_entry, const_CASE_VALUE_name', $this->arginfo); + } + + public function testInternalEnumCaseRegistersConstantAst(): void + { + self::assertStringContainsString('zend_string_init_interned("RoundingMode", sizeof("RoundingMode") - 1, 1)', $this->arginfo); + } + + public function testExpressionValuedBackedCaseRegistersComputedValue(): void + { + self::assertStringContainsString('ZVAL_LONG(&enum_case_A_value, 2);', $this->arginfo); + } +} diff --git a/src/Entity/EnumCaseRef.php b/src/Entity/EnumCaseRef.php new file mode 100644 index 00000000..d92206cd --- /dev/null +++ b/src/Entity/EnumCaseRef.php @@ -0,0 +1,25 @@ +parseIdentifier($v->name); - $this->classDef->enumCases[$caseName] = $v->expr?->value; + // Only literal backing values are recorded here; an + // expression-valued case (`case A = 1 + 1;`) cannot be + // evaluated while declarations are still being collected, + // and no compile-time consumer needs the scalar: case + // identity flows as EnumCaseRef and gen_stub evaluates + // the registration value from the AST itself. + $this->classDef->enumCases[$caseName] = + $v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_ + ? $v->expr->value + : null; break; case 'Stmt_ClassMethod': $this->prepareClassMethod($v, $class); diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 90ec17e5..873e8e10 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -12,6 +12,7 @@ use PhpParser\Node; use PhpParser\NodeAbstract; use TypePhp\Entity\ConstantDef; +use TypePhp\Entity\EnumCaseRef; trait ClassConstantValueTrait { @@ -48,7 +49,12 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n if ($this->isInternalClass($class)) { $constName = $class . '::' . $name; if (defined($constName)) { - return constant($constName); + $value = constant($constName); + // Internal enum cases (and internal constants holding one) + // must keep their identity through constant evaluation. + return $value instanceof \UnitEnum + ? new EnumCaseRef(get_class($value), $value->name) + : $value; } } [$inheritedFound, $inherited] = $this->resolveInheritedClassConst($class, $name); @@ -58,8 +64,10 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n if ($this->hasClass($class)) { $classDef = $this->getClass($class); if ($classDef->enum && array_key_exists($name, $classDef->enumCases)) { - $caseValue = $classDef->enumCases[$name]; - return $caseValue ?? $name; + // The case IDENTITY is the constant's value; folding to the + // backing scalar (or the case name) would make + // `K::CONST === E::Case` false through every dynamic path. + return new EnumCaseRef($classDef->getNamespacedName(false), $name); } } $this->fatalError($expr, "Class constant `{$class}::{$name}` not found"); @@ -89,7 +97,10 @@ protected function resolveInheritedClassConst(string $class, string $name): arra } elseif (Reflection::isInternalClass($current)) { $constName = $current . '::' . $name; if (defined($constName)) { - return [true, constant($constName)]; + $value = constant($constName); + return [true, $value instanceof \UnitEnum + ? new EnumCaseRef(get_class($value), $value->name) + : $value]; } break; } else { diff --git a/src/gen_stub.php b/src/gen_stub.php index 25533823..c2212755 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2757,6 +2757,9 @@ private function __construct($value, SimpleType $type, Expr $expr, array $origin public function initializeZval(string $zvalName, bool $alreadyExists = false, string $forStringDef = '', string $varName = ''): string { + if ($this->value instanceof \TypePhp\Entity\EnumCaseRef) { + return $this->initializeEnumCaseZval($zvalName, $alreadyExists); + } $cExpr = $this->getCExpr(); $code = ''; @@ -2803,6 +2806,52 @@ public function initializeZval(string $zvalName, bool $alreadyExists = false, st return $code; } + /** + * Initialize the zval as a persistent IS_CONSTANT_AST holding + * `EnumClass::CaseName`. Enum case objects have request lifetime and can + * never sit in the persistent class-entry tables, so the engine's own + * mechanism for internal enums is reused: declaring an AST constant makes + * Zend separate the class constants table into request-local mutable + * storage, evaluate the fetch there on first access, and clean it up at + * request shutdown. This keeps case identity intact for static access, + * constant(), and reflection, and is safe under concurrent ZTS requests. + */ + private function initializeEnumCaseZval(string $zvalName, bool $alreadyExists): string + { + /** @var \TypePhp\Entity\EnumCaseRef $case */ + $case = $this->value; + $enumCName = '"' . getTranslator()->escapeString(ltrim($case->enumClass, '\\')) . '"'; + $caseCName = '"' . getTranslator()->escapeString($case->caseName) . '"'; + $id = preg_replace('/[^A-Za-z0-9_]/', '_', $zvalName); + + $code = $alreadyExists ? '' : "\tzval $zvalName;\n"; + $code .= "\t{\n"; + $code .= "\t\tzend_string *{$id}_enum_name = zend_string_init_interned($enumCName, sizeof($enumCName) - 1, 1);\n"; + $code .= "\t\tzend_string *{$id}_case_name = zend_string_init_interned($caseCName, sizeof($caseCName) - 1, 1);\n"; + $code .= "\t\tzend_ast_zval *{$id}_class_ast = (zend_ast_zval *) pemalloc(sizeof(zend_ast_zval), 1);\n"; + $code .= "\t\t{$id}_class_ast->kind = ZEND_AST_ZVAL;\n"; + $code .= "\t\t{$id}_class_ast->attr = ZEND_NAME_FQ;\n"; + $code .= "\t\tZVAL_INTERNED_STR(&{$id}_class_ast->val, {$id}_enum_name);\n"; + $code .= "\t\tZ_LINENO({$id}_class_ast->val) = 0;\n"; + $code .= "\t\tzend_ast_zval *{$id}_const_ast = (zend_ast_zval *) pemalloc(sizeof(zend_ast_zval), 1);\n"; + $code .= "\t\t{$id}_const_ast->kind = ZEND_AST_ZVAL;\n"; + $code .= "\t\t{$id}_const_ast->attr = 0;\n"; + $code .= "\t\tZVAL_INTERNED_STR(&{$id}_const_ast->val, {$id}_case_name);\n"; + $code .= "\t\tZ_LINENO({$id}_const_ast->val) = 0;\n"; + $code .= "\t\tzend_ast_ref *{$id}_ast_ref = (zend_ast_ref *) pemalloc(sizeof(zend_ast_ref) + ZEND_MM_ALIGNED_SIZE(zend_ast_size(2)), 1);\n"; + $code .= "\t\tGC_SET_REFCOUNT({$id}_ast_ref, 1);\n"; + $code .= "\t\tGC_TYPE_INFO({$id}_ast_ref) = GC_CONSTANT_AST | ((GC_PERSISTENT | GC_IMMUTABLE) << GC_FLAGS_SHIFT);\n"; + $code .= "\t\tzend_ast *{$id}_fetch_ast = GC_AST({$id}_ast_ref);\n"; + $code .= "\t\t{$id}_fetch_ast->kind = ZEND_AST_CLASS_CONST;\n"; + $code .= "\t\t{$id}_fetch_ast->attr = 0;\n"; + $code .= "\t\t{$id}_fetch_ast->lineno = 0;\n"; + $code .= "\t\t{$id}_fetch_ast->child[0] = (zend_ast *) {$id}_class_ast;\n"; + $code .= "\t\t{$id}_fetch_ast->child[1] = (zend_ast *) {$id}_const_ast;\n"; + $code .= "\t\tZVAL_AST(&$zvalName, {$id}_ast_ref);\n"; + $code .= "\t}\n"; + return $code; + } + public function getCExpr(): ?string { // $this->expr has all its PHP constants replaced by C constants diff --git a/tests/compiler/enum/enum-case-class-constant.phpt b/tests/compiler/enum/enum-case-class-constant.phpt new file mode 100644 index 00000000..0436d07c --- /dev/null +++ b/tests/compiler/enum/enum-case-class-constant.phpt @@ -0,0 +1,57 @@ +--TEST-- +Class constants valued by enum cases keep case identity everywhere +--FILE-- +getValue() === TypedCase::A); + var_dump((string) (new ReflectionClassConstant('K', 'CASE_VALUE'))->getType()); + // Expression-valued backed case keeps its computed backing value + var_dump(E::A->value); + var_dump(K::VALUE->value); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +string(9) "TypedCase" +int(2) +int(2) From b7843706d478d6a3cbb6394dec1ef6227046a15d Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 15:47:13 +0200 Subject: [PATCH 2/2] fix(gen_stub): constrain enum-case AST registration to class constants The persistent IS_CONSTANT_AST representation leaked into property and parameter defaults, whose persistent tables reject refcounted zvals: startup died with "Internal zvals cannot be refcounted". EvaluatedValue now carries the case identity in a dedicated field while its value degrades to what those consumers read before case identity existed (the host case object for internal enums, the literal backing value or case name for compiled ones), and only class-constant registration opts into the AST. Property/parameter defaults keep flowing through their existing runtime-restore machinery unchanged. Also parenthesize a folded constant operand before appending a member access: the C++ ternary of `const VALUE = cond ? E::A : E::B;` bound `.attr("value")` to its else branch only, so `K::VALUE->value` evaluated to the case object instead of its backing value. --- src/Parser/PropertyAccessTrait.php | 37 +++++++++++++++++++++++- src/Resolver/ClassConstantValueTrait.php | 24 +++++++++++++++ src/gen_stub.php | 27 +++++++++++++---- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index c553fd09..4d358ed1 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -1002,7 +1002,7 @@ protected function parsePropertyFetch(Expr\PropertyFetch $expr): string return $this->getNativeObjectMemberReceiver($objectName) . $this->getNativeObjectPropertyCppName($resolution->propertyDef, $resolution->classDef); } - $objectVar = $objectName; + $objectVar = $this->parenthesizeOpenOperand($objectName); if ($this->usesTraitPropertyScope($objectVar)) { $getProperty = 'typephp_read_property_scoped(' . $objectVar . ', ' . $id . ', php::FakeScopeGuard::current(), ' . $this->escapeAttrMode($update) . ')'; @@ -1155,4 +1155,39 @@ private function emitNativeInstancePropertyTypedFetch( return $result; } + + /** + * A folded constant value can be a full C++ expression (e.g. the ternary + * of `const VALUE = cond ? E::A : E::B;`). Appending `.attr(...)` to it + * unparenthesized would bind the member access to the last operand only, + * so any operand with top-level operators is wrapped first. Simple + * identifiers and closed call chains stay untouched. + */ + private function parenthesizeOpenOperand(string $code): string + { + $depth = 0; + $inString = false; + $length = strlen($code); + for ($i = 0; $i < $length; $i++) { + $char = $code[$i]; + if ($inString) { + if ($char === '\\') { + $i++; + } elseif ($char === '"') { + $inString = false; + } + continue; + } + if ($char === '"') { + $inString = true; + } elseif ($char === '(' || $char === '{' || $char === '[') { + $depth++; + } elseif ($char === ')' || $char === '}' || $char === ']') { + $depth--; + } elseif ($depth === 0 && ($char === ' ' || $char === '?')) { + return '(' . $code . ')'; + } + } + return $code; + } } diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 873e8e10..649f7a5b 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -155,6 +155,30 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c return $evaluator->evaluateDirectly($valueExpr); } + /** + * The pre-AST representation of an enum case for consumers that cannot + * register an IS_CONSTANT_AST (property and parameter defaults, attribute + * arguments): internal enums degrade to the host case object, compiled + * enums to the literal backing value or the case name — exactly the + * values those paths consumed before case identity existed. + */ + public function enumCaseLegacyValue(\TypePhp\Entity\EnumCaseRef $ref): mixed + { + if ($this->isInternalClass($ref->enumClass)) { + $constName = $ref->enumClass . '::' . $ref->caseName; + if (defined($constName)) { + return constant($constName); + } + } + if ($this->hasClass($ref->enumClass)) { + $classDef = $this->getClass($ref->enumClass); + if (array_key_exists($ref->caseName, $classDef->enumCases)) { + return $classDef->enumCases[$ref->caseName] ?? $ref->caseName; + } + } + return $ref->caseName; + } + public function getConstValue(string $name): mixed { if ($this->isInternalConstant($name)) { diff --git a/src/gen_stub.php b/src/gen_stub.php index c2212755..65d92a8d 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2569,6 +2569,10 @@ class EvaluatedValue public SimpleType $type; public Expr $expr; public bool $isUnknownConstValue; + /** Case identity when the expression evaluates to an enum case; only + * class-constant registration may use it (persistent AST) — every other + * consumer sees the legacy scalar/object in $value. */ + public ?\TypePhp\Entity\EnumCaseRef $enumCaseRef = null; /** @var ConstInfo[] */ public array $originatingConsts; @@ -2728,13 +2732,25 @@ static function (Expr $expr) use ( $result = $evaluator->evaluateDirectly($expr); - return new EvaluatedValue( + $enumCaseRef = null; + if ($result instanceof \TypePhp\Entity\EnumCaseRef) { + // Property/parameter defaults and attribute arguments must keep + // consuming the legacy value (persistent tables reject refcounted + // zvals, and those paths have their own runtime restore + // machinery); only class-constant registration uses the identity. + $enumCaseRef = $result; + $result = getTranslator()->enumCaseLegacyValue($result); + } + + $evaluated = new EvaluatedValue( $result, // note: we are generally not interested in the actual value of $result, unless it's a bare value, without constants $constType ?? SimpleType::fromValue($result), $cConstName === null ? $expr : new Expr\ConstFetch(new Node\Name($cConstName)), $visitor->visitedConstants, $isUnknownConstValue ); + $evaluated->enumCaseRef = $enumCaseRef; + return $evaluated; } public static function null(): EvaluatedValue @@ -2755,9 +2771,9 @@ private function __construct($value, SimpleType $type, Expr $expr, array $origin $this->isUnknownConstValue = $isUnknownConstValue; } - public function initializeZval(string $zvalName, bool $alreadyExists = false, string $forStringDef = '', string $varName = ''): string + public function initializeZval(string $zvalName, bool $alreadyExists = false, string $forStringDef = '', string $varName = '', bool $allowConstantAst = false): string { - if ($this->value instanceof \TypePhp\Entity\EnumCaseRef) { + if ($this->enumCaseRef !== null && $allowConstantAst) { return $this->initializeEnumCaseZval($zvalName, $alreadyExists); } $cExpr = $this->getCExpr(); @@ -2818,8 +2834,7 @@ public function initializeZval(string $zvalName, bool $alreadyExists = false, st */ private function initializeEnumCaseZval(string $zvalName, bool $alreadyExists): string { - /** @var \TypePhp\Entity\EnumCaseRef $case */ - $case = $this->value; + $case = $this->enumCaseRef; $enumCName = '"' . getTranslator()->escapeString(ltrim($case->enumClass, '\\')) . '"'; $caseCName = '"' . getTranslator()->escapeString($case->caseName) . '"'; $id = preg_replace('/[^A-Za-z0-9_]/', '_', $zvalName); @@ -3273,7 +3288,7 @@ private function getClassConstDeclaration(EvaluatedValue $value): string { $constName = $this->name->getDeclarationName(); - $zvalCode = $value->initializeZval("const_{$constName}_value"); + $zvalCode = $value->initializeZval("const_{$constName}_value", allowConstantAst: true); $code = "\n" . $zvalCode;