Commit 2c5dc5d8b1f for php.net

commit 2c5dc5d8b1f9c77b706b7e48801a8acd0cdfffb1
Author: Arnaud Le Blanc <arnaud.lb@gmail.com>
Date:   Tue Apr 21 11:04:32 2026 +0200

    Add ZEND_ACC2_FORBID_DYN_CALLS

    Functions that use zend_forbid_dynamic_call() must be flagged with
    ZEND_ACC2_FORBID_DYN_CALLS. In stubs, this is done by using
    @forbid-dynamic-calls.

    To ensure consistency, we assert that the flag exists in
    zend_forbid_dynamic_call(), and we assert that flagged functions thrown after a
    dynamic call.

    Closes GH-21818

diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index 3a24fcce477..1d53df7e4a3 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -88,6 +88,9 @@ PHP 8.6 INTERNALS UPGRADE NOTES
     ZEND_AST_TRAIT_METHOD_REFERENCE.
   . The EMPTY_SWITCH_DEFAULT_CASE() macro has been removed. Use
     default: ZEND_UNREACHABLE(); instead.
+  . Functions using zend_forbid_dynamic_call() *must* be flagged with
+    ZEND_ACC2_FORBID_DYN_CALLS (@forbid-dynamic-calls in stubs). In debug
+    builds, failing to include that flag will lead to assertion failures.

 ========================
 2. Build system changes
diff --git a/Zend/zend_API.h b/Zend/zend_API.h
index aff6a5bba22..9eaa1ec7b3c 100644
--- a/Zend/zend_API.h
+++ b/Zend/zend_API.h
@@ -896,6 +896,8 @@ static zend_always_inline zend_result zend_forbid_dynamic_call(void)
 	const zend_execute_data *ex = EG(current_execute_data);
 	ZEND_ASSERT(ex != NULL && ex->func != NULL);

