fix(codegen): register enum-case class constants as real case objects - #51
fix(codegen): register enum-case class constants as real case objects#51AlessioGiacobbe wants to merge 4 commits into
Conversation
matyhtf
left a comment
There was a problem hiding this comment.
Thank you for isolating this fix from #39. Preserving enum-case object identity for class constants is necessary, and the added direct-case tests pass. However, this implementation has lifecycle and compatibility blockers that need to be addressed before merging.
- The RINIT/RSHUTDOWN rebinding mutates a persistent class-constant table and is not ZTS-safe
The generated class constant is initially a scalar placeholder, so the internal class does not get ZEND_ACC_HAS_AST_CONSTANTS or a request-local mutable constants table. php::updateConstant() therefore reaches the persistent ce->constants_table and replaces zend_class_constant::value directly with a request-owned enum object.
With concurrent ZTS requests, request A and request B can overwrite the same constant, and one request can reset it to null while the other is still using it. This can also expose an object owned by one request to another request. Mirroring the existing array-constant code does not make this safe; that helper path appears to have the same underlying limitation for refcounted request values.
Please use Zend's request-local mutable constant table, or register an appropriate persistent IS_CONSTANT_AST and let Zend perform per-request separation, evaluation, and cleanup. A request-owned object must never be written into the shared persistent class-entry table.
- Valid enum-case class constants are still unsupported
These cases fail against the updated branch:
- Internal enum case:
class InternalEnumCaseHolder
{
public const MODE = RoundingMode::HalfEven;
}Compilation aborts in gen_stub.php with Invalid default value: RoundingMode Enum ... type: object.
- Typed class constant:
enum TypedCase { case A; }
class TypedHolder
{
public const TypedCase CASE_VALUE = TypedCase::A;
}Compilation aborts because the scalar placeholder "A" is not valid for the declared TypedCase type.
Both are valid PHP 8.4 declarations and should preserve case identity through static access, constant(), and reflection.
- Only direct
ClassConstFetchchains are recognized
resolveEnumCaseClassConstant() returns null for valid constant expressions that evaluate to an enum case:
enum E { case A; case B; }
class K
{
public const VALUE = true ? E::A : E::B;
}The compiled direct access evaluates correctly, but constant('K::VALUE') === E::A is false, because the registered constant remains the folded string placeholder. The solution needs to represent/evaluate enum-case-valued constant expressions, not only syntactically follow direct E::Case and constant aliases.
- Constant evaluation should not be moved into prepare
evaluateEnumCaseValue() eagerly evaluates the case expression while declarations are still being collected. The need to catch forward-reference failures demonstrates that the symbol environment is incomplete at this phase. It also catches every Throwable, which can hide genuine compiler defects, and overloads null to mean both a pure case and an unresolved backed case.
Please keep the value AST and explicit backed/pure state during prepare, then resolve it after declarations are complete in convert. This follows TypePHP's declaration-only prepare model and removes the broad exception-based fallback.
Please add PHPT coverage for internal enum cases, typed enum-case class constants, enum-case-valued conditional expressions, and repeated request/ZTS-safe lifecycle behavior. The current direct user-enum tests and the forward-reference path pass, but the cases above do not.
f023bbc to
132d81e
Compare
|
Rewritten from scratch along the lines you suggested — the branch is now a single commit implementing the engine-managed design, rebased on latest master.
The PHPT now covers all your cases with Zend-8.4-validated expectations: internal enum case, typed constant, ternary expression, constant chain, |
|
CI failures fixed (new commit):
Verified locally: property defaults emit the legacy scalar again (zero ASTs outside constants), the five constant registrations still emit the AST, the full 1138-file phpt corpus front-end sweeps clean, and the related PHPUnit suites show only the pre-existing environmental failures. |
matyhtf
left a comment
There was a problem hiding this comment.
This still cannot be merged because the persistent AST representation violates Zend's internal-class teardown contract.
Running the PR's own enum-case-class-constant.phpt with a PHP 8.4 debug build produces all expected output, then aborts during shutdown:
zend_opcode.c:499: destroy_zend_class:
Assertion `Z_ASTVAL(c->value)->kind == ZEND_AST_CONST_ENUM_INIT' failed.
Aborted
initializeEnumCaseZval() registers an internal class constant whose persistent value is IS_CONSTANT_AST with root kind ZEND_AST_CLASS_CONST. In both PHP 8.4 and 8.5, destroy_zend_class() explicitly asserts that persistent constant ASTs owned by internal classes are ZEND_AST_CONST_ENUM_INIT. Release PHP builds used by CI compile out this assertion, which is why all CI jobs pass.
There is also an ownership leak: the two zend_ast_zval children are allocated by separate pemalloc() calls, while Zend's internal-class teardown only frees the allocation referenced by Z_AST(c->value). Zend's own persistent enum AST builder stores the ref, root, and children in one contiguous allocation.
Changing the root kind to ZEND_AST_CONST_ENUM_INIT is not sufficient: that node constructs another enum object instead of fetching the canonical registered case, so case identity would be broken again.
Please redesign this around a representation with a supported and complete lifecycle, or add explicit ownership/teardown infrastructure for the custom persistent AST. The PHPT must pass through process shutdown under PHP 8.4 and 8.5 debug builds, not only release CI.
|
Lifecycle redesigned per the review (rebased on current master):
I validated the mechanism with a standalone test extension against PHP 8.4.13 (contiguous layout, identity preserved through static access/ |
81f781b to
905367d
Compare
matyhtf
left a comment
There was a problem hiding this comment.
Please rebase this branch onto the latest master; #45, #52, #59, #63, and #66 have now been merged.
There is still a lifecycle blocker in the AST-constant teardown design. The generated typephp_release_ast_constants_*() function is called only from MSHUTDOWN. That ordering is valid for a normal persistent module shutdown, but it is not a general pre-class-destruction hook:
- For
MODULE_TEMPORARYmodules loaded throughdl(), Zendmodule_destructor()callsclean_module_classes()before the module shutdown callback. The foreignZEND_AST_CLASS_CONSTtherefore reachesdestroy_zend_class()first and still triggers the debug assertion. - If MINIT registers one of these constants and then fails before
module_startedbecomes true, MSHUTDOWN is not guaranteed to run either.
Please redesign this so the persistent class table never depends on a later MSHUTDOWN rewrite for Zend-safe destruction, or explicitly reject every lifecycle that cannot provide that guarantee before registering any affected class. Add a PHP debug/ZTS regression that exercises the supported shutdown and module-load paths; checking expected runtime output alone will not catch the shutdown assertion.
…onstants
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.
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.
…cycle 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.
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).
905367d to
d92eaa1
Compare
|
Rebased onto current master. The lifecycle contract is now enforced instead of assumed:
Tests: |
A class constant valued by an enum case was registered as the case's backing value (or its name string for pure enums):
enum E: int { case B = 4; } class K { const CB = E::B; }registeredZVAL_LONG(4), soK::CB === E::Bwasfalse— observable throughconstant(),$cls::CB, and reflection. Expression-valued cases (case A = 1 + 1;) also warned "Undefined property …::$value" during compilation and lost their value in compile-time metadata.Case expressions are now evaluated with the constant evaluator — leniently, since the value may reference a class declared later in the file (forward references are legal; an unresolvable value degrades to a placeholder that the stub registration resolves independently in a later phase). Enum-case-valued constants are re-bound each RINIT via
php::updateConstant(..., php::getEnumCase(...)), mirroring the array-constant mechanism, because enum case objects are request-scoped and cannot live in a MINIT zval.Verified against Zend 8.4.13; tests + phpt included.
Part of the split of #39.