Commit b9cf5d8151a for php

commit b9cf5d8151a62a16a2803b30eea49bc313e71931
Author: Alessio Attilio <attilio.alessio@protonmail.com>
Date:   Wed Nov 11 17:19:37 2026 +0100

    Add per-extension child initialization hooks (#24647)

    Add zend_module_entry.child_init_func and invoke it from php_child_init()
    for started modules in dependency order after core child initialization.
    Expose PHP_CINIT/PHP_CINIT_FUNCTION macros and use named initializers for
    the touched module properties.

    In ext/session, seed GC randomness during GINIT and advance the PRNG state
    in the child init hook so forked children do not share the same GC stream.
    Keep session GC state in TLS globals.

    Fixes GH-22616.

diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index 10f54d2395c..b5134705b37 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -14,6 +14,12 @@ PHP 8.7 INTERNALS UPGRADE NOTES
 1. Internal API changes
 ========================

+- Added zend_module_entry.child_init_func, an optional void callback invoked by
+  php_child_init() in the child process after core child initialization. It runs
+  in module dependency order for started modules and may run before a request
+  starts or during an active request. Initialize it with a designated
+  initializer such as `.child_init_func = callback` after
+  STANDARD_MODULE_PROPERTIES_EX.
 - Removed the ZEND_LOG_XOR() macro.
 - Removed the ZEND_TRUTH() macro.
 - Removed zend_execute_scripts(). Manually call zend_execute_script() in a loop
diff --git a/Zend/zend_modules.h b/Zend/zend_modules.h
index 945205c4f47..a941fd063a5 100644
--- a/Zend/zend_modules.h
+++ b/Zend/zend_modules.h
@@ -45,7 +45,12 @@

 #define ZEND_MODULE_BUILD_ID "API" ZEND_TOSTR(ZEND_MODULE_API_NO) ZEND_BUILD_TS ZEND_BUILD_DEBUG ZEND_BUILD_SYSTEM ZEND_BUILD_EXTRA

-#define STANDARD_MODULE_PROPERTIES_EX 0, 0, NULL, 0, ZEND_MODULE_BUILD_ID
+#define STANDARD_MODULE_PROPERTIES_EX \
+	.module_started = 0, \
+	.type = 0, \
+	.handle = NULL, \
+	.module_number = 0, \
+	.build_id = ZEND_MODULE_BUILD_ID

 #define NO_MODULE_GLOBALS 0, NULL, NULL, NULL

@@ -91,6 +96,7 @@ struct _zend_module_entry {
 	void (*globals_ctor)(void *global);
 	void (*globals_dtor)(void *global);
 	zend_result (*post_deactivate_func)(void);
+	void (*child_init_func)(void);
 	int module_started;
 	unsigned char type;
 	void *handle;
diff --git a/ext/session/php_session.h b/ext/session/php_session.h
index ee36eb291e5..7711d9432dc 100644
--- a/ext/session/php_session.h
+++ b/ext/session/php_session.h
@@ -183,8 +183,6 @@ typedef struct _php_ps_globals {
 	bool mod_user_is_open;
 	bool mod_user_uses_object_methods_as_handlers;
 	bool use_trans_sid; /* contains the INI value of whether to use trans-sid */
-	bool random_seeded;
-
 	bool in_rfc1867_update;    /* php_session_rfc1867_update() is running */
 } php_ps_globals;

diff --git a/ext/session/session.c b/ext/session/session.c
index 702b56020ae..c15941b7452 100644
--- a/ext/session/session.c
+++ b/ext/session/session.c
@@ -409,18 +409,6 @@ static zend_long php_session_gc(bool immediate)
 	if ((PS(mod_data) || PS(mod_user_implemented))) {
 		/* Use probability-based GC if not forced and probability is configured */
 		if (!collect && PS(gc_probability) > 0) {
-			/* Seed lazily on first GC draw per process. */
-			if (UNEXPECTED(!PS(random_seeded))) {
-				php_random_uint128_t seed;
-				if (php_random_bytes_silent(&seed, sizeof(seed)) == FAILURE) {
-					seed = php_random_uint128_constant(
-						php_random_generate_fallback_seed(),
-						php_random_generate_fallback_seed()
-					);
-				}
-				php_random_pcgoneseq128xslrr64_seed128(PS(random).state, seed);
-				PS(random_seeded) = true;
-			}
 			collect = php_random_range(PS(random), 0, PS(gc_divisor) - 1) < PS(gc_probability);
 		}

@@ -3019,7 +3007,15 @@ static PHP_GINIT_FUNCTION(ps)
 		.algo = &php_random_algo_pcgoneseq128xslrr64,
 		.state = &ps_globals->random_state,
 	};
-	ps_globals->random_seeded = false;
+
+	php_random_uint128_t seed;
+	if (php_random_bytes_silent(&seed, sizeof(seed)) == FAILURE) {
+		seed = php_random_uint128_constant(
+			php_random_generate_fallback_seed(),
+			php_random_generate_fallback_seed()
+		);
+	}
+	php_random_pcgoneseq128xslrr64_seed128(ps_globals->random.state, seed);
 }

 /* Interfaces extending the given one are not flattened into ce->interfaces before they are
@@ -3454,6 +3450,13 @@ static zend_result php_session_rfc1867_callback(unsigned int event, void *event_
 	return retval;
 }

+PHP_CINIT_FUNCTION(session)
+{
+	/* Give each child a distinct, widely separated GC sequence without requesting new entropy. */
+	uint64_t advance = (uint64_t) getpid() << 32;
+	php_random_pcgoneseq128xslrr64_advance(PS(random).state, advance);
+}
+
 zend_module_entry session_module_entry = {
 	STANDARD_MODULE_HEADER,
 	"session",
@@ -3466,7 +3469,8 @@ zend_module_entry session_module_entry = {
 	PHP_GINIT(ps),
 	NULL,
 	NULL,
-	STANDARD_MODULE_PROPERTIES_EX
+	STANDARD_MODULE_PROPERTIES_EX,
+	.child_init_func = PHP_CINIT(session)
 };

 #ifdef COMPILE_DL_SESSION
diff --git a/ext/session/tests/gh22616.phpt b/ext/session/tests/gh22616.phpt
new file mode 100644
index 00000000000..13235dede56
--- /dev/null
+++ b/ext/session/tests/gh22616.phpt
@@ -0,0 +1,99 @@
+--TEST--
+GH-22616: Session GC randomness is independent after a fork
+--EXTENSIONS--
+session
+pcntl
+--INI--
+session.gc_probability=1
+session.gc_divisor=2
+session.use_cookies=0
+session.cache_limiter=
+--FILE--
+<?php
+$handler = new class implements SessionHandlerInterface, SessionIdInterface, SessionUpdateTimestampHandlerInterface {
+    public bool $collected = false;
+
+    public function create_sid(): string { return 'gh22616'; }
+    public function validateId(string $id): bool { return true; }
+    public function updateTimestamp(string $id, string $data): bool { return true; }
+    public function open(string $path, string $name): bool { return true; }
+    public function close(): bool { return true; }
+    public function read(string $id): string|false { return ''; }
+    public function write(string $id, string $data): bool { return true; }
+    public function destroy(string $id): bool { return true; }
+    public function gc(int $max_lifetime): int|false {
+        $this->collected = true;
+        return 0;
+    }
+};
+session_set_save_handler($handler);
+session_id('gh22616');
+
+function sequence(): string {
+    global $handler;
+    if (session_status() === PHP_SESSION_ACTIVE) {
+        session_write_close();
+    }
+    $result = '';
+    for ($i = 0; $i < 256; $i++) {
+        $handler->collected = false;
+        session_start();
+        session_write_close();
+        $result .= (int) $handler->collected;
+    }
+    return $result;
+}
+
+$results = [];
+foreach (['unseeded', 'seeded', 'active'] as $state) {
+    if ($state === 'active') {
+        session_start();
+    }
+    $file1 = tempnam(sys_get_temp_dir(), 'gh22616');
+    $pid1 = pcntl_fork();
+    if ($pid1 === -1) {
+        throw new Exception('fork failed');
+    }
+    if ($pid1 === 0) {
+        file_put_contents($file1, sequence());
+        exit;
+    }
+
+    $file2 = tempnam(sys_get_temp_dir(), 'gh22616');
+    $pid2 = pcntl_fork();
+    if ($pid2 === -1) {
+        throw new Exception('fork failed');
+    }
+    if ($pid2 === 0) {
+        file_put_contents($file2, sequence());
+        exit;
+    }
+
+    $parent = sequence();
+    pcntl_waitpid($pid1, $status1);
+    pcntl_waitpid($pid2, $status2);
+    $child1 = file_get_contents($file1);
+    $child2 = file_get_contents($file2);
+    unlink($file1);
+    unlink($file2);
+    $results[$state] = pcntl_wifexited($status1)
+        && pcntl_wexitstatus($status1) === 0
+        && pcntl_wifexited($status2)
+        && pcntl_wexitstatus($status2) === 0
+        && strlen($child1) === 256
+        && strlen($child2) === 256
+        && $parent !== $child1
+        && $parent !== $child2
+        && $child1 !== $child2;
+}
+var_dump($results);
+?>
+--EXPECT--
+array(3) {
+  ["unseeded"]=>
+  bool(true)
+  ["seeded"]=>
+  bool(true)
+  ["active"]=>
+  bool(true)
+}
diff --git a/ext/zend_test/php_test.h b/ext/zend_test/php_test.h
index db6feede7ca..34f6d7c30bf 100644
--- a/ext/zend_test/php_test.h
+++ b/ext/zend_test/php_test.h
@@ -29,6 +29,7 @@ ZEND_TSRMLS_CACHE_EXTERN()
 #endif

 ZEND_BEGIN_MODULE_GLOBALS(zend_test)
+	zend_long child_init_count;
 	int observer_enabled;
 	int observer_show_output;
 	int observer_observe_all;
diff --git a/ext/zend_test/test.c b/ext/zend_test/test.c
index b75da6cc9a2..e6172fff010 100644
--- a/ext/zend_test/test.c
+++ b/ext/zend_test/test.c
@@ -1669,6 +1669,13 @@ static PHP_INI_MH(OnUpdateZendTestObserveOplineInZendMM)
 	return OnUpdateBool(entry, new_value, mh_arg1, mh_arg2, mh_arg3, stage);
 }