+	ZEND_ASSERT(ex->func->common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS);
+
 	if (ZEND_CALL_INFO(ex) & ZEND_CALL_DYNAMIC) {
 		zend_string *function_or_method_name = get_active_function_or_method_name();
 		zend_throw_error(NULL, "Cannot call %.*s() dynamically",
diff --git a/Zend/zend_builtin_functions.c b/Zend/zend_builtin_functions.c
index 1fe4bcf3ee5..c19bf2779fb 100644
--- a/Zend/zend_builtin_functions.c
+++ b/Zend/zend_builtin_functions.c
@@ -16,6 +16,7 @@
    +----------------------------------------------------------------------+
 */

+#include "php_version.h"
 #include "zend.h"
 #include "zend_API.h"
 #include "zend_attributes.h"
diff --git a/Zend/zend_builtin_functions.stub.php b/Zend/zend_builtin_functions.stub.php
index 9b2267b531e..1d405587145 100644
--- a/Zend/zend_builtin_functions.stub.php
+++ b/Zend/zend_builtin_functions.stub.php
@@ -18,11 +18,16 @@ function die(string|int $status = 0): never {}
 /** @refcount 1 */
 function zend_version(): string {}

+/** @forbid-dynamic-calls */
 function func_num_args(): int {}

+/** @forbid-dynamic-calls */
 function func_get_arg(int $position): mixed {}

-/** @return array<int, mixed> */
+/**
+ * @return array<int, mixed>
+ * @forbid-dynamic-calls
+ */
 function func_get_args(): array {}

 function strlen(string $string): int {}
@@ -156,6 +161,7 @@ function get_defined_functions(bool $exclude_disabled = true): array {}
 /**
  * @return array<string, mixed|ref>
  * @refcount 1
+ * @forbid-dynamic-calls
  */
 function get_defined_vars(): array {}

diff --git a/Zend/zend_builtin_functions_arginfo.h b/Zend/zend_builtin_functions_arginfo.h
index cb626ff430e..b3af43fef34 100644
Binary files a/Zend/zend_builtin_functions_arginfo.h and b/Zend/zend_builtin_functions_arginfo.h differ
diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c
index 56090cddcaf..314abede406 100644
--- a/Zend/zend_closures.c
+++ b/Zend/zend_closures.c
@@ -749,6 +749,7 @@ static ZEND_NAMED_FUNCTION(zend_closure_internal_handler) /* {{{ */
 {
 	zend_closure *closure = (zend_closure*)ZEND_CLOSURE_OBJECT(EX(func));
 	closure->orig_internal_handler(INTERNAL_FUNCTION_PARAM_PASSTHRU);
+	ZEND_ASSERT(!(closure->func.common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS) || EG(exception));
 	// Assign to EX(this) so that it is released after observer checks etc.
 	ZEND_ADD_CALL_FLAG(execute_data, ZEND_CALL_RELEASE_THIS);
 	Z_OBJ(EX(This)) = &closure->std;
diff --git a/Zend/zend_compile.h b/Zend/zend_compile.h
index 3b85d52c187..8f67fee2a52 100644
--- a/Zend/zend_compile.h
+++ b/Zend/zend_compile.h
@@ -412,10 +412,11 @@ typedef struct _zend_oparray_context {
 /* op_array uses strict mode types                        |     |     |     */
 #define ZEND_ACC_STRICT_TYPES            (1U << 31) /*    |  X  |     |     */
 /*                                                        |     |     |     */
-/* Function Flags 2 (fn_flags2) (unused: 0-31)            |     |     |     */
+/* Function Flags 2 (fn_flags2) (unused: 1-31)            |     |     |     */
 /* ============================                           |     |     |     */
 /*                                                        |     |     |     */
-/* #define ZEND_ACC2_EXAMPLE             (1 << 0)         |  X  |     |     */
+/* Function forbids dynamic calls                         |     |     |     */
+#define ZEND_ACC2_FORBID_DYN_CALLS       (1 << 0)  /*     |  X  |     |     */

 #define ZEND_ACC_PPP_MASK  (ZEND_ACC_PUBLIC | ZEND_ACC_PROTECTED | ZEND_ACC_PRIVATE)
 #define ZEND_ACC_PPP_SET_MASK  (ZEND_ACC_PUBLIC_SET | ZEND_ACC_PROTECTED_SET | ZEND_ACC_PRIVATE_SET)
diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
index 5bead7034b9..0022eb4a1df 100644
--- a/Zend/zend_execute_API.c
+++ b/Zend/zend_execute_API.c
@@ -1030,6 +1030,7 @@ zend_result zend_call_function(zend_fcall_info *fci, zend_fcall_info_cache *fci_
 			}
 			ZEND_ASSERT((call->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE)
 				? Z_ISREF_P(fci->retval) : !Z_ISREF_P(fci->retval));
+			ZEND_ASSERT(!(call->func->common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS));
 		}
 #endif
 		ZEND_OBSERVER_FCALL_END(call, fci->retval);
diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h
index 391b82241e4..1f7e09d1be3 100644
--- a/Zend/zend_vm_def.h
+++ b/Zend/zend_vm_def.h
@@ -4160,6 +4160,8 @@ ZEND_VM_HOT_HANDLER(129, ZEND_DO_ICALL, ANY, ANY, SPEC(RETVAL,OBSERVER))
 		ZEND_ASSERT((call->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE)
 			? Z_ISREF_P(ret) : !Z_ISREF_P(ret));
 		zend_verify_internal_func_info(call->func, ret);
+		ZEND_ASSERT(!(ZEND_CALL_INFO(call) & ZEND_CALL_DYNAMIC)
+				|| !(call->func->common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS));
 	}
 #endif
 	ZEND_OBSERVER_FCALL_END(call, EG(exception) ? NULL : ret);
@@ -4291,6 +4293,8 @@ ZEND_VM_HOT_HANDLER(131, ZEND_DO_FCALL_BY_NAME, ANY, ANY, SPEC(RETVAL,OBSERVER))
 			ZEND_ASSERT((call->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE)
 				? Z_ISREF_P(ret) : !Z_ISREF_P(ret));
 			zend_verify_internal_func_info(call->func, ret);
+			ZEND_ASSERT(!(ZEND_CALL_INFO(call) & ZEND_CALL_DYNAMIC)
+					|| !(call->func->common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS));
 		}
 		ZEND_ASSERT(opline->result_type != IS_TMP_VAR || !Z_ISREF_P(ret));
 #endif
@@ -4422,6 +4426,8 @@ ZEND_VM_HOT_HANDLER(60, ZEND_DO_FCALL, ANY, ANY, SPEC(RETVAL,OBSERVER))
 			ZEND_ASSERT((call->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE)
 				? Z_ISREF_P(ret) : !Z_ISREF_P(ret));
 			zend_verify_internal_func_info(call->func, ret);
+			ZEND_ASSERT(!(ZEND_CALL_INFO(call) & ZEND_CALL_DYNAMIC)
+					|| !(call->func->common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS));
 		}
 		ZEND_ASSERT(opline->result_type != IS_TMP_VAR || !Z_ISREF_P(ret));
 #endif
@@ -9131,6 +9137,8 @@ ZEND_VM_HANDLER(158, ZEND_CALL_TRAMPOLINE, ANY, ANY, SPEC(OBSERVER))
 			ZEND_ASSERT((call->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE)
 				? Z_ISREF_P(ret) : !Z_ISREF_P(ret));
 			zend_verify_internal_func_info(call->func, ret);
