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