Skip to content

fix(codegen): register enum-case class constants as real case objects - #51

Open
AlessioGiacobbe wants to merge 4 commits into
swoole:masterfrom
AlessioGiacobbe:split/enum-case-class-constants
Open

fix(codegen): register enum-case class constants as real case objects#51
AlessioGiacobbe wants to merge 4 commits into
swoole:masterfrom
AlessioGiacobbe:split/enum-case-class-constants

Conversation

@AlessioGiacobbe

Copy link
Copy Markdown
Contributor

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; } registered ZVAL_LONG(4), so K::CB === E::B was false — observable through constant(), $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.

@matyhtf matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. 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.

  1. 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.

  1. Only direct ClassConstFetch chains 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.

  1. 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.

@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/enum-case-class-constants branch from f023bbc to 132d81e Compare September 1, 2026 11:55
@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

Rewritten from scratch along the lines you suggested — the branch is now a single commit implementing the engine-managed design, rebased on latest master.

  1. No more RINIT/RSHUTDOWN rebinding. The constant is registered at MINIT as a persistent IS_CONSTANT_AST holding the Enum::Case fetch — the same mechanism internal enums use for their own case constants. 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: no request-owned object ever touches the persistent class entry, safe under concurrent ZTS requests, and lazy evaluation also removes any registration-order sensitivity. I validated the mechanism against PHP 8.4.13 with a standalone test extension before wiring it into gen_stub (static access, constant(), and reflection all preserve identity; typed constants keep their declared type).

  2. Internal enum cases and typed constants now work. const MODE = RoundingMode::HalfEven; registers the same AST (no more gen_stub abort), and const TypedCase CASE_VALUE = TypedCase::A; goes through zend_declare_typed_class_constant with the AST value — no scalar placeholder exists anymore, so there is nothing to violate the declared type.

  3. Constant expressions preserve identity. Case identity flows through compile-time constant evaluation as an EnumCaseRef value instead of a scalar, so true ? E::A : E::B folds to the E::A identity and constant('K::VALUE') === E::A is true; constant chains (const REF = K::CB;) follow for free. The runtime expression path (php::getEnumCase) is untouched.

  4. No more eager evaluation in prepare. The withThrowingDiagnostics machinery and the catch-all are gone. The preprocessor only records literal backing values (fixing the original ->value-off-an-expression warning); nothing at compile time needs the evaluated scalar of an expression-valued case — gen_stub evaluates the registration value from the AST itself, and null again means exactly "pure case".

The PHPT now covers all your cases with Zend-8.4-validated expectations: internal enum case, typed constant, ternary expression, constant chain, constant(), dynamic class access, reflection (value and type), and the computed backing value of case A = 1 + 1;.

@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

CI failures fixed (new commit):

  • Property/parameter defaults — the AST representation had leaked into their persistent tables, which reject refcounted zvals ("Internal zvals cannot be refcounted" at startup). EvaluatedValue now carries the case identity in a dedicated field while its value degrades to exactly what those consumers read before (host case object for internal enums, literal backing/case name for compiled ones), and only class-constant registration opts into the AST — imported-enum-defaults and default-initialization-paths are back on their unchanged runtime-restore path.
  • K::VALUE->value — a C++ precedence bug my own PHPT caught: the folded ternary of const VALUE = cond ? E::A : E::B; bound .attr("value") to its else branch only. Folded constant operands with top-level operators are now parenthesized before a member access is appended.

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 matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

Lifecycle redesigned per the review (rebased on current master):

  • One contiguous persistent allocation now holds the ast_ref, the CLASS_CONST root, and both zval children — mirroring Zend's own persistent enum AST builder — so ownership is a single block and the children can no longer leak.
  • Explicit teardown before class destruction: every generated file with AST constants emits a typephp_release_ast_constants_* function that the module's MSHUTDOWN calls before Zend's class teardown runs. It frees the block and restores the constant slot to null, so destroy_zend_class() never encounters a non-CONST_ENUM_INIT persistent AST — the debug assertion is unreachable by construction, on 8.4 and 8.5 alike. Request-local mutable copies are untouched (no request is live at MSHUTDOWN).
  • CONST_ENUM_INIT itself was ruled out for the reason you gave: it constructs a new case object instead of fetching the canonical one, which would break identity again.

I validated the mechanism with a standalone test extension against PHP 8.4.13 (contiguous layout, identity preserved through static access/constant()/reflection, clean MSHUTDOWN release and process exit). I don't have a debug build locally, so the assertion itself I can only make unreachable by construction — the constant is no longer IS_CONSTANT_AST by the time class teardown runs; if you can re-run the PHPT on your debug build, that would be the definitive check.

@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/enum-case-class-constants branch 2 times, most recently from 81f781b to 905367d Compare September 2, 2026 09:19

@matyhtf matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_TEMPORARY modules loaded through dl(), Zend module_destructor() calls clean_module_classes() before the module shutdown callback. The foreign ZEND_AST_CLASS_CONST therefore reaches destroy_zend_class() first and still triggers the debug assertion.
  • If MINIT registers one of these constants and then fails before module_started becomes 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).
@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/enum-case-class-constants branch from 905367d to d92eaa1 Compare September 2, 2026 13:17
@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

Rebased onto current master. The lifecycle contract is now enforced instead of assumed:

  • When the module declares any enum-case AST constants, the generated MINIT opens with a MODULE_TEMPORARY guard (zend_error + return FAILURE) before any class registration — a dl() load is rejected with an empty class table, so clean_module_classes() never sees a foreign ZEND_AST_CLASS_CONST.
  • MINIT is restructured so every step that can return FAILURE precedes the first register_class_*() call; the AST constants are installed by the infallible registration tail, so a failing MINIT can never leave a foreign AST in the persistent tables. The generator itself throws at build time if a future change emits a FAILURE return after registration begins.
  • The MSHUTDOWN release is unchanged and remains the supported persistent-module path.

Tests: EnumCaseAstConstantLifecycleTest asserts the guard exists exactly when AST constants exist, precedes every registration step, that no FAILURE return follows the first registration, and that the release call is MSHUTDOWN's first statement. The phpt now also carries a never-accessed constant so a pristine persistent AST goes through full process shutdown (a debug build turns a violation into an output mismatch via the assert on stderr). A runnable dl()-path phpt is not feasible in this harness — tests are linked into standalone binaries with persistently registered modules only — which is documented in the test; that lifecycle is now rejected at MINIT rather than left undefined.

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.

2 participants