fix(codegen): register enum-case class constants as real case objects (#51) --skip-tests
* 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.
* 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.
* fix(gen_stub): give persistent AST constants a complete teardown lifecycle
destroy_zend_class() asserts (in debug builds) that every persistent
AST constant remaining on an internal class is CONST_ENUM_INIT, and its
teardown frees only the allocation referenced by Z_AST — the previous
representation left a CLASS_CONST root behind (assertion failure at
shutdown on 8.4/8.5 debug builds) and leaked the two separately
allocated children.
The AST is now built in one contiguous persistent allocation (ast_ref,
root, both zval children — mirroring Zend's own persistent enum AST
builder), and every generated file with AST constants emits a release
function that runs from the module's MSHUTDOWN, before Zend's class
teardown: it frees the single block and restores the constant slot to
null, so destroy_zend_class() never sees a foreign AST. Request-local
mutable copies are unaffected (no request is live at MSHUTDOWN).
CONST_ENUM_INIT itself is not usable here: that node constructs a new
case object rather than fetching the canonical registered one, which
would break case identity again.
* fix(codegen): reject lifecycles that cannot release AST constants
The typephp_release_ast_constants_*() teardown ran only from MSHUTDOWN,
which is not a general pre-class-destruction hook: for a
MODULE_TEMPORARY module loaded through dl(), module_destructor() runs
clean_module_classes() before the shutdown callback, so the foreign
ZEND_AST_CLASS_CONST reached destroy_zend_class() first and still
tripped the debug assertion; and a MINIT that fails after registering
such a constant never sets module_started, so MSHUTDOWN is not
guaranteed to run at all.
The generated module now enforces the lifecycle contract instead of
assuming it. When the module declares any enum-case AST constant, MINIT
opens with a guard that rejects MODULE_TEMPORARY (zend_error E_WARNING,
return FAILURE) before a single class is registered — with nothing in
the class table, teardown is trivially safe. MINIT is also restructured
so that every step that can return FAILURE precedes the first
register_class_*() call: the AST constants are installed by the
infallible tail (class registration, then symbol registration), so a
FAILURE return can never leave a foreign AST in the persistent tables.
The generator itself throws if a future change introduces a FAILURE
return after registration begins. The MSHUTDOWN release is unchanged
and remains the supported, persistent-module path.
EnumCaseAstConstantLifecycleTest asserts the guard exists exactly when
AST constants exist, that it precedes every registration step, that no
FAILURE return follows the first class registration, and that MSHUTDOWN
releases the constants before any other teardown. The enum-case phpt
gains a never-accessed constant so the full process shutdown it already
performs also covers a pristine persistent AST; a dl()-path phpt is not
feasible because the harness only builds standalone binaries whose
module is registered persistently (documented in the test file).
master
parent
e90d6246ec
commit
4ca9072f36
10 changed files with 540 additions and 12 deletions
@ -0,0 +1,24 @@ |
||||
<?php |
||||
enum CodegenEnum: int |
||||
{ |
||||
case B = 4; |
||||
case A = 1 + 1; |
||||
} |
||||
|
||||
enum CodegenTyped |
||||
{ |
||||
case A; |
||||
} |
||||
|
||||
class CodegenHolder |
||||
{ |
||||
public const CB = CodegenEnum::B; |
||||
public const PICKED = true ? CodegenEnum::A : CodegenEnum::B; |
||||
public const CodegenTyped CASE_VALUE = CodegenTyped::A; |
||||
public const MODE = RoundingMode::HalfEven; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
var_dump(CodegenHolder::CB === CodegenEnum::B); |
||||
} |
||||
@ -0,0 +1,120 @@ |
||||
<?php |
||||
|
||||
use TypePhp\CompilerBase; |
||||
use TypePhp\CompilerTest; |
||||
|
||||
/** |
||||
* Lifecycle contract for persistent enum-case AST class constants: Zend's |
||||
* internal-class teardown (destroy_zend_class) tolerates them only after the |
||||
* module's MSHUTDOWN released them. The generated module must therefore |
||||
* (a) refuse to start as a MODULE_TEMPORARY (dl()-loaded) module before any |
||||
* class is registered, because module_destructor() destroys temporary-module |
||||
* classes before the shutdown callback runs, and (b) order every fallible |
||||
* MINIT step before the first class registration, so a FAILURE return (which |
||||
* suppresses MSHUTDOWN) can never leave a foreign AST in the class table. |
||||
*/ |
||||
final class EnumCaseAstConstantLifecycleTest extends \BaseTest |
||||
{ |
||||
public function testModuleTemporaryGuardPrecedesEveryRegistrationStep(): void |
||||
{ |
||||
$minit = $this->generateMinitBody('enum-case-class-constant.php', 'ast_lifecycle_guard'); |
||||
|
||||
$guardPos = strpos($minit, 'if (type == MODULE_TEMPORARY) {'); |
||||
self::assertIsInt($guardPos, 'MINIT must reject dl()-loaded temporary modules'); |
||||
self::assertStringContainsString( |
||||
'registers enum-case class constants that must be released by MSHUTDOWN', |
||||
$minit, |
||||
); |
||||
self::assertStringContainsString('Load the extension from php.ini instead.', $minit); |
||||
|
||||
$handlersPos = strpos($minit, 'typephp_install_reflection_attribute_handlers()'); |
||||
$firstRegisterPos = strpos($minit, 'register_class_'); |
||||
self::assertIsInt($handlersPos); |
||||
self::assertIsInt($firstRegisterPos); |
||||
self::assertLessThan($handlersPos, $guardPos, 'the lifecycle guard must be the first MINIT statement'); |
||||
self::assertLessThan($firstRegisterPos, $guardPos, 'the lifecycle guard must precede every class registration'); |
||||
} |
||||
|
||||
public function testAstConstantRegistrationIsOrderedAfterEveryFallibleMinitStep(): void |
||||
{ |
||||
$minit = $this->generateMinitBody('enum-case-class-constant.php', 'ast_lifecycle_order'); |
||||
|
||||
$firstRegisterPos = strpos($minit, 'register_class_'); |
||||
$lastFailurePos = strrpos($minit, 'return FAILURE;'); |
||||
self::assertIsInt($firstRegisterPos); |
||||
self::assertIsInt($lastFailurePos); |
||||
self::assertLessThan( |
||||
$firstRegisterPos, |
||||
$lastFailurePos, |
||||
'no MINIT step after the first class registration may return FAILURE: ' |
||||
. 'a failed MINIT never reaches MSHUTDOWN, so the persistent class ' |
||||
. 'table would keep an AST that destroy_zend_class() cannot handle', |
||||
); |
||||
self::assertGreaterThan( |
||||
strrpos($minit, 'register_class_'), |
||||
strpos($minit, 'return SUCCESS;'), |
||||
); |
||||
} |
||||
|
||||
public function testMshutdownReleasesAstConstantsBeforeAnyOtherTeardown(): void |
||||
{ |
||||
$extension = $this->generateExtension('enum-case-class-constant.php', 'ast_lifecycle_shutdown'); |
||||
$mshutdown = $this->sliceFunction($extension, 'PHP_MSHUTDOWN_FUNCTION', 'THREAD_LOCAL zval globals_array'); |
||||
|
||||
$releasePos = strpos($mshutdown, 'typephp_release_ast_constants_enum_case_class_constant();'); |
||||
self::assertIsInt($releasePos, 'MSHUTDOWN must release the persistent AST constants'); |
||||
// The release must run before anything else so the class table is |
||||
// Zend-safe no matter what the rest of the teardown does. |
||||
$firstStatementPos = strpos($mshutdown, ';'); |
||||
self::assertSame($firstStatementPos, $releasePos + strlen('typephp_release_ast_constants_enum_case_class_constant();') - 1); |
||||
} |
||||
|
||||
public function testModulesWithoutAstConstantsCarryNeitherGuardNorRelease(): void |
||||
{ |
||||
$extension = $this->generateExtension('class-constant-codegen.php', 'ast_lifecycle_none'); |
||||
|
||||
self::assertStringNotContainsString('MODULE_TEMPORARY', $extension); |
||||
self::assertStringNotContainsString('typephp_release_ast_constants_', $extension); |
||||
} |
||||
|
||||
private function generateMinitBody(string $fixture, string $target): string |
||||
{ |
||||
return $this->sliceFunction( |
||||
$this->generateExtension($fixture, $target), |
||||
'PHP_MINIT_FUNCTION', |
||||
'PHP_MSHUTDOWN_FUNCTION', |
||||
); |
||||
} |
||||
|
||||
private function generateExtension(string $fixture, string $target): string |
||||
{ |
||||
global $translator; |
||||
|
||||
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); |
||||
$translator = $compiler; |
||||
$compiler->setBuildMode(CompilerBase::BUILD_MODE_EXT); |
||||
$compiler->setTargetName($target); |
||||
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/' . $fixture; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
$compiler->convertFile($source); |
||||
$extension = file_get_contents($compiler->genExtension()); |
||||
|
||||
self::assertIsString($extension); |
||||
return $extension; |
||||
} |
||||
|
||||
/** |
||||
* The generated function bodies keep statements at column zero, so the |
||||
* closing brace is not recognizable; slice up to the next known emission |
||||
* instead. |
||||
*/ |
||||
private function sliceFunction(string $extension, string $startMarker, string $endMarker): string |
||||
{ |
||||
$start = strpos($extension, $startMarker); |
||||
self::assertIsInt($start, "generated extension must contain {$startMarker}"); |
||||
$end = strpos($extension, $endMarker, $start + strlen($startMarker)); |
||||
self::assertIsInt($end, "generated extension must contain {$endMarker}"); |
||||
return substr($extension, $start, $end - $start); |
||||
} |
||||
} |
||||
@ -0,0 +1,61 @@ |
||||
<?php |
||||
|
||||
use TypePhp\CompilerTest; |
||||
|
||||
/** |
||||
* A class constant valued by an enum case must register a persistent |
||||
* IS_CONSTANT_AST (`Enum::Case`) instead of a folded scalar: the engine then |
||||
* separates the constants table per request, evaluates the fetch there, and |
||||
* cleans it up — preserving case identity for static access, constant(), and |
||||
* reflection, safely under concurrent ZTS requests. |
||||
*/ |
||||
final class EnumCaseClassConstantTest extends \BaseTest |
||||
{ |
||||
private string $arginfo; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
global $translator; |
||||
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); |
||||
$translator = $compiler; |
||||
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/enum-case-class-constant.php'; |
||||
$compiler->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); |
||||
} |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP. |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Entity; |
||||
|
||||
/** |
||||
* Compile-time identity of an enum case flowing through constant-expression |
||||
* evaluation. Enum case objects have request lifetime, so a constant whose |
||||
* value is a case cannot be folded to its backing scalar (identity would be |
||||
* lost) nor embedded in persistent class metadata as an object; carriers of |
||||
* this value register an IS_CONSTANT_AST the engine evaluates per request. |
||||
*/ |
||||
final class EnumCaseRef |
||||
{ |
||||
public function __construct( |
||||
public readonly string $enumClass, |
||||
public readonly string $caseName, |
||||
) { |
||||
} |
||||
} |
||||
@ -0,0 +1,71 @@ |
||||
--TEST-- |
||||
Class constants valued by enum cases keep case identity everywhere |
||||
--FILE-- |
||||
<?php |
||||
// The compiled binary runs the full module lifecycle: MINIT registers the |
||||
// persistent IS_CONSTANT_AST constants, MSHUTDOWN releases them, and |
||||
// zend_shutdown() then destroys the classes. Under a debug PHP build a |
||||
// constant that survived to destroy_zend_class() trips its persistent-AST |
||||
// assertion, which lands on captured stderr and fails the expected output — |
||||
// so this test covers the supported (persistent module) shutdown path, not |
||||
// just runtime values. The rejected path — a MODULE_TEMPORARY module loaded |
||||
// with dl() — cannot be exercised as a phpt here: the harness always links |
||||
// the test into a standalone binary whose module is registered persistently |
||||
// before startup, and never produces a dl()-loadable artifact. That guard is |
||||
// asserted structurally in phpunit/src/EnumCaseAstConstantLifecycleTest.php. |
||||
enum E: int { case B = 4; case A = 1 + 1; } |
||||
enum P { case X; } |
||||
enum TypedCase { case A; } |
||||
|
||||
class K { |
||||
public const CB = E::B; |
||||
public const CX = P::X; |
||||
public const VALUE = true ? E::A : E::B; |
||||
public const TypedCase CASE_VALUE = TypedCase::A; |
||||
public const MODE = RoundingMode::HalfEven; |
||||
// Never read at runtime: its AST is never evaluated into the mutable |
||||
// constants table, so shutdown must release the pristine persistent AST. |
||||
public const UNTOUCHED = E::A; |
||||
} |
||||
|
||||
class Alias { |
||||
public const REF = K::CB; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
// Static access |
||||
var_dump(K::CB === E::B); |
||||
var_dump(K::CX === P::X); |
||||
var_dump(K::CASE_VALUE === TypedCase::A); |
||||
var_dump(K::MODE === RoundingMode::HalfEven); |
||||
// Expression-valued constant and constant chains |
||||
var_dump(K::VALUE === E::A); |
||||
var_dump(Alias::REF === E::B); |
||||
// Dynamic access |
||||
var_dump(constant('K::CB') === E::B); |
||||
var_dump(constant('K::VALUE') === E::A); |
||||
$cls = 'K'; |
||||
var_dump($cls::MODE === RoundingMode::HalfEven); |
||||
// Reflection |
||||
var_dump((new ReflectionClassConstant('K', 'CASE_VALUE'))->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) |
||||
Loading…
Reference in new issue