Commit 7c98e65ca01 for php.net
commit 7c98e65ca0196f6b397161b8489d258163a701f7
Author: Pratik Bhujel <prateekbhujelpb@gmail.com>
Date: Sun Jun 14 14:36:22 2026 +0545
Fix GH-22118: Compare equivalent fake closures in FCCs (#22145)
The reason fake closures didn't compare as equal was that two different objects could represent the same fake closure.
Therefore, we need to do a more detailed comparison when one or both operands are closures rather than just comparing pointers.
A good follow-up would be to draft an RFC to fix the semantics of `==` with two different instances of the same fake closure.
diff --git a/Zend/zend_API.c b/Zend/zend_API.c
index 65834adbaff..789b3ac2642 100644
--- a/Zend/zend_API.c
+++ b/Zend/zend_API.c
@@ -4096,6 +4096,52 @@ ZEND_API zend_string *zend_get_callable_name_ex(const zval *callable, const zend
}
/* }}} */
+static bool zend_fcc_function_handler_equals(const zend_function *func1, const zend_function *func2) /* {{{ */
+{
+ if (func1 == func2) {
+ return true;
+ }
+
+ const bool fake_closure1 = (func1->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) != 0;
+ const bool fake_closure2 = (func2->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) != 0;
+
+ if (!fake_closure1 && !fake_closure2) {
+ return false;
+ }
+ if (((func1->common.fn_flags & ZEND_ACC_CLOSURE) && !fake_closure1) ||
+ ((func2->common.fn_flags & ZEND_ACC_CLOSURE) && !fake_closure2)) {
+ return false;
+ }
+ if (func1->type != func2->type ||
+ func1->common.scope != func2->common.scope ||
+ !zend_string_equals(func1->common.function_name, func2->common.function_name)) {
+ return false;
+ }
+
+ if (func1->type == ZEND_USER_FUNCTION) {
+ return func1->op_array.opcodes == func2->op_array.opcodes;
+ }
+
+ return func1->internal_function.handler == func2->internal_function.handler;
+}
+/* }}} */
+
+ZEND_API bool zend_fcc_closure_equals_ex(const zend_fcall_info_cache* a, const zend_fcall_info_cache* b) /* {{{ */
+{
+ const zend_function *func1 = a->function_handler;
+ const zend_function *func2 = b->function_handler;
+
+ if (a->closure && a->closure->ce == zend_ce_closure) {
+ func1 = zend_get_closure_method_def(a->closure);
+ }
+ if (b->closure && b->closure->ce == zend_ce_closure) {
+ func2 = zend_get_closure_method_def(b->closure);
+ }
+
+ return zend_fcc_function_handler_equals(func1, func2);
+}
+/* }}} */
+
ZEND_API zend_string *zend_get_callable_name(const zval *callable) /* {{{ */
{
return zend_get_callable_name_ex(callable, NULL);
diff --git a/Zend/zend_API.h b/Zend/zend_API.h
index 2487c8b632f..593be26788d 100644
--- a/Zend/zend_API.h
+++ b/Zend/zend_API.h
@@ -758,20 +758,27 @@ ZEND_API void zend_fcall_info_argn(zend_fcall_info *fci, uint32_t argc, ...);
ZEND_API zend_result zend_fcall_info_call(zend_fcall_info *fci, zend_fcall_info_cache *fcc, zval *retval, zval *args);
/* Zend FCC API to store and handle PHP userland functions */
+ZEND_API bool zend_fcc_closure_equals_ex(const zend_fcall_info_cache* a, const zend_fcall_info_cache* b);
+
static zend_always_inline bool zend_fcc_equals(const zend_fcall_info_cache* a, const zend_fcall_info_cache* b)
{
+ if (a->closure || b->closure) {
+ return a->object == b->object
+ && a->calling_scope == b->calling_scope
+ && a->called_scope == b->called_scope
+ && (a->closure == b->closure || zend_fcc_closure_equals_ex(a, b))
+ ;
+ }
if (UNEXPECTED((a->function_handler->common.fn_flags & ZEND_ACC_CALL_VIA_TRAMPOLINE) &&
(b->function_handler->common.fn_flags & ZEND_ACC_CALL_VIA_TRAMPOLINE))) {
return a->object == b->object
&& a->calling_scope == b->calling_scope
- && a->closure == b->closure
&& zend_string_equals(a->function_handler->common.function_name, b->function_handler->common.function_name)
;
}
return a->function_handler == b->function_handler
&& a->object == b->object
&& a->calling_scope == b->calling_scope
- && a->closure == b->closure
;
}
diff --git a/ext/spl/tests/autoloading/gh22118.phpt b/ext/spl/tests/autoloading/gh22118.phpt
new file mode 100644
index 00000000000..635d876c274
--- /dev/null
+++ b/ext/spl/tests/autoloading/gh22118.phpt
@@ -0,0 +1,58 @@
+--TEST--
+GH-22118: spl_autoload_unregister() unregisters equivalent first-class method callables
+--FILE--
+<?php
+function autoload_string(string $class): void
+{
+ echo "autoload $class\n";
+}
+
+class AutoloadTest
+{
+ public function load(string $class): void
+ {
+ echo "autoload $class\n";
+ }
+
+ public function __call(string $name, array $arguments): void
+ {
+ echo "autoload {$arguments[0]}\n";
+ }
+
+ public function __invoke(string $class): void
+ {
+ echo "autoload $class\n";
+ }
+
+ public function run(): void
+ {
+ spl_autoload_register($this->load(...));
+ var_dump(spl_autoload_unregister($this->load(...)));
+ spl_autoload_call('MissingDirect');
+
+ spl_autoload_register($this->missing(...));
+ var_dump(spl_autoload_unregister($this->missing(...)));
+ spl_autoload_call('MissingTrampoline');
+
+ spl_autoload_register($this->load(...));
+ var_dump(spl_autoload_unregister([$this, 'load']));
+ spl_autoload_call('MissingArray');
+
+ spl_autoload_register($this(...));
+ var_dump(spl_autoload_unregister($this));
+ spl_autoload_call('MissingInvokable');
+ }
+}
+
+spl_autoload_register(autoload_string(...));
+var_dump(spl_autoload_unregister('autoload_string'));
+spl_autoload_call('MissingString');
+
+(new AutoloadTest())->run();
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/general_functions/gh22118.phpt b/ext/standard/tests/general_functions/gh22118.phpt
new file mode 100644
index 00000000000..32c1a0fa1de
--- /dev/null
+++ b/ext/standard/tests/general_functions/gh22118.phpt
@@ -0,0 +1,75 @@
+--TEST--
+GH-22118: unregister_tick_function() unregisters equivalent first-class method callables
+--FILE--
+<?php
+declare(ticks=1);
+
+$string = 0;
+
+function tick_string(): void
+{
+ global $string;
+ $string++;
+}
+
+class TickTest
+{
+ public int $direct = 0;
+ public int $trampoline = 0;
+ public int $array = 0;
+ public int $invoke = 0;
+
+ public function kick(): void
+ {
+ $this->direct++;
+ }
+
+ public function arrayCallable(): void
+ {
+ $this->array++;
+ }
+
+ public function __invoke(): void
+ {
+ $this->invoke++;
+ }
+
+ public function __call(string $name, array $arguments): void
+ {
+ $this->trampoline++;
+ }
+
+ public function run(): void
+ {
+ register_tick_function($this->kick(...));
+ unregister_tick_function($this->kick(...));
+ echo "direct: {$this->direct}\n";
+
+ register_tick_function($this->missing(...));
+ unregister_tick_function($this->missing(...));
+ echo "trampoline: {$this->trampoline}\n";
+
+ register_tick_function($this->arrayCallable(...));
+ unregister_tick_function([$this, 'arrayCallable']);
+ echo "array: {$this->array}\n";
+
+ register_tick_function($this(...));
+ unregister_tick_function($this);
+ echo "invoke: {$this->invoke}\n";
+ }
+}
+
+register_tick_function(tick_string(...));
+unregister_tick_function('tick_string');
+echo "string: {$string}\n";
+
+(new TickTest())->run();
+echo "done\n";
+?>
+--EXPECT--
+string: 1
+direct: 1
+trampoline: 1
+array: 1
+invoke: 1
+done