Skip to content

Fix GH-18714: opcache_compile_file() breaks class hoisting#21470

Open
iliaal wants to merge 2 commits intophp:masterfrom
iliaal:fix/gh-18714-opcache-compile-file-class-hoisting
Open

Fix GH-18714: opcache_compile_file() breaks class hoisting#21470
iliaal wants to merge 2 commits intophp:masterfrom
iliaal:fix/gh-18714-opcache-compile-file-class-hoisting

Conversation

@iliaal
Copy link
Contributor

@iliaal iliaal commented Mar 18, 2026

Summary

opcache_compile_file() followed by require_once on the same file fails with "Class not found" when the class is used before its declaration (relying on class hoisting).

Root cause: opcache_compile_file() sets ZEND_COMPILE_WITHOUT_EXECUTION which prevents simple parentless classes from being linked during compilation. The cached script stores the class as unlinked, and opcache's delayed early binding can't resolve it -- the class has no parent to look up but isn't marked as linked either, so the binding is silently skipped.

Fix (two parts):

  • Zend/zend_compile.c: Add a separate block after the existing early-binding logic that links simple classes (no parent, no traits, no interfaces) during ZEND_COMPILE_WITHOUT_EXECUTION. Linking is purely structural (zend_build_properties_info_table + zend_inheritance_check_override + ZEND_ACC_LINKED flag) with no execution side effects. Skipped during preloading which has its own class linking pipeline. The existing early-binding block is untouched.

  • ext/opcache/zend_accelerator_module.c: opcache_compile_file() goes through persistent_compile_file() which calls zend_accel_load_script(), registering classes/functions in the runtime tables. This causes duplicate registration when require_once later loads the same file. Clean up via zend_hash_discard() after compilation returns. Cleanup is skipped during preloading where registrations must persist.

Fixes #18714

opcache_compile_file() sets ZEND_COMPILE_WITHOUT_EXECUTION which
prevented simple parentless classes from being linked during
compilation. When the cached script was later loaded via require,
opcache's delayed early binding couldn't resolve the unlinked class,
causing "Class not found" errors for classes used before their
declaration.

Two changes:

1. zend_compile.c: Allow linking simple classes (no parent, no
   interfaces, no traits) even with ZEND_COMPILE_WITHOUT_EXECUTION.
   This is purely structural with no execution side effects. The
   change is scoped to non-preload compilation to avoid interfering
   with preloading's own class linking pipeline.

2. zend_accelerator_module.c: Clean up classes/functions registered
   by zend_accel_load_script() after opcache_compile_file() returns.
   The function should only cache, not pollute the runtime tables.
   Cleanup is skipped during preloading where registrations must
   persist.

Closes phpGH-18714
Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

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

Can you please try to minimize the changes in zend_compile.c. Or separate significant changes from indentation in different commits.

Also see my thought in comments.

}
ht->pDestructor = orig_dtor;
}

Copy link
Member

Choose a reason for hiding this comment

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

You can use ZEND_HASH_MAP_FOREACH_END_DEL. See examples in Zend/zend_execute_API.c and ext/opcache/ZendAccelerator.c.

Copy link
Contributor Author

@iliaal iliaal Mar 23, 2026

Choose a reason for hiding this comment

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

Switched to zend_hash_discard(), which does the same thing in one call: removes entries from nNumUsed back to a given point, updates hash chains, skips the destructor. Dropped the custom accel_rollback_hash function.

Comment on lines +1026 to +1032
/* Undo classes/functions registered by zend_accel_load_script().
* opcache_compile_file() should only cache without side effects.
* Skip during preloading: preload needs the registrations to persist. */
if (!(orig_compiler_options & ZEND_COMPILE_PRELOAD)) {
accel_rollback_hash(EG(class_table), orig_class_count);
accel_rollback_hash(EG(function_table), orig_function_count);
}
Copy link
Member

Choose a reason for hiding this comment

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

I think the old behavior may make sense not only for preloading.
Changing it may cause troubles for existing use cases.

I would propose to introduce the new behavior with an optional boolean argument, keeping the old behavior by default. What do you think?

Copy link
Contributor Author

@iliaal iliaal Mar 23, 2026

Choose a reason for hiding this comment

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

I think the registration is a side effect of the implementation path (persistent_compile_filezend_accel_load_script), not intentional behavior. The function compiles with ZEND_COMPILE_WITHOUT_EXECUTION, and registering classes and functions in the runtime tables is an execution side effect.

Users who call opcache_compile_file() then require_once the same file hit this bug because of that side effect: duplicate class/function registration. Making the fix opt-in means anyone who hits #18714 needs to discover a new parameter to get correct behavior.

If there are known use cases that depend on the registration happening, I'm open to adding the argument. I haven't found any in the docs or issue tracker though -- the documented purpose is cache warming, not class loading.

- zend_compile.c: keep original block untouched, add separate block
  for ZEND_COMPILE_WITHOUT_EXECUTION class linking after it
- zend_accelerator_module.c: replace custom accel_rollback_hash with
  built-in zend_hash_discard()
@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

Restructured. The zend_compile.c diff is now a pure addition: the original block stays untouched, and the ZEND_COMPILE_WITHOUT_EXECUTION linking lives in a separate block after it. Also swapped the custom accel_rollback_hash for zend_hash_discard().

@iliaal iliaal requested a review from dstogov March 23, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opcache does not handle class hoisting with @opcache_compile_file

2 participants