+			ZEND_ASSERT(!(ZEND_CALL_INFO(call) & ZEND_CALL_DYNAMIC)
+					|| !(call->func->common.fn_flags2 & ZEND_ACC2_FORBID_DYN_CALLS));
 		}
 #endif
 		ZEND_OBSERVER_FCALL_END(call, EG(exception) ? NULL : ret);
diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h
index a2e5eac491d..d5860da23b4 100644
Binary files a/Zend/zend_vm_execute.h and b/Zend/zend_vm_execute.h differ
diff --git a/build/gen_stub.php b/build/gen_stub.php
index 857c6cb7087..619c4c905b6 100755
--- a/build/gen_stub.php
+++ b/build/gen_stub.php
@@ -1304,6 +1304,7 @@ public function __construct(
         public ?FunctionOrMethodName $alias,
         private readonly bool $isDeprecated,
         private bool $supportsCompileTimeEval,
+        private bool $forbidDynamicCalls,
         public readonly bool $verify,
         public /* readonly */ array $args,
         public /* readonly */ ReturnInfo $return,
@@ -1611,6 +1612,10 @@ private function getArginfoFlagsByPhpVersions(): VersionFlags
             $flags->addForVersionsAbove("ZEND_ACC_NODISCARD", PHP_85_VERSION_ID);
         }

+        if ($this->forbidDynamicCalls) {
+            $flags->addForVersionsAbove("ZEND_ACC2_FORBID_DYN_CALLS", PHP_86_VERSION_ID);
+        }
+
         return $flags;
     }

@@ -4797,6 +4802,7 @@ function parseFunctionLike(
         $alias = null;
         $isDeprecated = false;
         $supportsCompileTimeEval = false;
+        $forbidDynamicCalls = false;
         $verify = true;
         $docReturnType = null;
         $tentativeReturnType = false;
@@ -4812,6 +4818,7 @@ function parseFunctionLike(
             $verify = !array_key_exists('no-verify', $tagMap);
             $tentativeReturnType = array_key_exists('tentative-return-type', $tagMap);
             $supportsCompileTimeEval = array_key_exists('compile-time-eval', $tagMap);
+            $forbidDynamicCalls = array_key_exists('forbid-dynamic-calls', $tagMap);
             $isUndocumentable = $isUndocumentable || array_key_exists('undocumentable', $tagMap);

             foreach ($tags as $tag) {
@@ -4944,6 +4951,7 @@ function parseFunctionLike(
             $alias,
             $isDeprecated,
             $supportsCompileTimeEval,
+            $forbidDynamicCalls,
             $verify,
             $args,
             $return,
diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php
index 0f04cc036d4..1999c9b92be 100644
--- a/ext/standard/basic_functions.stub.php
+++ b/ext/standard/basic_functions.stub.php
@@ -1652,6 +1652,7 @@ function extract(array &$array, int $flags = EXTR_OVERWRITE, string $prefix = ""
  * @param array|string $var_names
  * @return array<string, mixed|ref>
  * @refcount 1
+ * @forbid-dynamic-calls
  */
 function compact($var_name, ...$var_names): array {}

diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h
index 7ad59cfc689..e51a837ffa4 100644
Binary files a/ext/standard/basic_functions_arginfo.h and b/ext/standard/basic_functions_arginfo.h differ
diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h
index 0dda2f894f1..b3eb25c5d98 100644
Binary files a/ext/standard/basic_functions_decl.h and b/ext/standard/basic_functions_decl.h differ
diff --git a/ext/zend_test/test.stub.php b/ext/zend_test/test.stub.php
index c9d367d5553..653630ed73b 100644
--- a/ext/zend_test/test.stub.php
+++ b/ext/zend_test/test.stub.php
@@ -187,7 +187,9 @@ class ZendTestClassWithPropertyAttribute {
     }

     final class ZendTestForbidDynamicCall {
+        /** @forbid-dynamic-calls */
         public function call(): void {}
+        /** @forbid-dynamic-calls */
         public static function callStatic(): void {}
     }

diff --git a/ext/zend_test/test_arginfo.h b/ext/zend_test/test_arginfo.h
index a4da05df2ff..adcae16cdf6 100644
Binary files a/ext/zend_test/test_arginfo.h and b/ext/zend_test/test_arginfo.h differ
diff --git a/ext/zend_test/test_decl.h b/ext/zend_test/test_decl.h
index bc2ebaa93c3..ba6aab90228 100644
Binary files a/ext/zend_test/test_decl.h and b/ext/zend_test/test_decl.h differ
diff --git a/ext/zend_test/test_legacy_arginfo.h b/ext/zend_test/test_legacy_arginfo.h
index b446a0f9a29..b42d524d7a8 100644
Binary files a/ext/zend_test/test_legacy_arginfo.h and b/ext/zend_test/test_legacy_arginfo.h differ