+static ZEND_FUNCTION(zend_test_child_init_count)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	RETURN_LONG(ZT_G(child_init_count));
+}
+
 static ZEND_FUNCTION(zend_test_fill_packed_array)
 {
 	HashTable *parameter;
@@ -2609,6 +2616,11 @@ PHP_MINFO_FUNCTION(zend_test)
 	DISPLAY_INI_ENTRIES();
 }

+static void zend_test_child_init(void)
+{
+	ZT_G(child_init_count)++;
+}
+
 zend_module_entry zend_test_module_entry = {
 	STANDARD_MODULE_HEADER,
 	"zend_test",
@@ -2623,7 +2635,8 @@ zend_module_entry zend_test_module_entry = {
 	PHP_GINIT(zend_test),
 	PHP_GSHUTDOWN(zend_test),
 	NULL,
-	STANDARD_MODULE_PROPERTIES_EX
+	STANDARD_MODULE_PROPERTIES_EX,
+	.child_init_func = zend_test_child_init
 };

 #ifdef COMPILE_DL_ZEND_TEST
diff --git a/ext/zend_test/test.stub.php b/ext/zend_test/test.stub.php
index 96f4a599e3d..87ad8c9b93c 100644
--- a/ext/zend_test/test.stub.php
+++ b/ext/zend_test/test.stub.php
@@ -282,6 +282,8 @@ function zend_test_nodiscard(): int {}
     #[\NoDiscard(message: "custom message 2")]
     function zend_test_deprecated_nodiscard(): int {}

+    function zend_test_child_init_count(): int {}
+
     /** @alias zend_test_void_return */
     function zend_test_aliased(): void {}

diff --git a/ext/zend_test/test_arginfo.h b/ext/zend_test/test_arginfo.h
index 9ad59954eae..314d66a2799 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 b38ffe98827..494e097fa57 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 ef7de4d062f..f941cf44c57 100644
Binary files a/ext/zend_test/test_legacy_arginfo.h and b/ext/zend_test/test_legacy_arginfo.h differ
diff --git a/ext/zend_test/tests/child_init.phpt b/ext/zend_test/tests/child_init.phpt
new file mode 100644
index 00000000000..60fd93ff810
--- /dev/null
+++ b/ext/zend_test/tests/child_init.phpt
@@ -0,0 +1,39 @@
+--TEST--
+GH-22616: Extension child initialization runs once per fork in the child
+--EXTENSIONS--
+zend_test
+pcntl
+--FILE--
+<?php
+var_dump(zend_test_child_init_count());
+$pid = pcntl_fork();
+if ($pid === -1) {
+    throw new Exception('fork failed');
+}
+if ($pid === 0) {
+    var_dump(zend_test_child_init_count());
+    $pid = pcntl_fork();
+    if ($pid === -1) {
+        throw new Exception('fork failed');
+    }
+    if ($pid === 0) {
+        var_dump(zend_test_child_init_count());
+        exit;
+    }
+    pcntl_waitpid($pid, $status);
+    var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+    var_dump(zend_test_child_init_count());
+    exit;
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+var_dump(zend_test_child_init_count());
+?>
+--EXPECT--
+int(0)
+int(1)
+int(2)
+bool(true)
+int(1)
+bool(true)
+int(0)
diff --git a/main/main.c b/main/main.c
index 40ff5af0d5e..962739df421 100644
--- a/main/main.c
+++ b/main/main.c
@@ -1868,6 +1868,12 @@ PHPAPI void php_child_init(void)
 	refresh_memory_manager();
 	zend_max_execution_timer_init();
 	php_clear_stat_cache(true, NULL, 0);
+
+	ZEND_HASH_MAP_FOREACH_PTR(&module_registry, zend_module_entry *module) {
+		if (module->module_started && module->child_init_func) {
+			module->child_init_func();
+		}
+	} ZEND_HASH_FOREACH_END();
 }

 /* {{{ php_request_startup */
diff --git a/main/php.h b/main/php.h
index dbf298b10a1..e5a6d6f9633 100644
--- a/main/php.h
+++ b/main/php.h
@@ -369,6 +369,7 @@ END_EXTERN_C()
 #define PHP_MINFO		ZEND_MODULE_INFO_N
 #define PHP_GINIT		ZEND_GINIT
 #define PHP_GSHUTDOWN	ZEND_GSHUTDOWN
+#define PHP_CINIT(module)		php_##module##_child_init

 #define PHP_MINIT_FUNCTION		ZEND_MODULE_STARTUP_D
 #define PHP_MSHUTDOWN_FUNCTION	ZEND_MODULE_SHUTDOWN_D
@@ -377,6 +378,7 @@ END_EXTERN_C()
 #define PHP_MINFO_FUNCTION		ZEND_MODULE_INFO_D
 #define PHP_GINIT_FUNCTION		ZEND_GINIT_FUNCTION
 #define PHP_GSHUTDOWN_FUNCTION	ZEND_GSHUTDOWN_FUNCTION
+#define PHP_CINIT_FUNCTION(module)	static void php_##module##_child_init(void)

 #define PHP_MODULE_GLOBALS		ZEND_MODULE_GLOBALS

diff --git a/sapi/fpm/tests/gh22616-child-init.phpt b/sapi/fpm/tests/gh22616-child-init.phpt
new file mode 100644
index 00000000000..e9acab08395
--- /dev/null
+++ b/sapi/fpm/tests/gh22616-child-init.phpt
@@ -0,0 +1,43 @@
+--TEST--
+GH-22616: Extension child initialization runs before FPM requests
+--EXTENSIONS--
+zend_test
+--SKIPIF--
+<?php include "skipif.inc"; ?>
+--FILE--
+<?php
+require_once "tester.inc";
+
+$cfg = <<<EOT
+[global]
+error_log = {{FILE:LOG}}
+[unconfined]
+listen = {{ADDR}}
+pm = static
+pm.max_children = 1
+pm.max_requests = 2
+EOT;
+
+$code = <<<'EOT'
+<?php
+echo zend_test_child_init_count();
+EOT;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(extensions: ['zend_test']);
+$tester->expectLogStartNotices();
+for ($i = 0; $i < 5; $i++) {
+    $tester->request()->expectBody('1');
+}
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+?>
+Done
+--EXPECT--
+Done
+--CLEAN--
+<?php
+require_once "tester.inc";
+FPM\Tester::clean();
+?>