|
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_UCACHE_H
+#define PHP_UCACHE_H
+
+#include "php.h"
+
+#if (defined(__GNUC__) && ZEND_GCC_VERSION >= 4003) || __has_attribute(hot)
+# define PHP_UCACHE_HOT __attribute__((hot))
+#else
+# define PHP_UCACHE_HOT
+#endif
+
+#define PHP_UCACHE_HOST_API_VERSION 1
+
+typedef enum {
+ PHP_UCACHE_REASON_NONE = 0,
+ PHP_UCACHE_REASON_DISABLED_BY_INI,
+ PHP_UCACHE_REASON_SHM_INIT_FAILED,
+ PHP_UCACHE_REASON_SAPI_NOT_ENABLED,
+ PHP_UCACHE_REASON_BACKEND_NOT_INITIALIZED_BEFORE_WORKER,
+ PHP_UCACHE_REASON_BACKEND_INITIALIZED_AFTER_WORKER,
+ PHP_UCACHE_REASON_CGI_BOUNDARY_UNAVAILABLE,
+ PHP_UCACHE_REASON_APACHE_BOUNDARY_UNAVAILABLE,
+ PHP_UCACHE_REASON_LSAPI_BOUNDARY_UNAVAILABLE,
+ PHP_UCACHE_REASON_REQ_SHUTDOWN
+} php_ucache_reason;
+
+typedef bool (*php_ucache_safe_direct_clone_val_func_t)(
+ void *ctx,
+ zval *dst,
+ zval *src);
+
+typedef bool (*php_ucache_safe_direct_copy_func_t)(
+ void *ctx,
+ zend_object *new_obj,
+ zend_object *old_obj,
+ php_ucache_safe_direct_clone_val_func_t clone_val);
+
+typedef bool (*php_ucache_safe_direct_state_serialize_func_t)(
+ zval *state,
+ const zval *obj);
+
+typedef bool (*php_ucache_safe_direct_state_unserialize_func_t)(
+ zval *obj,
+ zval *state);
+
+typedef bool (*php_ucache_safe_direct_is_internal_prop_func_t)(
+ const zend_object *obj,
+ const zend_string *name);
+
+typedef struct {
+ bool prefer_req_local_proto;
+ php_ucache_safe_direct_copy_func_t copy;
+ php_ucache_safe_direct_state_serialize_func_t state_serialize;
+ php_ucache_safe_direct_state_unserialize_func_t state_unserialize;
+ php_ucache_safe_direct_is_internal_prop_func_t is_internal_prop;
+} php_ucache_safe_direct_handlers;
+
+typedef struct _php_ucache_partition php_ucache_partition;
+
+typedef enum {
+ PHP_UCACHE_MODE_REQ,
+ PHP_UCACHE_MODE_PERSISTENT,
+ PHP_UCACHE_MODE_HOST_MANAGED
+} php_ucache_mode;
+
+typedef enum {
+ PHP_UCACHE_EXEC_REQ,
+ PHP_UCACHE_EXEC_PERSISTENT
+} php_ucache_exec_mode;
+
+typedef enum {
+ PHP_UCACHE_SCOPE_COMPLETE,
+ PHP_UCACHE_SCOPE_ABORTED
+} php_ucache_scope_outcome;
+
+typedef uint64_t php_ucache_scope_token;
+
+BEGIN_EXTERN_C()
+
+ZEND_API void php_ucache_safe_direct_register_class(
+ zend_class_entry *ce,
+ const php_ucache_safe_direct_handlers *handlers);
+ZEND_API zend_result php_ucache_opt_in(php_ucache_mode mode);
+ZEND_API bool php_ucache_is_enabled_by_ini(void);
+ZEND_API zend_result php_ucache_exec_prepare(
+ php_ucache_exec_mode mode,
+ php_ucache_partition *partition);
+ZEND_API zend_result php_ucache_exec_cancel_prepare(void);
+ZEND_API zend_result php_ucache_logical_req_begin(php_ucache_scope_token *token);
+ZEND_API zend_result php_ucache_logical_req_end(
+ php_ucache_scope_token token,
+ php_ucache_scope_outcome outcome);
+ZEND_API bool php_ucache_startup_default_ctx_storage(void);
+ZEND_API php_ucache_partition *php_ucache_partition_create(const char *name);
+ZEND_API void php_ucache_partition_set_max_procs(php_ucache_partition *partition, uint32_t max_procs);
+ZEND_API bool php_ucache_partition_startup_storage(php_ucache_partition *partition);
+ZEND_API void php_ucache_partition_activate(php_ucache_partition *partition);
+ZEND_API void php_ucache_partition_detach_all_except(php_ucache_partition *keep);
+ZEND_API void php_ucache_fork_prepare(void);
+ZEND_API void php_ucache_fork_cancel(void);
+ZEND_API void php_ucache_child_init(void);
+ZEND_API void php_ucache_activate_boundary_partition_by_id(
+ const char *sapi_prefix,
+ const char *boundary,
+ size_t boundary_len,
+ php_ucache_reason failure_reason);
+ZEND_API void php_ucache_activate_boundary_partition(
+ const char *sapi_prefix,
+ const char *doc_root,
+ php_ucache_reason failure_reason);
+
+#ifdef ZTS
+size_t php_ucache_globals_size(void);
+void php_ucache_globals_startup(void);
+#endif
+
+extern zend_module_entry user_cache_module_entry;
+
+#define phpext_user_cache_ptr &user_cache_module_entry
+
+END_EXTERN_C()
+
+#endif /* PHP_UCACHE_H */
diff --git a/ext/user_cache/tests/CONFLICTS b/ext/user_cache/tests/CONFLICTS
new file mode 100644
index 000000000000..3b242bdd032d
--- /dev/null
+++ b/ext/user_cache/tests/CONFLICTS
@@ -0,0 +1 @@
+user_cache
diff --git a/ext/user_cache/tests/fpm/CONFLICTS b/ext/user_cache/tests/fpm/CONFLICTS
new file mode 100644
index 000000000000..0702cb5bfbb0
--- /dev/null
+++ b/ext/user_cache/tests/fpm/CONFLICTS
@@ -0,0 +1 @@
+all
diff --git a/ext/user_cache/tests/fpm/skipif.inc b/ext/user_cache/tests/fpm/skipif.inc
new file mode 100644
index 000000000000..bc6813f63979
--- /dev/null
+++ b/ext/user_cache/tests/fpm/skipif.inc
@@ -0,0 +1,13 @@
+
+--FILE--
+clear();
+ $cache->store('pinned', ['a' => 1, 'b' => [1, 2, 3]]);
+ echo "seeded\n";
+ return;
+}
+
+if ($action === 'hold') {
+ $pinned = $cache->fetch('pinned');
+ $cache->lock('job');
+
+ for ($i = 0; $i < 500 && !$cache->has('writer-started'); $i++) {
+ usleep(10000);
+ }
+
+ usleep(500000);
+
+ $cache->unlock('job');
+ echo $pinned['a'], "\n";
+ return;
+}
+
+if ($action === 'crash') {
+ usleep(100000);
+ $cache->store('writer-started', true);
+ usleep(100000);
+ putenv('USER_CACHE_DEBUG_EXIT_IN_WRITE_SECTION=1');
+ $cache->store('never', 'published');
+ return;
+}
+
+var_dump($cache->clear(), $cache->lock('job'), $cache->unlock('job'));
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'user_cache.shm_size' => '8M',
+]);
+$tester->expectLogStartNotices();
+
+$tester->request(query: 'action=seed')->expectBody('seeded');
+
+$tester->multiRequest(
+ [['query' => 'action=hold'], ['query' => 'action=crash', 'delay' => 50000]],
+ errorMessage: 'the crashing worker closed its connection'
+);
+
+usleep(300000);
+
+$tester->request(query: 'action=check')->expectBody(
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)"
+);
+
+$tester->terminate();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+the crashing worker closed its connection
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_eviction.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_eviction.phpt
new file mode 100644
index 000000000000..6336bd313f57
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_eviction.phpt
@@ -0,0 +1,112 @@
+--TEST--
+FPM: LRU eviction works per-partition and never wipes the pool cache
+--SKIPIF--
+
+--FILE--
+store('k' . $i, $blob . $i)) {
+ break;
+ }
+ $stored++;
+ }
+ var_dump($stored > 400);
+ echo "fill\n";
+ return;
+}
+
+if ($action === 'touch') {
+ $alive = 0;
+ for ($i = 0; $i < 10; $i++) {
+ if ($cache->fetch('k' . $i) !== null) {
+ $alive++;
+ }
+ }
+ var_dump($alive === 10);
+ echo "touch\n";
+ return;
+}
+
+$ok = 0;
+for ($i = 0; $i < 30; $i++) {
+ if ($cache->store('new' . $i, $blob . 'n' . $i)) {
+ $ok++;
+ }
+}
+$touched = 0;
+for ($i = 0; $i < 10; $i++) {
+ if ($cache->fetch('k' . $i) !== null) {
+ $touched++;
+ }
+}
+$status = UserCache\Cache::getStatus();
+var_dump($ok === 30);
+var_dump($touched === 10);
+var_dump($status->getEntryCount() > 400);
+var_dump($status->getEvictionCount() > 0);
+var_dump($status->getExpungeCount() === 0);
+echo "pressure\n";
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'user_cache.shm_size' => '4M',
+]);
+$tester->expectLogStartNotices();
+
+$tester->request(query: 'action=fill')->expectBody("bool(true)\nfill");
+
+/* A later request re-stamps a slice of old keys with a fresh request clock. */
+sleep(1);
+$tester->request(query: 'action=touch')->expectBody("bool(true)\ntouch");
+
+sleep(1);
+$tester->request(query: 'action=pressure')->expectBody(
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "pressure"
+);
+
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_pin_slots.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_pin_slots.phpt
new file mode 100644
index 000000000000..c8e4901a22d6
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_pin_slots.phpt
@@ -0,0 +1,77 @@
+--TEST--
+FPM: shared graph payloads carry a pin bitmap sized to the pool's pm.max_children
+--SKIPIF--
+
+--FILE--
+ range(1, 16), 'text' => str_repeat('t', 64)];
+if (($_GET['action'] ?? '') === 'store') {
+ $cache->clear();
+ $before = UserCache\Cache::getStatus()->getUsedMemory();
+ $cache->store('graph', $value);
+ echo UserCache\Cache::getStatus()->getUsedMemory() - $before;
+ exit;
+}
+$fetched = $cache->fetch('graph');
+echo json_encode([
+ 'equal' => $fetched === $value,
+ 'pinned' => UserCache\Cache::getStatus()->getGraphPinnedReferences() > 0,
+]);
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: ['user_cache.enable' => '1']);
+$tester->expectLogStartNotices();
+
+try {
+ $grown = [];
+ foreach (['small', 'large'] as $pool) {
+ $grown[$pool] = (int) (string) $tester->request('action=store', address: '{{ADDR[' . $pool . ']}}')->getBody();
+ echo $pool, ': ', (string) $tester->request(address: '{{ADDR[' . $pool . ']}}')->getBody(), "\n";
+ }
+ echo 'payload bytes saved by 64 pin slots instead of 256: ', $grown['large'] - $grown['small'], "\n";
+} finally {
+ $tester->terminate();
+ $tester->expectLogTerminatingNotices();
+ $tester->close();
+
+ /* Release builds do not collect cycles at shutdown. */
+ unset($tester);
+ gc_collect_cycles();
+}
+
+?>
+--EXPECT--
+small: {"equal":true,"pinned":true}
+large: {"equal":true,"pinned":true}
+payload bytes saved by 64 pin slots instead of 256: 24
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_pool_config.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_pool_config.phpt
new file mode 100644
index 000000000000..b573da0ba93f
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_pool_config.phpt
@@ -0,0 +1,208 @@
+--TEST--
+FPM: UserCache storage uses each pool's startup settings without changing master defaults
+--SKIPIF--
+
+--FILE--
+| |=>)\s*([a-z0-9-]+)/', ob_get_clean(), $model);
+echo json_encode([
+ 'pool' => fpm_get_status()['pool'] ?? null,
+ 'pid' => getmypid(),
+ 'availability' => $status->getAvailability()->name,
+ 'configured' => $status->getConfiguredMemory(),
+ 'allocated' => $status->getSharedMemorySize(),
+ 'capacity' => $status->getEntryCapacity(),
+ 'preferred' => ini_get_all('user_cache')['user_cache.preferred_memory_model']['local_value'],
+ 'model' => $model[1] ?? null,
+]);
+PHP;
+
+foreach ([1, 0] as $masterEnabled) {
+ $tester = new FPM\Tester($cfg, $code);
+ $tester->start(iniEntries: [
+ 'user_cache.enable' => (string) $masterEnabled,
+ 'user_cache.shm_size' => '8M',
+ 'user_cache.entries_hint' => '0',
+ ]);
+ $tester->expectLogStartNotices();
+
+ try {
+ $states = [];
+ foreach (['alpha' => 1, 'beta' => 2, 'disabled' => 3, 'inherited' => 8] as $pool => $size) {
+ $response = $tester->request(address: '{{ADDR[' . $pool . ']}}');
+ $state = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
+ $enabled = $pool !== 'disabled' && ($pool !== 'inherited' || $masterEnabled);
+ $bytes = $size * 1024 * 1024;
+ if ($state['pool'] !== $pool ||
+ $state['configured'] !== $bytes ||
+ $state['allocated'] !== ($enabled ? $bytes : 0) ||
+ $state['availability'] !== ($enabled ? 'Available' : 'DisabledByIni') ||
+ $state['preferred'] !== ($pool === 'alpha' ? 'posix' : '') ||
+ $state['model'] !== ($enabled ? ($pool === 'alpha' ? 'posix' : 'mmap') : 'none')) {
+ throw new RuntimeException($pool . ': ' . json_encode($state));
+ }
+ if (!$enabled && $state['capacity'] !== 0) {
+ throw new RuntimeException($pool . ' allocated a disabled cache');
+ }
+ $states[$pool] = $state;
+ }
+ if ($states['alpha']['capacity'] !== 173 || $states['beta']['capacity'] !== 347) {
+ throw new RuntimeException('Pool entry hints were not applied');
+ }
+ if ($masterEnabled && $states['inherited']['capacity'] <= $states['beta']['capacity']) {
+ throw new RuntimeException('A pool entry hint changed the master default');
+ }
+ echo 'master enable=', $masterEnabled, ': pools configured correctly', "\n";
+ } finally {
+ $tester->terminate();
+ $tester->expectLogTerminatingNotices();
+ $tester->close();
+
+ /* Release builds do not collect cycles at shutdown. */
+ unset($tester);
+ gc_collect_cycles();
+ }
+}
+
+function user_cache_fpm_clamping_master(string $cfg, string $code, array $pools, array $iniEntries): string
+{
+ $tester = new FPM\Tester($cfg, $code);
+ $tester->start(iniEntries: $iniEntries + [
+ 'user_cache.enable' => '1',
+ 'user_cache.shm_size' => '8M',
+ 'user_cache.entries_hint' => '99999999',
+ 'log_errors' => '1',
+ 'display_errors' => '0',
+ ]);
+ $tester->expectLogStartNotices();
+
+ try {
+ foreach ($pools as $pool => $capacity) {
+ $response = $tester->request(address: '{{ADDR[' . $pool . ']}}');
+ $state = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
+ if ($state['availability'] !== 'Available' || ($capacity !== null && $state['capacity'] !== $capacity)) {
+ throw new RuntimeException($pool . ': ' . json_encode($state));
+ }
+ }
+ } finally {
+ $tester->terminate();
+ $tester->expectLogTerminatingNotices();
+ $tester->close();
+ }
+
+ ob_start();
+ $tester->printLogs();
+ $log = ob_get_clean();
+
+ unset($tester);
+ gc_collect_cycles();
+
+ return $log;
+}
+
+$overrideCfg = <<<'CFG'
+[global]
+error_log = {{FILE:LOG}}
+[alpha]
+listen = {{ADDR[alpha]}}
+pm = static
+pm.max_children = 1
+php_admin_value[user_cache.entries_hint] = 127
+[beta]
+listen = {{ADDR[beta]}}
+pm = static
+pm.max_children = 1
+php_admin_value[user_cache.entries_hint] = 255
+CFG;
+
+$masterClamp = 'user_cache.entries_hint is limited to 16777213; clamping';
+$capacityClamp = 'user_cache.entries_hint (16777213) exceeds what user_cache.shm_size can index';
+
+echo "Restoring a clamped master entries_hint after the pool overrides:\n";
+$fpmLog = user_cache_fpm_clamping_master($overrideCfg, $code, ['alpha' => 173, 'beta' => 347], []);
+echo 'FPM log clamp warnings: ', substr_count($fpmLog, $masterClamp), "\n";
+
+$inheritCfg = <<<'CFG'
+[global]
+error_log = {{FILE:LOG}}
+[alpha]
+listen = {{ADDR[alpha]}}
+pm = static
+pm.max_children = 1
+php_admin_value[user_cache.entries_hint] = 127
+[inherited]
+listen = {{ADDR[inherited]}}
+pm = static
+pm.max_children = 1
+CFG;
+
+$phpLog = sys_get_temp_dir() . '/php-user-cache-fpm-pool-config-' . getmypid() . '.log';
+@unlink($phpLog);
+
+echo "Master-side warnings with error_log set:\n";
+try {
+ $fpmLog = user_cache_fpm_clamping_master($inheritCfg, $code, ['alpha' => 173, 'inherited' => null], ['error_log' => $phpLog]);
+ $errorLog = (string) @file_get_contents($phpLog);
+ echo 'FPM log capacity clamp warnings: ', substr_count($fpmLog, $capacityClamp), "\n";
+ echo 'error_log clamp warnings: ', substr_count($errorLog, $masterClamp), ', capacity clamp warnings: ', substr_count($errorLog, $capacityClamp), "\n";
+} finally {
+ @unlink($phpLog);
+}
+
+?>
+--EXPECT--
+master enable=1: pools configured correctly
+master enable=0: pools configured correctly
+Restoring a clamped master entries_hint after the pool overrides:
+FPM log clamp warnings: 1
+Master-side warnings with error_log set:
+FPM log capacity clamp warnings: 1
+error_log clamp warnings: 1, capacity clamp warnings: 0
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_pool_detach.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_pool_detach.phpt
new file mode 100644
index 000000000000..097b77e020eb
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_pool_detach.phpt
@@ -0,0 +1,83 @@
+--TEST--
+FPM: a worker maps only its own pool's partition segment
+--SKIPIF--
+
+--FILE--
+store('probe', $pool);
+$own = UserCache\Cache::getStatus()->getSharedMemorySize();
+$sizes = [24 * 1024 * 1024 => 0, 20 * 1024 * 1024 => 0];
+foreach (file('/proc/self/maps') as $line) {
+ if (!preg_match('~^([0-9a-f]+)-([0-9a-f]+) (\S+) ~', $line, $m) || $m[3][3] !== 's') {
+ continue;
+ }
+ $len = hexdec($m[2]) - hexdec($m[1]);
+ if (isset($sizes[$len])) {
+ $sizes[$len]++;
+ }
+}
+printf("%s own=%dM alpha-sized=%d beta-sized=%d value=%s\n", $pool, $own >> 20, $sizes[24 * 1024 * 1024], $sizes[20 * 1024 * 1024], $cache->fetch('probe'));
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'opcache.file_update_protection' => '0',
+]);
+$tester->expectLogStartNotices();
+
+foreach (['alpha', 'beta'] as $pool) {
+ $response = $tester->request(query: 'pool=' . $pool, address: '{{ADDR[' . $pool . ']}}');
+ echo trim((string) $response->getBody()), "\n";
+}
+
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+alpha own=24M alpha-sized=1 beta-sized=0 value=alpha
+beta own=20M alpha-sized=0 beta-sized=1 value=beta
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_pool_separate.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_pool_separate.phpt
new file mode 100644
index 000000000000..1f61f7b85bd4
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_pool_separate.phpt
@@ -0,0 +1,112 @@
+--TEST--
+FPM: UserCache\Cache is separated between pools
+--SKIPIF--
+
+--FILE--
+store($key, $pool . '-value');
+}
+
+printf(
+ "%s:%s:%s\n",
+ $pool,
+ $cache->fetch($key, 'MISS'),
+ $cache->getPoolStatus()->getPoolName()
+);
+PHP;
+
+function expectPoolState(FPM\Tester $tester, string $pool, string $expected): void
+{
+ $response = $tester->request(
+ query: 'action=fetch&pool=' . $pool,
+ address: '{{ADDR[' . $pool . ']}}',
+ );
+ $body = trim((string) $response->getBody());
+ if ($body !== $expected) {
+ throw new RuntimeException(sprintf(
+ 'Unexpected state for pool %s: expected %s, got %s',
+ $pool,
+ $expected,
+ $body
+ ));
+ }
+}
+
+function seedPool(FPM\Tester $tester, string $pool, string $expected): void
+{
+ $response = $tester->request(
+ query: 'action=seed&pool=' . $pool,
+ address: '{{ADDR[' . $pool . ']}}',
+ );
+ $body = trim((string) $response->getBody());
+ if ($body !== $expected) {
+ throw new RuntimeException(sprintf(
+ 'Unexpected seed state for pool %s: expected %s, got %s',
+ $pool,
+ $expected,
+ $body
+ ));
+ }
+}
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'user_cache.shm_size' => '32M',
+ 'opcache.file_update_protection' => '0',
+]);
+$tester->expectLogStartNotices();
+
+seedPool($tester, 'alpha', 'alpha:alpha-value:default');
+expectPoolState($tester, 'alpha', 'alpha:alpha-value:default');
+expectPoolState($tester, 'beta', 'beta:MISS:default');
+
+seedPool($tester, 'beta', 'beta:beta-value:default');
+expectPoolState($tester, 'alpha', 'alpha:alpha-value:default');
+expectPoolState($tester, 'beta', 'beta:beta-value:default');
+
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_prototype_destructors.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_prototype_destructors.phpt
new file mode 100644
index 000000000000..a346455525fd
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_prototype_destructors.phpt
@@ -0,0 +1,79 @@
+--TEST--
+UserCache\Cache: prototype eligibility uses the current class destructor after an FPM request changes its definition
+--SKIPIF--
+
+--FILE--
+clear();
+ var_dump($cache->store('direct', new ChangingDestructorValue));
+ var_dump($cache->store('hidden', new ArrayObject([new ChangingDestructorValue])));
+ return;
+}
+
+foreach (['direct', 'hidden'] as $key) {
+ $valid = true;
+ for ($i = 0; $i < 2; $i++) {
+ $fetched = $cache->fetch($key);
+ $valid = ($key === 'direct' ? $fetched->value : $fetched[0]->value) === 41 && $valid;
+ unset($fetched);
+ }
+ echo $key, ':', ChangingDestructorValue::$destructors, ':', $valid ? 'valid' : 'invalid', "\n";
+ $cache->delete($key);
+ echo 'after delete:', ChangingDestructorValue::$destructors, "\n";
+}
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: ['user_cache.enable' => '1', 'user_cache.shm_size' => '16M']);
+$tester->expectLogStartNotices();
+for ($i = 0; $i < 2; $i++) {
+ $tester->request(query: 'action=seed')->expectBody("bool(true)\nbool(true)");
+ $tester->request(query: 'action=fetch')->expectBody(
+ "direct:2:valid\nafter delete:2\nhidden:4:valid\nafter delete:4");
+}
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+echo "Done\n";
+?>
+--EXPECT--
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_request_semantics.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_request_semantics.phpt
new file mode 100644
index 000000000000..382c1b8a7482
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_request_semantics.phpt
@@ -0,0 +1,286 @@
+--TEST--
+UserCache\Cache: per-fetch decoding, remember callbacks, decode failures and release of pinned payloads across FPM requests
+--SKIPIF--
+
+--FILE--
+ $this->value];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCalls++;
+ $this->value = $data['value'];
+ }
+}
+
+final class RememberCarrier
+{
+ public function __construct(public string $name) {}
+}
+
+[$section, $step] = explode('_', $_GET['action'], 2);
+$cache = UserCache\Cache::getPool($section);
+
+/* __unserialize() runs on every fetch, with no cross-fetch amortisation; static state resets per request */
+if ($section === 'unserialize') {
+ $key = 'magic_value';
+
+ if ($step === 'seed') {
+ $cache->clear();
+ echo $cache->store($key, new CountingMagicValue(41)) ? 'stored' : 'store-failed';
+ return;
+ }
+
+ $last = null;
+ for ($i = 0; $i < 5; $i++) {
+ $last = $cache->fetch($key);
+ }
+ echo CountingMagicValue::$unserializeCalls, ':', $last->value;
+ return;
+}
+
+/* remember() callbacks capturing request-local state are not retained across requests */
+if ($section === 'remember') {
+ if ($step === 'seed') {
+ $cache->clear();
+
+ $token = 'seed-token';
+ $carrier = new RememberCarrier('captured-object');
+ $resource = tmpfile();
+ fwrite($resource, 'request-local-resource');
+
+ $value = $cache->remember('captured', function () use (&$token, $carrier, $resource) {
+ return [
+ 'token' => $token,
+ 'carrier' => $carrier->name,
+ 'position' => ftell($resource),
+ ];
+ });
+
+ var_dump($value);
+ echo "seed\n";
+ return;
+ }
+
+ if ($step === 'hit') {
+ $poison = 'second-request';
+ $value = $cache->remember('captured', function () use (&$poison) {
+ echo "CALLBACK_RAN:$poison\n";
+ throw new RuntimeException('remember callback must not run on cache hit');
+ });
+
+ var_dump($value);
+ echo "hit\n";
+ return;
+ }
+
+ $cache->delete('captured');
+ $value = $cache->remember('captured', function () {
+ return 'fresh-after-delete';
+ });
+
+ var_dump($value);
+ echo "miss\n";
+ return;
+}
+
+/* Silently corrupt entries are dropped while autoloader exceptions propagate and keep the entry */
+if ($section === 'decode') {
+ if ($step === 'seed') {
+ class UserCacheGone
+ {
+ public string $value = 'stored';
+ }
+
+ class UserCacheAutoloadThrows
+ {
+ public string $value = 'stored';
+ }
+
+ $cache->clear();
+ var_dump($cache->store('single', new UserCacheGone()));
+ var_dump($cache->store('multiple', new UserCacheGone()));
+ var_dump($cache->store('autoload-single', new UserCacheAutoloadThrows()));
+ var_dump($cache->store('autoload-multiple', new UserCacheAutoloadThrows()));
+ var_dump($cache->has('single'));
+ var_dump($cache->has('multiple'));
+ var_dump($cache->has('autoload-single'));
+ var_dump($cache->has('autoload-multiple'));
+ return;
+ }
+
+ spl_autoload_register(function (string $class): void {
+ if ($class === 'UserCacheAutoloadThrows') {
+ throw new Exception('autoload failed');
+ }
+ });
+
+ /* Missing classes cause silent decode failure and entry removal. */
+ var_dump($cache->fetch('single', 'DEFAULT'));
+ var_dump($cache->has('single'));
+
+ /* Autoloader exceptions propagate and preserve the entry. */
+ try {
+ $cache->fetch('autoload-single', 'DEFAULT');
+ echo "no exception\n";
+ } catch (Exception $e) {
+ echo "caught: ", $e->getMessage(), "\n";
+ }
+ var_dump($cache->has('autoload-single'));
+
+ /* Drop the corrupt entry before the autoloader aborts the batch. */
+ try {
+ $cache->fetchMultiple(['multiple', 'missing', 'autoload-multiple'], 'DEFAULT');
+ echo "no exception\n";
+ } catch (Exception $e) {
+ echo "caught: ", $e->getMessage(), "\n";
+ }
+ var_dump($cache->has('multiple'));
+ var_dump($cache->has('autoload-multiple'));
+ return;
+}
+
+/* Payloads deleted while this request still pins them are freed when the request ends */
+if ($section === 'orphan') {
+ if ($step === 'seed') {
+ $cache->clear();
+ $cache->store('baseline', 0);
+ $cache->store('tick', 0);
+ $baseline = UserCache\Cache::getStatus()->getFreeMemory();
+ $cache->store('baseline', $baseline);
+
+ $pad = str_repeat('o', 3000);
+ $held = [];
+ for ($i = 0; $i < 40; $i++) {
+ $cache->store("o:$i", ['i' => $i, 'pad' => $pad]);
+ $held[] = $cache->fetch("o:$i");
+ }
+ for ($i = 0; $i < 40; $i++) {
+ $cache->delete("o:$i");
+ }
+ echo 'held:', var_export(UserCache\Cache::getStatus()->getFreeMemory() < $baseline, true);
+ echo ' pins:', var_export(UserCache\Cache::getStatus()->getGraphPinnedReferences() > 0, true);
+ echo ' intact:', var_export($held[39]['pad'] === $pad, true);
+ return;
+ }
+
+ if ($step === 'check') {
+ $status = UserCache\Cache::getStatus();
+ echo 'freed:', var_export($status->getFreeMemory() === $cache->fetch('baseline'), true);
+ echo ' pins:', $status->getGraphPinnedReferences();
+ return;
+ }
+}
+
+throw new RuntimeException('unknown action ' . $_GET['action']);
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'user_cache.enable' => '1',
+ 'user_cache.shm_size' => '32M',
+]);
+$tester->expectLogStartNotices();
+
+/* __unserialize() runs on every fetch, with no cross-fetch amortisation; static state resets per request */
+$tester->request(query: 'action=unserialize_seed')->expectBody('stored');
+$tester->request(query: 'action=unserialize_measure')->expectBody('5:41');
+$tester->request(query: 'action=unserialize_measure')->expectBody('5:41');
+
+/* remember() callbacks capturing request-local state are not retained across requests */
+$expectedValue =
+ "array(3) {\n" .
+ " [\"token\"]=>\n" .
+ " string(10) \"seed-token\"\n" .
+ " [\"carrier\"]=>\n" .
+ " string(15) \"captured-object\"\n" .
+ " [\"position\"]=>\n" .
+ " int(22)\n" .
+ "}";
+
+$tester->request(query: 'action=remember_seed')->expectBody(
+ $expectedValue . "\n" .
+ "seed"
+);
+
+$tester->request(query: 'action=remember_hit')->expectBody(
+ $expectedValue . "\n" .
+ "hit"
+);
+
+$tester->request(query: 'action=remember_miss')->expectBody(
+ "string(18) \"fresh-after-delete\"\n" .
+ "miss"
+);
+
+/* Silently corrupt entries are dropped while autoloader exceptions propagate and keep the entry */
+$tester->request(query: 'action=decode_seed')->expectBody(
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)"
+);
+
+$tester->request(query: 'action=decode_fetch')->expectBody(
+ "string(7) \"DEFAULT\"\n" .
+ "bool(false)\n" .
+ "caught: autoload failed\n" .
+ "bool(true)\n" .
+ "caught: autoload failed\n" .
+ "bool(false)\n" .
+ "bool(true)"
+);
+
+/* Payloads deleted while this request still pins them are freed when the request ends */
+$tester->request(query: 'action=orphan_seed')->expectBody('held:true pins:true intact:true');
+$tester->request(query: 'action=orphan_check')->expectBody('freed:true pins:0');
+
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_store_multiple_bailout.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_store_multiple_bailout.phpt
new file mode 100644
index 000000000000..67a696654933
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_store_multiple_bailout.phpt
@@ -0,0 +1,84 @@
+--TEST--
+FPM: UserCache\Cache storeMultiple rolls back a committed replacement when the commit loop bails out
+--SKIPIF--
+
+--FILE--
+clear();
+ $cache->store('a', 'ORIGINAL');
+ echo UserCache\Cache::getStatus()->getFreeMemory(), "\n";
+ return;
+}
+
+if ($action === 'bail') {
+ /* Bail out after replacing 'a', before storing 'z'. */
+ $cache->storeMultiple(['a' => 'REPLACEMENT', 'z' => 'NEW']);
+ echo "no-bailout\n";
+ return;
+}
+
+/* Rollback must restore 'a' and free the replacement without storing 'z'. */
+echo $cache->fetch('a', 'MISS'), "\n";
+var_dump($cache->has('z'));
+echo UserCache\Cache::getStatus()->getFreeMemory(), "\n";
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'user_cache.shm_size' => '32M',
+]);
+$tester->expectLogStartNotices();
+
+$baseline = trim($tester->request(query: 'action=seed')->getBody());
+
+/* The bail request dies with a fatal error; the worker survives it. */
+$tester->request(query: 'action=bail');
+
+$tester->request(query: 'action=check')->expectBody(
+ "ORIGINAL\n" .
+ "bool(false)\n" .
+ $baseline
+);
+
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/fpm/user_cache_fpm_values.phpt b/ext/user_cache/tests/fpm/user_cache_fpm_values.phpt
new file mode 100644
index 000000000000..0d676b617bc4
--- /dev/null
+++ b/ext/user_cache/tests/fpm/user_cache_fpm_values.phpt
@@ -0,0 +1,377 @@
+--TEST--
+UserCache\Cache: objects, arrays, safe-direct DateTime, SPL and DatePeriod values persist across FPM requests
+--SKIPIF--
+
+--FILE--
+id = $id;
+ $this->name = $name;
+ }
+
+ public function __serialize(): array
+ {
+ return ['id' => $this->id, 'name' => $this->name];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->id = $data['id'];
+ $this->name = $data['name'];
+ }
+
+ public function info(): string
+ {
+ return $this->id . ':' . $this->name;
+ }
+}
+
+class EventDateTime extends DateTime
+{
+ private string $label;
+ protected int $revision;
+
+ public function __construct(string $time, DateTimeZone $timezone, string $label, int $revision)
+ {
+ parent::__construct($time, $timezone);
+ $this->label = $label;
+ $this->revision = $revision;
+ }
+
+ public function describe(): string
+ {
+ return $this->label . ':' . $this->revision;
+ }
+}
+
+class LabelIterator extends ArrayIterator
+{
+}
+
+class TaggedCollection extends ArrayObject
+{
+ private string $type;
+
+ public function __construct(array $data, string $type, string $iteratorClass)
+ {
+ parent::__construct($data, 0, $iteratorClass);
+ $this->type = $type;
+ }
+
+ public function type(): string
+ {
+ return $this->type;
+ }
+}
+
+class TaggedDatePeriod extends DatePeriod
+{
+ public string $label = 'default';
+
+ public function tag(string $label): void
+ {
+ $this->label = $label;
+ }
+}
+
+function period_dates(DatePeriod $period): string
+{
+ $dates = [];
+ foreach ($period as $date) {
+ $dates[] = $date->format('Y-m-d');
+ }
+ return implode(',', $dates);
+}
+
+[$section, $step] = explode('_', $_GET['action'], 2);
+$cache = UserCache\Cache::getPool($section);
+
+/* Object graphs, array holes, shared identity, verbatim leaves and references */
+if ($section === 'complex') {
+ $leaf = [str_repeat('leaf', 512), ['id' => 13]];
+
+ if ($step === 'seed') {
+ $gap = [];
+ $gap[4] = 'seed';
+ unset($gap[4]);
+
+ $payload = [
+ 'props' => new SimpleUser('Alice', 30),
+ 'serialize' => new SerUser(7, 'Bob'),
+ 'internal' => new DateTimeImmutable('2026-06-15 09:30:00', new DateTimeZone('UTC')),
+ 'gap' => $gap,
+ ];
+
+ $shared = new stdClass();
+ $shared->value = 42;
+
+ $refs = ['value' => 1];
+ $refs['alias'] =& $refs['value'];
+
+ $cache->clear();
+ var_dump($cache->store('complex', $payload));
+ var_dump($cache->store('shared_pair', [$shared, $shared, $leaf]));
+ var_dump($cache->store('refs', $refs));
+ echo "seed\n";
+ return;
+ }
+
+ $complex = $cache->fetch('complex');
+ $complex['gap'][] = 'tail';
+ echo $complex['props']->name, ',', $complex['props']->age, ',', $complex['serialize']->info(), ',', $complex['internal']->format('Y-m-d H:i:s'), ',', array_key_last($complex['gap']), "\n";
+
+ $pair = $cache->fetch('shared_pair');
+ var_dump(spl_object_id($pair[0]) === spl_object_id($pair[1]));
+ var_dump($pair[2] === $leaf);
+ $pair[2][1]['id'] = 99;
+
+ $refs = $cache->fetch('refs');
+ $refs['alias'] = 7;
+ var_dump($refs['value']);
+
+ echo "fetch\n";
+ return;
+}
+
+/* Scalars and arrays with storeMultiple and deleteMultiple */
+if ($section === 'scalars') {
+ if ($step === 'seed') {
+ $cache->clear();
+ var_dump($cache->store('counter', 41));
+ var_dump($cache->storeMultiple([
+ 'message' => 'hello from fpm',
+ 'payload' => ['a' => 1, 'b' => [2, 3]],
+ ]));
+ var_dump($cache->store('persistent_key', 'long-lived'));
+ echo "seed\n";
+ return;
+ }
+
+ if ($step === 'fetch') {
+ var_dump($cache->fetch('counter'));
+ var_dump($cache->fetch('message'));
+ var_dump($cache->fetch('payload'));
+ $cache->deleteMultiple(['message']);
+ echo $cache->fetch('message', 'MISS'), "\n";
+ echo "fetch\n";
+ return;
+ }
+
+ echo $cache->fetch('persistent_key', 'MISS'), "\n";
+ echo "persist\n";
+ return;
+}
+
+/* Safe-direct DateTime subclass and SPL containers */
+if ($section === 'direct') {
+ if ($step === 'seed') {
+ $cache->clear();
+
+ $event = new EventDateTime('2026-06-15 09:30:00.123456', new DateTimeZone('Europe/Paris'), 'launch', 7);
+ $collection = new TaggedCollection(['alpha' => 10, 'beta' => 20], 'metric', LabelIterator::class);
+ $fixed = SplFixedArray::fromArray(['zero', 'one', ['two']], false);
+ $queue = new SplQueue();
+ $queue->enqueue('q1');
+ $queue->enqueue('q2');
+ $priorityQueue = new SplPriorityQueue();
+ $priorityQueue->setExtractFlags(SplPriorityQueue::EXTR_BOTH);
+ $priorityQueue->insert('low', 1);
+ $priorityQueue->insert('high', 10);
+
+ var_dump($cache->store('direct_payload', [
+ 'event' => $event,
+ 'collection' => $collection,
+ 'fixed' => $fixed,
+ 'queue' => $queue,
+ 'priorityQueue' => $priorityQueue,
+ ]));
+ echo "seed\n";
+ return;
+ }
+
+ $payload = $cache->fetch('direct_payload');
+ $event = $payload['event'];
+ $collection = $payload['collection'];
+ $iterator = $collection->getIterator();
+ $fixed = $payload['fixed'];
+ $queue = $payload['queue'];
+ $priorityQueue = $payload['priorityQueue'];
+
+ echo $event->format('Y-m-d H:i:s.u e'), ',', $event->describe(), "\n";
+ echo $collection->type(), ',', ($iterator instanceof LabelIterator ? 'LabelIterator' : get_debug_type($iterator)), ',', $collection['alpha'], ',', $collection['beta'], "\n";
+ echo $fixed->getSize(), ',', $fixed[2][0], "\n";
+ echo $queue->dequeue(), ',', $queue->dequeue(), "\n";
+ $top = $priorityQueue->extract();
+ echo $top['data'], ',', $top['priority'], "\n";
+ return;
+}
+
+/* Safe-direct DatePeriod by recurrences, by end date and subclassed */
+if ($section === 'period') {
+ if ($step === 'seed') {
+ $cache->clear();
+
+ $byRecurrences = new DatePeriod(new DateTimeImmutable('2026-01-01'), new DateInterval('P1D'), 3);
+ $byEnd = new DatePeriod(
+ new DateTimeImmutable('2026-03-01'),
+ new DateInterval('P1M'),
+ new DateTimeImmutable('2026-05-15'),
+ );
+ $tagged = new TaggedDatePeriod(new DateTimeImmutable('2026-02-01'), new DateInterval('P1D'), 2);
+ $tagged->tag('window');
+
+ var_dump($cache->store('period_payload', [
+ 'recurrences' => $byRecurrences,
+ 'end' => $byEnd,
+ 'tagged' => $tagged,
+ ]));
+ echo "seed\n";
+ return;
+ }
+
+ $payload = $cache->fetch('period_payload');
+ $recurrences = $payload['recurrences'];
+ $end = $payload['end'];
+ $tagged = $payload['tagged'];
+
+ echo ($recurrences instanceof DatePeriod ? 'DatePeriod' : get_debug_type($recurrences)), ',', period_dates($recurrences), "\n";
+ echo period_dates($end), "\n";
+ echo ($tagged instanceof TaggedDatePeriod ? 'TaggedDatePeriod' : get_debug_type($tagged)), ',', $tagged->label, ',', period_dates($tagged), "\n";
+ return;
+}
+
+throw new RuntimeException('unknown action ' . $_GET['action']);
+PHP;
+
+$tester = new FPM\Tester($cfg, $code);
+$tester->start(iniEntries: [
+ 'user_cache.enable' => '1',
+ 'user_cache.shm_size' => '32M',
+ 'date.timezone' => 'UTC',
+]);
+$tester->expectLogStartNotices();
+
+/* Object graphs, array holes, shared identity, verbatim leaves and references */
+$tester->request(query: 'action=complex_seed')->expectBody(
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "seed"
+);
+
+/* Mutating the fetched leaf must not affect the following request. */
+for ($i = 0; $i < 2; $i++) {
+ $tester->request(query: 'action=complex_fetch')->expectBody(
+ "Alice,30,7:Bob,2026-06-15 09:30:00,5\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "int(7)\n" .
+ "fetch"
+ );
+}
+
+/* Scalars and arrays with storeMultiple and deleteMultiple */
+$tester->request(query: 'action=scalars_seed')->expectBody(
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "bool(true)\n" .
+ "seed"
+);
+
+$tester->request(query: 'action=scalars_fetch')->expectBody(
+ "int(41)\n" .
+ "string(14) \"hello from fpm\"\n" .
+ "array(2) {\n" .
+ " [\"a\"]=>\n" .
+ " int(1)\n" .
+ " [\"b\"]=>\n" .
+ " array(2) {\n" .
+ " [0]=>\n" .
+ " int(2)\n" .
+ " [1]=>\n" .
+ " int(3)\n" .
+ " }\n" .
+ "}\n" .
+ "MISS\n" .
+ "fetch"
+);
+
+$tester->request(query: 'action=scalars_persist')->expectBody(
+ "long-lived\n" .
+ "persist"
+);
+
+/* Safe-direct DateTime subclass and SPL containers */
+$tester->request(query: 'action=direct_seed')->expectBody(
+ "bool(true)\n" .
+ "seed"
+);
+
+$tester->request(query: 'action=direct_fetch')->expectBody(
+ "2026-06-15 09:30:00.123456 Europe/Paris,launch:7\n" .
+ "metric,LabelIterator,10,20\n" .
+ "3,two\n" .
+ "q1,q2\n" .
+ "high,10"
+);
+
+/* Safe-direct DatePeriod by recurrences, by end date and subclassed */
+$tester->request(query: 'action=period_seed')->expectBody(
+ "bool(true)\n" .
+ "seed"
+);
+
+$tester->request(query: 'action=period_fetch')->expectBody(
+ "DatePeriod,2026-01-01,2026-01-02,2026-01-03,2026-01-04\n" .
+ "2026-03-01,2026-04-01,2026-05-01\n" .
+ "TaggedDatePeriod,window,2026-02-01,2026-02-02,2026-02-03"
+);
+
+$tester->terminate();
+$tester->expectLogTerminatingNotices();
+$tester->close();
+
+/* Release builds do not collect cycles at shutdown. */
+unset($tester);
+gc_collect_cycles();
+
+echo "Done\n";
+
+?>
+--EXPECT--
+Done
+--CLEAN--
+
diff --git a/ext/user_cache/tests/user_cache_apache2handler_boundary.phpt b/ext/user_cache/tests/user_cache_apache2handler_boundary.phpt
new file mode 100644
index 000000000000..3b8b545cb76e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_apache2handler_boundary.phpt
@@ -0,0 +1,225 @@
+--TEST--
+UserCache\Cache: apache2handler partitions cache data by virtual host, not by request Host
+--SKIPIF--
+
+--FILE--
+clear();
+} elseif ($action === 'seed') {
+ $cache->store($key, $host . '-value');
+}
+
+echo $host, ':', $cache->fetch($key, 'MISS'), ':', $cache->getPoolStatus()->getPoolName(), "\n";
+PHP;
+
+file_put_contents($alphaRoot . '/index.php', $script);
+file_put_contents($betaRoot . '/index.php', $script);
+file_put_contents($root . '/php.ini', implode("\n", [
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=32M',
+ 'opcache.file_update_protection=0',
+]));
+
+$port = user_cache_apache_free_port();
+$httpd = getenv('TEST_PHP_APACHE2HANDLER_HTTPD');
+$module = getenv('TEST_PHP_APACHE2HANDLER_MODULE');
+$moduleName = getenv('TEST_PHP_APACHE2HANDLER_MODULE_NAME') ?: 'php_module';
+$extraConfig = getenv('TEST_PHP_APACHE2HANDLER_EXTRA_CONFIG') ?: '';
+$ldPreload = getenv('TEST_PHP_APACHE2HANDLER_LD_PRELOAD');
+$conf = $root . '/httpd.conf';
+
+file_put_contents($conf, <<
+ ServerName alpha.local
+ ServerAlias alpha-alias.local
+ DocumentRoot "$alphaRoot"
+
+ Require all granted
+ AllowOverride None
+
+
+ SetHandler application/x-httpd-php
+
+
+
+
+ ServerName beta.local
+ DocumentRoot "$betaRoot"
+
+ Require all granted
+ AllowOverride None
+
+
+ SetHandler application/x-httpd-php
+
+
+CONF);
+
+try {
+ $env = null;
+ if ($ldPreload !== false && $ldPreload !== '') {
+ /* Sanitizer builds need the runtime preloaded by httpd, but not by the PHP test runner. */
+ $env = getenv();
+ $env['LD_PRELOAD'] = $ldPreload;
+ }
+
+ $process = proc_open(
+ [$httpd, '-X', '-f', $conf],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $pipes,
+ $root,
+ $env
+ );
+ if (!is_resource($process)) {
+ throw new RuntimeException('Unable to start httpd');
+ }
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ user_cache_apache_wait($process, $pipes, $port);
+
+ user_cache_apache_request($port, 'alpha.local', '/index.php?action=clear');
+ user_cache_apache_request($port, 'beta.local', '/index.php?action=clear');
+
+ $checks = [
+ ['alpha.local', '/index.php?action=seed', 'alpha.local:alpha.local-value:default'],
+ ['alpha.local', '/index.php?action=fetch', 'alpha.local:alpha.local-value:default'],
+ ['beta.local', '/index.php?action=fetch', 'beta.local:MISS:default'],
+ ['beta.local', '/index.php?action=seed', 'beta.local:beta.local-value:default'],
+ ['alpha.local', '/index.php?action=fetch', 'alpha.local:alpha.local-value:default'],
+ ['beta.local', '/index.php?action=fetch', 'beta.local:beta.local-value:default'],
+ /* ServerAlias and default-vhost requests use alpha's partition. */
+ ['alpha-alias.local', '/index.php?action=fetch', 'alpha-alias.local:alpha.local-value:default'],
+ ['gamma.local', '/index.php?action=fetch', 'gamma.local:alpha.local-value:default'],
+ ];
+
+ foreach ($checks as [$host, $path, $expected]) {
+ $actual = user_cache_apache_request($port, $host, $path);
+ if ($actual !== $expected) {
+ throw new RuntimeException("Expected $expected, got $actual");
+ }
+ }
+
+ echo "Done\n";
+} finally {
+ if (is_resource($process)) {
+ proc_terminate($process);
+ proc_close($process);
+ }
+ user_cache_apache_rm_rf($root);
+}
+
+?>
+--EXPECT--
+Done
diff --git a/ext/user_cache/tests/user_cache_apache2handler_dynamic_boundary.phpt b/ext/user_cache/tests/user_cache_apache2handler_dynamic_boundary.phpt
new file mode 100644
index 000000000000..165b9ac7ba63
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_apache2handler_dynamic_boundary.phpt
@@ -0,0 +1,228 @@
+--TEST--
+UserCache\Cache: apache2handler isolates automatically mapped mass virtual hosts
+--SKIPIF--
+ ['pipe', 'w'], 2 => ['null']], $listPipes);
+$builtIn = stream_get_contents($listPipes[1]) ?: '';
+fclose($listPipes[1]);
+proc_close($list);
+if (str_contains($builtIn, 'mod_vhost_alias.c')) return;
+
+$candidates = [
+ getenv('TEST_PHP_APACHE2HANDLER_VHOST_ALIAS_MODULE') ?: null,
+ dirname(dirname($httpd)) . '/modules/mod_vhost_alias.so',
+ '/usr/libexec/apache2/mod_vhost_alias.so',
+ '/usr/lib/apache2/modules/mod_vhost_alias.so',
+ '/usr/lib64/httpd/modules/mod_vhost_alias.so',
+];
+foreach ($candidates as $candidate) {
+ if ($candidate !== null && is_file($candidate)) return;
+}
+die('skip mod_vhost_alias is not built in and its module path is unavailable');
+?>
+--FILE--
+clear();
+} elseif ($action === 'seed') {
+ $cache->store('dynamic-boundary-key', $host . '-value');
+}
+echo $host, ':', $cache->fetch('dynamic-boundary-key', 'MISS'), ':', UserCache\Cache::getStatus()->getAvailability()->name, "\n";
+PHP);
+}
+file_put_contents($root . '/php.ini', implode("\n", [
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=1M',
+ 'user_cache.lockfile_path=' . $root,
+ 'opcache.file_update_protection=0',
+]));
+
+$httpd = getenv('TEST_PHP_APACHE2HANDLER_HTTPD');
+$module = getenv('TEST_PHP_APACHE2HANDLER_MODULE');
+$moduleName = getenv('TEST_PHP_APACHE2HANDLER_MODULE_NAME') ?: 'php_module';
+$extraConfig = getenv('TEST_PHP_APACHE2HANDLER_EXTRA_CONFIG') ?: '';
+$ldPreload = getenv('TEST_PHP_APACHE2HANDLER_LD_PRELOAD');
+$list = proc_open([$httpd, '-l'], [1 => ['pipe', 'w'], 2 => ['null']], $listPipes);
+$builtIn = stream_get_contents($listPipes[1]) ?: '';
+fclose($listPipes[1]);
+proc_close($list);
+$vhostAliasLoad = '';
+if (!str_contains($builtIn, 'mod_vhost_alias.c')) {
+ $candidates = [
+ getenv('TEST_PHP_APACHE2HANDLER_VHOST_ALIAS_MODULE') ?: null,
+ dirname(dirname($httpd)) . '/modules/mod_vhost_alias.so',
+ '/usr/libexec/apache2/mod_vhost_alias.so',
+ '/usr/lib/apache2/modules/mod_vhost_alias.so',
+ '/usr/lib64/httpd/modules/mod_vhost_alias.so',
+ ];
+ foreach ($candidates as $candidate) {
+ if ($candidate !== null && is_file($candidate)) {
+ $vhostAliasLoad = 'LoadModule vhost_alias_module "' . $candidate . '"';
+ break;
+ }
+ }
+}
+
+$port = user_cache_apache_free_port();
+$conf = $root . '/httpd.conf';
+file_put_contents($conf, <<
+ Require all granted
+ AllowOverride None
+
+
+ SetHandler application/x-httpd-php
+
+CONF);
+
+try {
+ $env = null;
+ if ($ldPreload !== false && $ldPreload !== '') {
+ $env = getenv();
+ $env['LD_PRELOAD'] = $ldPreload;
+ }
+ $process = proc_open(
+ [$httpd, '-X', '-f', $conf],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $pipes,
+ $root,
+ $env
+ );
+ if (!is_resource($process)) {
+ throw new RuntimeException('Unable to start httpd');
+ }
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ for ($i = 0; $i < 50; $i++) {
+ try {
+ user_cache_apache_request($port, 'alpha.local', '/index.php?action=fetch');
+ break;
+ } catch (Throwable) {
+ usleep(100000);
+ }
+ }
+
+ user_cache_apache_request($port, 'alpha.local', '/index.php?action=clear');
+ user_cache_apache_request($port, 'beta.local', '/index.php?action=clear');
+
+ $checks = [
+ ['alpha.local', '/index.php?action=seed', 'alpha.local:alpha.local-value:Available'],
+ ['beta.local', '/index.php?action=fetch', 'beta.local:MISS:Available'],
+ ['beta.local', '/index.php?action=seed', 'beta.local:beta.local-value:Available'],
+ ['alpha.local', '/index.php?action=fetch', 'alpha.local:alpha.local-value:Available'],
+ ['beta.local', '/index.php?action=fetch', 'beta.local:beta.local-value:Available'],
+ ];
+ foreach ($checks as [$host, $path, $expected]) {
+ $actual = user_cache_apache_request($port, $host, $path);
+ if ($actual !== $expected) {
+ throw new RuntimeException("Expected $expected, got $actual\n" . stream_get_contents($pipes[2]));
+ }
+ }
+
+ echo "Done\n";
+} finally {
+ if (is_resource($process)) {
+ proc_terminate($process);
+ proc_close($process);
+ }
+ user_cache_apache_remove_boundary_segments($root);
+ user_cache_apache_rm_rf($root);
+}
+?>
+--EXPECT--
+Done
diff --git a/ext/user_cache/tests/user_cache_apache2handler_vhost_ini.phpt b/ext/user_cache/tests/user_cache_apache2handler_vhost_ini.phpt
new file mode 100644
index 000000000000..64de188a3fe0
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_apache2handler_vhost_ini.phpt
@@ -0,0 +1,219 @@
+--TEST--
+UserCache\Cache: apache2handler creates each virtual host's partition with the php_admin_value / php_admin_flag settings of that virtual host
+--SKIPIF--
+
+--FILE--
+getAvailability()->name,
+ $status->getConfiguredMemory() >> 20,
+ $status->getSharedMemorySize() >> 20,
+ $status->getEntryCapacity(),
+]), "\n";
+PHP;
+
+$vhosts = [
+ 'alpha' => "php_admin_value user_cache.shm_size 4M\n php_admin_value user_cache.entries_hint 127",
+ 'beta' => '',
+ 'gamma' => 'php_value user_cache.shm_size 2M',
+ 'delta' => 'php_admin_flag user_cache.enable off',
+];
+$vhostConfig = '';
+foreach ($vhosts as $name => $settings) {
+ mkdir("$root/$name", 0777, true);
+ file_put_contents("$root/$name/index.php", $script);
+ $vhostConfig .= <<
+ ServerName $name.local
+ DocumentRoot "$root/$name"
+ $settings
+
+ Require all granted
+ AllowOverride None
+
+
+ SetHandler application/x-httpd-php
+
+
+CONF;
+}
+
+file_put_contents($root . '/php.ini', implode("\n", [
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=8M',
+ 'user_cache.entries_hint=99999999',
+ 'log_errors=1',
+ 'display_errors=0',
+ 'error_log=' . $root . '/php.log',
+]));
+
+$port = user_cache_apache_free_port();
+$httpd = getenv('TEST_PHP_APACHE2HANDLER_HTTPD');
+$module = getenv('TEST_PHP_APACHE2HANDLER_MODULE');
+$moduleName = getenv('TEST_PHP_APACHE2HANDLER_MODULE_NAME') ?: 'php_module';
+$extraConfig = getenv('TEST_PHP_APACHE2HANDLER_EXTRA_CONFIG') ?: '';
+$ldPreload = getenv('TEST_PHP_APACHE2HANDLER_LD_PRELOAD');
+$conf = $root . '/httpd.conf';
+$vhostConfig = str_replace('{{PORT}}', (string) $port, $vhostConfig);
+
+file_put_contents($conf, <<
+--EXPECTF--
+alpha.local:Available:4:4:173
+beta.local:Available:8:8:%d
+gamma.local:Available:8:8:%d
+delta.local:DisabledByIni:8:0:0
+Warnings after several requests:
+error_log clamp warnings: 1, capacity clamp warnings: 0
+httpd error log capacity clamp warnings: 1
diff --git a/ext/user_cache/tests/user_cache_api_objects_not_comparable.phpt b/ext/user_cache/tests/user_cache_api_objects_not_comparable.phpt
new file mode 100644
index 000000000000..3f335b72034b
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_api_objects_not_comparable.phpt
@@ -0,0 +1,32 @@
+--TEST--
+UserCache\Cache: pool and status objects are equal only to themselves and take no dynamic properties
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+ $a, 'b' => $b]));
+
+$empty = Cache::getStatus();
+$a->store('k', 1);
+var_dump($empty == Cache::getStatus(), $empty == $empty, $a->getPoolStatus() == $b->getPoolStatus());
+
+try {
+ $a->hook = 1;
+} catch (Error $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+bool(false)
+bool(true)
+bool(false)
+string(1) "b"
+bool(false)
+bool(true)
+bool(false)
+Error: Cannot create dynamic property UserCache\Cache::$hook
diff --git a/ext/user_cache/tests/user_cache_argument_validation.phpt b/ext/user_cache/tests/user_cache_argument_validation.phpt
new file mode 100644
index 000000000000..ff4f0eb671ea
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_argument_validation.phpt
@@ -0,0 +1,107 @@
+--TEST--
+UserCache\Cache: invalid keys, pool names, negative TTLs and negative leases throw ValueError
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+getMessage());
+ echo $label, ': ', $e::class, ': ', $message, "\n";
+ }
+}
+
+show_error('store empty', fn() => $cache->store('', 1));
+show_error('fetch delimiter', fn() => $cache->fetch("bad\x1fkey"));
+show_error('has empty', fn() => $cache->has(''));
+show_error('lock delimiter', fn() => $cache->lock("bad\x1fkey"));
+show_error('fetchMultiple empty', fn() => $cache->fetchMultiple(['ok', '']));
+show_error('fetchMultiple type', fn() => $cache->fetchMultiple(['ok', 1.5]));
+show_error('deleteMultiple type', fn() => $cache->deleteMultiple(['ok', new stdClass()]));
+show_error('storeMultiple empty key', fn() => $cache->storeMultiple(['' => 1]));
+show_error('storeMultiple delimiter key', fn() => $cache->storeMultiple(["bad\x1fkey" => 1]));
+
+/* Invalid pool names are rejected by the pool factory. */
+try {
+ UserCache\Cache::getPool('');
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+try {
+ UserCache\Cache::getPool("bad\x1fpool");
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+try {
+ UserCache\Cache::deletePool("bad\x1fname");
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+try {
+ UserCache\Cache::deletePool('');
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+/* A negative TTL throws on every write API and nothing is written. */
+$cache = UserCache\Cache::getPool('negative-ttl');
+
+$calls = [
+ 'store' => fn() => $cache->store('key', 1, -1),
+ 'add' => fn() => $cache->add('key', 1, -1),
+ 'storeMultiple' => fn() => $cache->storeMultiple(['key' => 1], -1),
+ 'increment' => fn() => $cache->increment('key', 1, -1),
+ 'decrement' => fn() => $cache->decrement('key', 1, -1),
+ 'remember' => fn() => $cache->remember('key', fn() => 1, -1),
+];
+
+foreach ($calls as $fn) {
+ try {
+ $fn();
+ } catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+ }
+}
+
+var_dump($cache->has('key'));
+
+/* A negative lock lease throws. */
+$cache = UserCache\Cache::getPool('negative-lease');
+
+try {
+ $cache->lock('key', -1);
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+store empty: ValueError: Argument #1 ($key) must be a non-empty string
+fetch delimiter: ValueError: Argument #1 ($key) must not contain the user-cache key delimiter 0x1F
+has empty: ValueError: Argument #1 ($key) must be a non-empty string
+lock delimiter: ValueError: Argument #1 ($key) must not contain the user-cache key delimiter 0x1F
+fetchMultiple empty: ValueError: Argument #1 ($keys) must contain only non-empty string or int cache keys that do not contain 0x1F
+fetchMultiple type: ValueError: Argument #1 ($keys) must contain only non-empty string or int cache keys that do not contain 0x1F
+deleteMultiple type: ValueError: Argument #1 ($keys) must contain only non-empty string or int cache keys that do not contain 0x1F
+storeMultiple empty key: ValueError: Argument #1 ($values) must be an array with non-empty string or int keys that do not contain 0x1F
+storeMultiple delimiter key: ValueError: Argument #1 ($values) must be an array with non-empty string or int keys that do not contain 0x1F
+UserCache\Cache::getPool(): Argument #1 ($pool) must not be empty
+UserCache\Cache::getPool(): Argument #1 ($pool) must not contain the user-cache key delimiter 0x1F
+UserCache\Cache::deletePool(): Argument #1 ($pool) must not contain the user-cache key delimiter 0x1F
+UserCache\Cache::deletePool(): Argument #1 ($pool) must not be empty
+UserCache\Cache::store(): Argument #3 ($ttl) must be greater than or equal to 0
+UserCache\Cache::add(): Argument #3 ($ttl) must be greater than or equal to 0
+UserCache\Cache::storeMultiple(): Argument #2 ($ttl) must be greater than or equal to 0
+UserCache\Cache::increment(): Argument #3 ($ttl) must be greater than or equal to 0
+UserCache\Cache::decrement(): Argument #3 ($ttl) must be greater than or equal to 0
+UserCache\Cache::remember(): Argument #3 ($ttl) must be greater than or equal to 0
+bool(false)
+UserCache\Cache::lock(): Argument #2 ($lease) must be greater than or equal to 0
diff --git a/ext/user_cache/tests/user_cache_array_hole_compaction.phpt b/ext/user_cache/tests/user_cache_array_hole_compaction.phpt
new file mode 100644
index 000000000000..bda7e3469f10
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_array_hole_compaction.phpt
@@ -0,0 +1,99 @@
+--TEST--
+UserCache\Cache: arrays with deleted elements or an oversized hash table take the same memory as their compact copies and keep their contents
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+--FILE--
+clear();
+ $before = Cache::getStatus()->getUsedMemory();
+ $sizes->store('value', $value);
+
+ return Cache::getStatus()->getUsedMemory() - $before;
+}
+
+$front = [];
+for ($i = 0; $i < 1000; $i++) {
+ $front["k$i"] = $i;
+}
+for ($i = 0; $i < 990; $i++) {
+ unset($front["k$i"]);
+}
+
+$shrunk = [];
+for ($i = 0; $i < 1025; $i++) {
+ $shrunk["k$i"] = $i;
+}
+for ($i = 20; $i < 1025; $i++) {
+ unset($shrunk["k$i"]);
+}
+
+$mixed = [];
+for ($i = 0; $i < 100; $i++) {
+ $mixed[$i * 7 - 50] = "v$i";
+ $mixed["s$i"] = [$i, "x$i"];
+}
+foreach (array_keys($mixed) as $n => $key) {
+ if ($n % 3) {
+ unset($mixed[$key]);
+ }
+}
+
+$nested = ['keep' => 1, 'front' => $front, 'mixed' => $mixed];
+unset($nested['keep']);
+
+$emptied = ['x' => 1, 'y' => 2];
+unset($emptied['x'], $emptied['y']);
+
+$cases = ['front' => $front, 'shrunk' => $shrunk, 'mixed' => $mixed, 'nested' => $nested, 'emptied' => $emptied];
+
+foreach ($cases as $name => $value) {
+ $compact = array_slice($value, 0, null, true);
+ printf("%s: %s\n", $name, used_by($sizes, $value) === used_by($sizes, $compact) ? 'compact size' : 'larger');
+
+ $cache->store($name, $value);
+ $fetched = $cache->fetch($name);
+ $same = $fetched === $value && array_keys($fetched) === array_keys($value);
+ foreach (['missing', -1, 123456, 'k0'] as $absent) {
+ $same = $same && array_key_exists($absent, $fetched) === array_key_exists($absent, $value);
+ }
+ $fetched[] = 'appended';
+ $value[] = 'appended';
+ var_dump($same && $fetched === $value && array_key_last($fetched) === array_key_last($value));
+}
+
+$pid = pcntl_fork();
+if ($pid < 0) die('fork failed');
+if ($pid === 0) {
+ $same = true;
+ foreach ($cases as $name => $value) {
+ $same = $same && Cache::getPool('array-hole-compaction')->fetch($name) === $value;
+ }
+ echo 'child: ';
+ var_dump($same);
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+?>
+--EXPECT--
+front: compact size
+bool(true)
+shrunk: compact size
+bool(true)
+mixed: compact size
+bool(true)
+nested: compact size
+bool(true)
+emptied: compact size
+bool(true)
+child: bool(true)
diff --git a/ext/user_cache/tests/user_cache_arrays.phpt b/ext/user_cache/tests/user_cache_arrays.phpt
new file mode 100644
index 000000000000..eb8b81cf0e36
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_arrays.phpt
@@ -0,0 +1,381 @@
+--TEST--
+UserCache\Cache: array keys, next-free index, empty tables and repeated subtrees round-trip exactly
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+ 'a', -1 => 'b', 0 => 'c', -100 => 'd', 7 => 'e'];
+$cache->store('negative', $negative);
+$fetched = $cache->fetch('negative');
+ok('negative keys value', $fetched === $negative);
+ok('negative keys order', array_keys($fetched) === [-5, -1, 0, -100, 7]);
+
+$sparse = [0 => 'x', 5 => 'y', 10 => 'z'];
+$cache->store('sparse', $sparse);
+$fetched = $cache->fetch('sparse');
+ok('sparse keys value', $fetched === $sparse);
+ok('sparse not list', !array_is_list($fetched));
+
+$packed = [10, 20, 30, 40];
+unset($packed[1]);
+$cache->store('unset', $packed);
+$fetched = $cache->fetch('unset');
+ok('post-unset keys', array_keys($fetched) === [0, 2, 3]);
+ok('post-unset value', $fetched === $packed);
+$fetched[] = 99;
+ok('append after unset uses key 4', array_key_last($fetched) === 4);
+
+$list = ['p', 'q', 'r'];
+$cache->store('list', $list);
+$fetched = $cache->fetch('list');
+ok('list is list', array_is_list($fetched));
+$fetched[] = 's';
+ok('list append index 3', array_key_last($fetched) === 3);
+
+$numeric = ['123' => 'a', '007' => 'b', '-3' => 'c', 'x' => 'd'];
+$cache->store('numeric', $numeric);
+$fetched = $cache->fetch('numeric');
+ok('numeric string key value', $fetched === $numeric);
+ok('numeric string key coerced', array_key_first($fetched) === 123 && array_key_exists(-3, $fetched));
+
+$limits = [PHP_INT_MAX => 'max', PHP_INT_MIN => 'min', 0 => 'zero'];
+$cache->store('limits', $limits);
+$fetched = $cache->fetch('limits');
+ok('int limit keys', $fetched[PHP_INT_MAX] === 'max' && $fetched[PHP_INT_MIN] === 'min' && $fetched[0] === 'zero');
+
+$transition = [0 => 'a', 1 => 'b'];
+$transition[100] = 'c';
+$cache->store('transition', $transition);
+$fetched = $cache->fetch('transition');
+ok('packed to hashed value', $fetched === $transition);
+ok('packed to hashed keys', array_keys($fetched) === [0, 1, 100]);
+
+$nested = [
+ 'neg' => $negative,
+ 'sparse' => $sparse,
+ 'list' => ['deep' => [-2 => 'q', 4 => 'r']],
+ 9 => [PHP_INT_MAX => 'z'],
+];
+$cache->store('nested', $nested);
+ok('nested mixed keys', $cache->fetch('nested') === $nested);
+
+$all = [$negative, $sparse, $packed, $list, $numeric, $limits, $transition, $nested];
+$cache->store('all', $all);
+ok('serialize parity', serialize($cache->fetch('all')) === serialize($all));
+
+/* nNextFreeElement survives both the dynamic (object element) and the verbatim (scalar-only) encoding */
+$cache = UserCache\Cache::getPool('arrays-next-free');
+
+$negativeObject = [-5 => new stdClass()];
+$control = $negativeObject;
+$cache->store('negative-object', $negativeObject);
+$fetched = $cache->fetch('negative-object');
+ok('negative object survived', $fetched[-5] instanceof stdClass);
+$control[] = 'y';
+$fetched[] = 'y';
+ok('negative object control keys', array_keys($control) === [-5, -4]);
+ok('negative object fetched keys', array_keys($fetched) === array_keys($control));
+
+$emptied = [];
+$emptied[-9] = 1;
+unset($emptied[-9]);
+$control = $emptied;
+$cache->store('emptied-negative', $emptied);
+$fetched = $cache->fetch('emptied-negative');
+ok('emptied is empty', $fetched === []);
+$control[] = 'x';
+$fetched[] = 'x';
+ok('emptied control keys', array_keys($control) === [-8]);
+ok('emptied fetched keys', array_keys($fetched) === array_keys($control));
+
+$hugeObject = [PHP_INT_MAX - 1 => new stdClass()];
+$control = $hugeObject;
+$cache->store('huge-object', $hugeObject);
+$fetched = $cache->fetch('huge-object');
+ok('huge object survived', $fetched[PHP_INT_MAX - 1] instanceof stdClass);
+$control[] = 'z';
+$fetched[] = 'z';
+$offsetsFromIntMax = static fn (array $array): string => implode(
+ ',',
+ array_map(static fn (int $key): int => $key - PHP_INT_MAX, array_keys($array))
+);
+echo 'huge control keys: ', $offsetsFromIntMax($control), "\n";
+echo 'huge fetched keys: ', $offsetsFromIntMax($fetched), "\n";
+ok('huge fetched keys match control', array_keys($fetched) === array_keys($control));
+
+$negativePlain = [-5 => 1];
+$control = $negativePlain;
+$cache->store('negative-plain', $negativePlain);
+$fetched = $cache->fetch('negative-plain');
+ok('plain value', $fetched === $negativePlain);
+$control[] = 'x';
+$fetched[] = 'x';
+ok('plain control keys', array_keys($control) === [-5, -4]);
+ok('plain fetched keys', array_keys($fetched) === array_keys($control));
+
+/* Empty arrays from the lazy (unallocated) table */
+$cache = UserCache\Cache::getPool('arrays-empty');
+
+class UserCacheEmptyArrayNode
+{
+ public ?UserCacheEmptyArrayNode $parent = null;
+ public array $children = [];
+
+ public function __construct(public string $name)
+ {
+ }
+
+ public function __sleep(): array
+ {
+ return ['parent', 'children', 'name'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+var_dump($cache->store('root', []));
+var_dump($cache->fetch('root'));
+
+var_dump($cache->store('nested', ['a' => [], 'b' => [[], []], 'c' => ['x' => 1]]));
+var_dump($cache->fetch('nested'));
+
+$shared = [];
+$wrap = ['first' => &$shared, 'second' => &$shared];
+var_dump($cache->store('shared', $wrap));
+$fetched = $cache->fetch('shared');
+$fetched['first'][] = 'linked';
+var_dump($fetched['second']);
+
+$root = new UserCacheEmptyArrayNode('root');
+$leaf = new UserCacheEmptyArrayNode('leaf');
+$leaf->parent = $root;
+$root->children[] = $leaf;
+var_dump($cache->store('graph', $root));
+$graph = $cache->fetch('graph');
+var_dump($graph->children[0]->children);
+var_dump($graph->children[0]->parent === $graph);
+
+$empty = $cache->fetch('root');
+$empty[] = 42;
+var_dump($empty);
+
+/* Release builds do not collect cycles at shutdown. */
+unset($shared, $wrap, $fetched, $root, $leaf, $graph, $empty);
+gc_collect_cycles();
+
+/* Mixed string/integer key order inside __wakeup objects */
+$cache = UserCache\Cache::getPool('arrays-mixed-keys');
+
+class MixedKeyHolder
+{
+ public array $data = [];
+
+ public function __wakeup(): void {}
+}
+
+$cases = [
+ 'string then int' => ['name' => 'x', 5 => 'y'],
+ 'strings then int' => ['a' => 1, 'b' => 2, 10 => 3],
+ 'int then string' => [5 => 'y', 'name' => 'x'],
+ 'interleaved' => ['a' => 1, 3 => 'x', 'b' => 2, 7 => 'y'],
+ 'packed' => ['a', 'b'],
+ 'nested mixed' => ['outer' => [1 => 'one', 'two' => 2], 9 => ['k' => 'v']],
+];
+
+foreach ($cases as $label => $data) {
+ $holder = new MixedKeyHolder();
+ $holder->data = $data;
+
+ var_dump($cache->store($label, $holder));
+ $fetched = $cache->fetch($label);
+ var_dump($fetched->data === $data);
+}
+
+/* Negative next-free index through the __sleep()/__wakeup() path */
+$cache = UserCache\Cache::getPool('arrays-serdes-next-free');
+
+class NegativeNextFreeBag
+{
+ public $arr;
+
+ public function __sleep(): array
+ {
+ return ['arr'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+$bag = new NegativeNextFreeBag();
+$bag->arr = [-5 => 'v'];
+
+ok('negative key store', $cache->store('bag', $bag) === true);
+
+$fetched = $cache->fetch('bag', 'default');
+ok('negative key fetch decoded', $fetched !== 'default');
+ok('negative key class', $fetched instanceof NegativeNextFreeBag);
+var_dump($fetched->arr);
+$fetched->arr[] = 'appended';
+ok('negative key append', array_keys($fetched->arr) === [-5, -4]);
+ok('negative key value survived', $fetched->arr[-5] === 'v');
+
+$emptied = [];
+$emptied[-9] = 1;
+unset($emptied[-9]);
+
+$bag = new NegativeNextFreeBag();
+$bag->arr = $emptied;
+
+ok('emptied store', $cache->store('emptied', $bag) === true);
+
+$fetched = $cache->fetch('emptied', 'default');
+ok('emptied fetch decoded', $fetched !== 'default');
+ok('emptied is empty', $fetched->arr === []);
+$fetched->arr[] = 'appended';
+ok('emptied append', array_keys($fetched->arr) === [-8]);
+
+/* Repeated arrays in a DAG restore as copy-on-write independent values */
+$cache = UserCache\Cache::getPool('arrays-dag');
+
+$shared = [
+ 'text' => str_repeat('q', 64),
+ 'list' => range(1, 32),
+ 'nested' => ['deep' => 'value'],
+];
+
+$dag = [
+ 'first' => $shared,
+ 'second' => $shared,
+ 'third' => $shared,
+ 'wrapped' => [$shared, $shared],
+];
+
+var_dump($cache->store('dag', $dag));
+$fetched = $cache->fetch('dag');
+var_dump($fetched === $dag);
+
+$fetched['first']['nested']['deep'] = 'mutated';
+var_dump($fetched['second']['nested']['deep']);
+var_dump($fetched['wrapped'][1] === $shared);
+
+$pair = [['k' => 'v'], ['k' => 'v']];
+var_dump($cache->store('pair', $pair));
+var_dump($cache->fetch('pair') === $pair);
+
+$dag['third']['extra'] = 'tail';
+var_dump($cache->store('dag', $dag));
+var_dump($cache->fetch('dag') === $dag);
+?>
+--EXPECT--
+negative keys value: OK
+negative keys order: OK
+sparse keys value: OK
+sparse not list: OK
+post-unset keys: OK
+post-unset value: OK
+append after unset uses key 4: OK
+list is list: OK
+list append index 3: OK
+numeric string key value: OK
+numeric string key coerced: OK
+int limit keys: OK
+packed to hashed value: OK
+packed to hashed keys: OK
+nested mixed keys: OK
+serialize parity: OK
+negative object survived: OK
+negative object control keys: OK
+negative object fetched keys: OK
+emptied is empty: OK
+emptied control keys: OK
+emptied fetched keys: OK
+huge object survived: OK
+huge control keys: -1,0
+huge fetched keys: -1,0
+huge fetched keys match control: OK
+plain value: OK
+plain control keys: OK
+plain fetched keys: OK
+bool(true)
+array(0) {
+}
+bool(true)
+array(3) {
+ ["a"]=>
+ array(0) {
+ }
+ ["b"]=>
+ array(2) {
+ [0]=>
+ array(0) {
+ }
+ [1]=>
+ array(0) {
+ }
+ }
+ ["c"]=>
+ array(1) {
+ ["x"]=>
+ int(1)
+ }
+}
+bool(true)
+array(1) {
+ [0]=>
+ string(6) "linked"
+}
+bool(true)
+array(0) {
+}
+bool(true)
+array(1) {
+ [0]=>
+ int(42)
+}
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+negative key store: OK
+negative key fetch decoded: OK
+negative key class: OK
+array(1) {
+ [-5]=>
+ string(1) "v"
+}
+negative key append: OK
+negative key value survived: OK
+emptied store: OK
+emptied fetch decoded: OK
+emptied is empty: OK
+emptied append: OK
+bool(true)
+bool(true)
+string(5) "value"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_atomic.phpt b/ext/user_cache/tests/user_cache_atomic.phpt
new file mode 100644
index 000000000000..be27b81b4555
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_atomic.phpt
@@ -0,0 +1,140 @@
+--TEST--
+UserCache\Cache: atomic increment and decrement, and error messages that cut a key at its NUL byte while the full key is stored and updated
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+var_dump($cache->increment('counter'));
+var_dump($cache->increment('counter', 4));
+var_dump($cache->decrement('counter', 2));
+var_dump($cache->fetch('counter'));
+
+var_dump($cache->decrement('missing', 3));
+var_dump($cache->fetch('missing'));
+
+var_dump($cache->store('string', 'x'));
+try {
+ $cache->increment('string');
+} catch (\ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+try {
+ $cache->decrement('string');
+} catch (\ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->fetch('string'));
+
+var_dump($cache->store('max', PHP_INT_MAX));
+try {
+ $cache->increment('max');
+} catch (\ArithmeticError $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->fetch('max') === PHP_INT_MAX);
+
+var_dump($cache->store('min', PHP_INT_MIN));
+try {
+ $cache->decrement('min');
+} catch (\ArithmeticError $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->fetch('min') === PHP_INT_MIN);
+
+try {
+ $cache->increment('counter', -1);
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+try {
+ $cache->decrement('counter', -1);
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+function nul_key_messages(): void
+{
+ $cache = UserCache\Cache::getPool('atomic-nul-key');
+ $cache->clear();
+
+ var_dump($cache->store('text', 5), $cache->store("text\0tail", 'x'));
+ try {
+ $cache->increment("text\0tail");
+ } catch (\ValueError $e) {
+ echo $e->getMessage(), "\n";
+ }
+ try {
+ $cache->decrement("text\0tail");
+ } catch (\ValueError $e) {
+ echo $e->getMessage(), "\n";
+ }
+
+ var_dump($cache->store("max\0tail", PHP_INT_MAX), $cache->store("min\0tail", PHP_INT_MIN));
+ try {
+ $cache->increment("max\0tail");
+ } catch (\ArithmeticError $e) {
+ echo $e->getMessage(), "\n";
+ }
+ try {
+ $cache->decrement("min\0tail");
+ } catch (\ArithmeticError $e) {
+ echo $e->getMessage(), "\n";
+ }
+
+ var_dump($cache->increment("n\0a", 2), $cache->increment("n\0b", 3), $cache->decrement("n\0a"));
+ var_dump($cache->fetch('text'), $cache->fetch("text\0tail"), $cache->fetch('n', 'MISS'), $cache->fetch("n\0a"), $cache->fetch("n\0b"));
+ var_dump($cache->fetch("max\0tail") === PHP_INT_MAX, $cache->fetch("min\0tail") === PHP_INT_MIN);
+
+ $keys = str_replace("\0", '\0', $cache->getPoolStatus()->getEntryKeys());
+ sort($keys);
+ echo implode(',', $keys), "\n";
+}
+
+echo "\nNUL byte in the key:\n";
+nul_key_messages();
+?>
+--EXPECTF--
+int(1)
+int(5)
+int(3)
+int(3)
+int(-3)
+int(-3)
+bool(true)
+Increment of user cache key "string" requires the stored value to be an integer
+Decrement of user cache key "string" requires the stored value to be an integer
+string(1) "x"
+bool(true)
+Increment of user cache key "max" would exceed the range of a PHP integer
+bool(true)
+bool(true)
+Decrement of user cache key "min" would exceed the range of a PHP integer
+bool(true)
+UserCache\Cache::increment(): Argument #2 ($step) must be greater than or equal to 0
+UserCache\Cache::decrement(): Argument #2 ($step) must be greater than or equal to 0
+
+NUL byte in the key:
+bool(true)
+bool(true)
+Increment of user cache key "text" requires the stored value to be an integer
+Decrement of user cache key "text" requires the stored value to be an integer
+bool(true)
+bool(true)
+Increment of user cache key "max" would exceed the range of a PHP integer
+Decrement of user cache key "min" would exceed the range of a PHP integer
+int(2)
+int(3)
+int(1)
+int(5)
+string(1) "x"
+string(4) "MISS"
+int(1)
+int(3)
+bool(true)
+bool(true)
+max\0tail,min\0tail,n\0a,n\0b,text,text\0tail
diff --git a/ext/user_cache/tests/user_cache_atomic_full_segment.phpt b/ext/user_cache/tests/user_cache_atomic_full_segment.phpt
new file mode 100644
index 000000000000..90071528d7d7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_atomic_full_segment.phpt
@@ -0,0 +1,107 @@
+--TEST--
+UserCache\Cache: increment() and decrement() on a full segment without eviction return null for new counters and still update existing ones, and a request that keeps reading a rewritten value stops pinning payloads after a quarter of the data area
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=2M
+user_cache.eviction_policy=none
+--FILE--
+clear();
+var_dump($cache->store('counter', 10), $cache->store('expiring-counter', 20, 60));
+
+$n = 0;
+foreach ([4000, 1000, 200, 40, 8, 1] as $size) {
+ while ($cache->store('fill-' . $n++, str_repeat('x', $size)));
+}
+$status = UserCache\Cache::getStatus();
+var_dump($status->getFreeMemory() < 64, $status->getEvictionCount(), $status->getExpungeCount());
+$failures = $status->getStoreFailureCount();
+
+/* A missing counter needs an allocation: capacity failures return null, not an exception */
+var_dump($cache->increment('missing'));
+var_dump($cache->decrement('missing', 3, 60));
+var_dump($cache->has('missing'));
+var_dump(UserCache\Cache::getStatus()->getStoreFailureCount() > $failures);
+
+/* Existing integer entries are updated in place on both the lock-free and the locked path */
+var_dump($cache->increment('counter', 5));
+var_dump($cache->decrement('counter', 20));
+var_dump($cache->increment('expiring-counter', 2));
+var_dump($cache->decrement('expiring-counter', 1, 60));
+var_dump($cache->fetch('counter'), $cache->fetch('expiring-counter'));
+
+/* Nothing was evicted or wiped to make room */
+$status = UserCache\Cache::getStatus();
+var_dump($status->getEvictionCount(), $status->getExpungeCount());
+var_dump($cache->fetch('fill-0') === str_repeat('x', 4000));
+
+function request_pin_budget(): void
+{
+ $cache = UserCache\Cache::getPool('request-pin-budget');
+ $cache->clear();
+
+ $cache->store('small', ['a' => 1, 'b' => [1, 2, 3]]);
+ $before = UserCache\Cache::getStatus()->getGraphPinnedReferences();
+ $small = $cache->fetch('small');
+ var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences() - $before);
+
+ $value = range(1, 8000);
+ $failures = 0;
+ $stale = 0;
+ $held = [];
+ for ($i = 0; $i < 200; $i++) {
+ $value[0] = $i;
+ if (!$cache->store('hot', $value)) {
+ $failures++;
+ }
+
+ $fetched = $cache->fetch('hot');
+ if ($fetched[0] !== $i || $cache->fetch('hot') !== $fetched) {
+ $stale++;
+ }
+
+ $held[] = $fetched;
+ }
+
+ $status = UserCache\Cache::getStatus();
+ var_dump($failures, $stale, $status->getGraphPinnedReferences() - $before < 16);
+ var_dump($held[0][0], $held[199][0], $small['b'][2]);
+ var_dump($status->getEvictionCount(), $status->getExpungeCount());
+}
+
+echo "\nrequest pin budget:\n";
+$cache->clear();
+request_pin_budget();
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+int(0)
+int(0)
+NULL
+NULL
+bool(false)
+bool(true)
+int(15)
+int(-5)
+int(22)
+int(21)
+int(-5)
+int(21)
+int(0)
+int(0)
+bool(true)
+
+request pin budget:
+int(1)
+int(0)
+int(0)
+bool(true)
+int(0)
+int(199)
+int(3)
+int(0)
+int(0)
diff --git a/ext/user_cache/tests/user_cache_atomic_locked_path.phpt b/ext/user_cache/tests/user_cache_atomic_locked_path.phpt
new file mode 100644
index 000000000000..e213a92f7f19
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_atomic_locked_path.phpt
@@ -0,0 +1,98 @@
+--TEST--
+UserCache\Cache: increment() and decrement() under the global lock (TTL, expiring entry, held entry lock) keep range and type checks
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+getMessage();
+ }
+}
+
+$cache = UserCache\Cache::getPool('atomic-locked');
+$cache->clear();
+
+/* A TTL argument never takes the lock-free scalar path */
+echo attempt(fn() => $cache->increment('ttl-arg', 5, 60)), "\n";
+echo attempt(fn() => $cache->decrement('ttl-arg', 7, 60)), "\n";
+echo attempt(fn() => $cache->increment('ttl-arg', 3, 60)), "\n";
+var_dump($cache->fetch('ttl-arg'));
+echo attempt(fn() => $cache->decrement('ttl-missing', 4, 60)), "\n";
+var_dump($cache->fetch('ttl-missing'));
+
+/* Entries that carry a TTL are updated under the lock even without a TTL argument */
+var_dump($cache->store('expiring-max', PHP_INT_MAX - 1, 60));
+echo attempt(fn() => $cache->increment('expiring-max')), "\n";
+echo attempt(fn() => $cache->increment('expiring-max')), "\n";
+var_dump($cache->fetch('expiring-max') === PHP_INT_MAX);
+
+var_dump($cache->store('expiring-min', PHP_INT_MIN + 1, 60));
+echo attempt(fn() => $cache->decrement('expiring-min')), "\n";
+echo attempt(fn() => $cache->decrement('expiring-min')), "\n";
+echo attempt(fn() => $cache->decrement('expiring-min', 0)), "\n";
+var_dump($cache->fetch('expiring-min') === PHP_INT_MIN);
+
+var_dump($cache->store('expiring-text', 'text', 60));
+var_dump($cache->store('expiring-float', 1.5, 60));
+echo attempt(fn() => $cache->increment('expiring-text')), "\n";
+echo attempt(fn() => $cache->decrement('expiring-text')), "\n";
+echo attempt(fn() => $cache->decrement('expiring-float', 1, 60)), "\n";
+var_dump($cache->fetch('expiring-text'), $cache->fetch('expiring-float'));
+
+/* A key held by lock() is updated under the lock and stays coherent afterwards */
+var_dump($cache->store('held', 10));
+var_dump($cache->lock('held'));
+echo attempt(fn() => $cache->decrement('held', 20)), "\n";
+echo attempt(fn() => $cache->increment('held', PHP_INT_MAX)), "\n";
+echo attempt(fn() => $cache->increment('held', 11)), "\n";
+var_dump($cache->fetch('held') === PHP_INT_MAX - 10);
+var_dump($cache->lock('held-missing'));
+echo attempt(fn() => $cache->decrement('held-missing', 4)), "\n";
+var_dump($cache->unlock('held'), $cache->unlock('held-missing'));
+
+echo attempt(fn() => $cache->increment('held')), "\n";
+var_dump($cache->fetch('held') === PHP_INT_MAX - 9);
+var_dump($cache->fetch('held-missing'));
+?>
+--EXPECTF--
+5
+-2
+1
+int(1)
+-4
+int(-4)
+bool(true)
+%d
+ArithmeticError: Increment of user cache key "expiring-max" would exceed the range of a PHP integer
+bool(true)
+bool(true)
+-%d-1
+ArithmeticError: Decrement of user cache key "expiring-min" would exceed the range of a PHP integer
+-%d-1
+bool(true)
+bool(true)
+bool(true)
+ValueError: Increment of user cache key "expiring-text" requires the stored value to be an integer
+ValueError: Decrement of user cache key "expiring-text" requires the stored value to be an integer
+ValueError: Decrement of user cache key "expiring-float" requires the stored value to be an integer
+string(4) "text"
+float(1.5)
+bool(true)
+bool(true)
+-10
+%d
+ArithmeticError: Increment of user cache key "held" would exceed the range of a PHP integer
+bool(true)
+bool(true)
+-4
+bool(true)
+bool(true)
+%d
+bool(true)
+int(-4)
diff --git a/ext/user_cache/tests/user_cache_basic.phpt b/ext/user_cache/tests/user_cache_basic.phpt
new file mode 100644
index 000000000000..82cad9d91401
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_basic.phpt
@@ -0,0 +1,127 @@
+--TEST--
+UserCache\Cache: basic API, add() and the immutable shm_size setting
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+$status = UserCache\Cache::getStatus();
+$poolStatus = $cache->getPoolStatus();
+var_dump($poolStatus->getPoolName(), $status->getAvailability()->name, $poolStatus->getEntryCount());
+
+var_dump($cache->store('array', ['x' => 1]));
+var_dump($cache->fetch('array'));
+var_dump($cache->has('array'));
+
+$fetched = $cache->fetch('array');
+$fetched['x'] = 2;
+var_dump($cache->fetch('array'));
+
+var_dump($cache->storeMultiple(['one' => 1, 'two' => 'x'], 60));
+var_dump($cache->fetch('one'));
+var_dump($cache->fetchMultiple(['one', 'missing'], 'fallback'));
+var_dump($cache->fetch('missing', 'fallback'));
+
+var_dump($cache->delete('one'));
+var_dump($cache->has('one'));
+
+var_dump($cache->delete('never-stored'));
+var_dump($cache->deleteMultiple(['never-stored', 'also-missing']));
+
+var_dump($cache->lock('lock'));
+var_dump($cache->unlock('lock'));
+
+var_dump($cache->lock('locked-store'));
+var_dump($cache->store('locked-store', 'value'));
+var_dump($cache->unlock('locked-store'));
+
+var_dump($cache->lock('locked-delete'));
+var_dump($cache->store('locked-delete', 'value'));
+var_dump($cache->delete('locked-delete'));
+var_dump($cache->unlock('locked-delete'));
+
+var_dump($cache->lock('locked-clear'));
+var_dump($cache->clear());
+var_dump($cache->unlock('locked-clear'));
+var_dump($cache->has('two'));
+
+/* add() stores only when the key is absent. */
+$cache = UserCache\Cache::getPool('add');
+$cache->clear();
+
+var_dump($cache->add('key', 'first'));
+var_dump($cache->add('key', 'second'));
+var_dump($cache->fetch('key'));
+
+var_dump($cache->store('key', 'overwritten'));
+var_dump($cache->fetch('key'));
+
+var_dump($cache->delete('key'));
+var_dump($cache->add('key', 'after-delete'));
+var_dump($cache->fetch('key'));
+
+var_dump($cache->add('ttl-key', 'v', 60));
+var_dump($cache->fetch('ttl-key'));
+
+/* user_cache.shm_size cannot be changed at runtime. */
+var_dump(ini_set('user_cache.shm_size', '32M'));
+var_dump(ini_get('user_cache.shm_size'));
+var_dump(UserCache\Cache::getStatus()->getConfiguredMemory());
+?>
+--EXPECT--
+string(5) "basic"
+string(9) "Available"
+int(0)
+bool(true)
+array(1) {
+ ["x"]=>
+ int(1)
+}
+bool(true)
+array(1) {
+ ["x"]=>
+ int(1)
+}
+bool(true)
+int(1)
+array(2) {
+ ["one"]=>
+ int(1)
+ ["missing"]=>
+ string(8) "fallback"
+}
+string(8) "fallback"
+bool(true)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+string(5) "first"
+bool(true)
+string(11) "overwritten"
+bool(true)
+bool(true)
+string(12) "after-delete"
+bool(true)
+string(1) "v"
+bool(false)
+string(3) "16M"
+int(16777216)
diff --git a/ext/user_cache/tests/user_cache_block_merge_limit.phpt b/ext/user_cache/tests/user_cache_block_merge_limit.phpt
new file mode 100644
index 000000000000..07907772c69f
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_block_merge_limit.phpt
@@ -0,0 +1,67 @@
+--TEST--
+UserCache\Cache: free blocks that exceed the merge limit together stay linked, so freeing the tail returns all of them
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_SMALL_BLOCK_MERGE_LIMIT=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=2M
+user_cache.entries_hint=100
+--FILE--
+clear();
+$cache->store('q', str_repeat('q', 900 * 1024));
+$cache->store('x', str_repeat('x', 30 * 1024));
+$cache->store('y', 'tail');
+var_dump($cache->delete('q'), $cache->delete('x'), $cache->delete('y'));
+var_dump($cache->store('big', $big), $cache->has('big'));
+
+echo "merge into a block whose own previous merge was refused:\n";
+$cache->clear();
+$cache->store('q', str_repeat('q', 900 * 1024));
+$cache->store('p', str_repeat('p', 30 * 1024));
+$cache->store('b', str_repeat('b', 10 * 1024));
+$cache->store('y', 'tail');
+var_dump($cache->delete('q'), $cache->delete('p'), $cache->delete('b'), $cache->delete('y'));
+var_dump($cache->store('big', $big), $cache->has('big'));
+
+echo "refused merge with the next block:\n";
+$cache->clear();
+$cache->store('a', str_repeat('a', 30 * 1024));
+$cache->store('n', str_repeat('n', 900 * 1024));
+$cache->store('y', 'tail');
+var_dump($cache->delete('n'), $cache->delete('a'), $cache->delete('y'));
+var_dump($cache->store('big', $big), $cache->has('big'));
+var_dump(Cache::getStatus()->getStoreFailureCount());
+?>
+--EXPECT--
+refused merge with the previous block:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+merge into a block whose own previous merge was refused:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+refused merge with the next block:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(0)
diff --git a/ext/user_cache/tests/user_cache_block_merge_limit_split.phpt b/ext/user_cache/tests/user_cache_block_merge_limit_split.phpt
new file mode 100644
index 000000000000..6b6d86aac582
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_block_merge_limit_split.phpt
@@ -0,0 +1,39 @@
+--TEST--
+UserCache\Cache: the remainder of a split free block joins the free block after it when the merge limit allows
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_SMALL_BLOCK_MERGE_LIMIT=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=256K
+user_cache.eviction_policy=none
+--FILE--
+store('a', str_repeat('a', 50000)), $cache->store('b', str_repeat('b', 30000)));
+for ($fillers = 0; $cache->store("fill$fillers", str_repeat('f', 1000)); $fillers++);
+
+/* Together the two free blocks exceed the 64 KiB merge limit, so they stay apart. */
+var_dump($cache->delete('a'), $cache->delete('b'));
+
+/* Splitting the first block leaves a remainder that fits under the limit with the second one. */
+var_dump($cache->store('d', str_repeat('d', 45000)));
+var_dump($cache->store('e', str_repeat('e', 33000)), $cache->fetch('e') === str_repeat('e', 33000));
+var_dump(Cache::getStatus()->getStoreFailureCount());
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_build_identity.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_build_identity.phpt
new file mode 100644
index 000000000000..09a654165fae
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_build_identity.phpt
@@ -0,0 +1,55 @@
+--TEST--
+UserCache\Cache: a boundary segment outlives php-cgi restarts, is only reattached by the same build identity and is removed once idle by a process of another build for the same boundary
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+docRoot('site', <<<'PHP'
+store('k', 'seeded');
+}
+echo $cache->fetch('k', 'MISS');
+PHP);
+ $script = $root . '/index.php';
+
+ $run = function (bool $otherBuild, string $query) use ($server, $script, $root): string {
+ putenv('USER_CACHE_DEBUG_SIMULATE_OTHER_BUILD' . ($otherBuild ? '=1' : ''));
+ $server->start(['user_cache.enable=1', 'user_cache.shm_size=1M'], $script, $root, 'identity.local', 'action=fetch');
+ $out = $server->request($script, $root, 'identity.local', $query);
+ $server->stop();
+
+ return $out;
+ };
+
+ $locks = fn(): int => count(glob($server->path('.PhpUserCacheBnd.*/*.lock')) ?: []);
+
+ echo $run(false, 'action=seed'), "\n";
+ echo $run(false, 'action=fetch'), "\n";
+ echo $run(true, 'action=fetch'), ' ', $locks(), "\n";
+ echo $run(false, 'action=fetch'), ' ', $locks(), "\n";
+} finally {
+ putenv('USER_CACHE_DEBUG_SIMULATE_OTHER_BUILD');
+ $server->cleanup();
+}
+?>
+--EXPECT--
+seeded
+seeded
+MISS 1
+MISS 1
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_failed_startup_retry.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_failed_startup_retry.phpt
new file mode 100644
index 000000000000..0107ddae62e7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_failed_startup_retry.phpt
@@ -0,0 +1,98 @@
+--TEST--
+CGI: a request whose boundary partition failed to start at activation stays unavailable, and the next activation starts it with the INI in effect before the [PATH=] section
+--SKIPIF--
+
+--FILE--
+getSharedMemorySize(), ' ';
+if (isset($_GET['store'])) {
+ var_dump($cache->store($_GET['store'], 'from-' . basename(__DIR__)));
+} else {
+ var_dump($cache->fetch($_GET['fetch']));
+}
+PHP;
+file_put_contents($smallDir . '/index.php', $script);
+file_put_contents($docRoot . '/index.php', $script);
+
+function run_cgi(string $ini, string $docRoot, string $script, string $query, bool $failStartup): string
+{
+ $process = proc_open(
+ [FcgiTester::binary(), '-q', '-c', $ini],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $pipes,
+ dirname($script),
+ [
+ 'REDIRECT_STATUS' => '1',
+ 'REQUEST_METHOD' => 'GET',
+ 'QUERY_STRING' => $query,
+ 'SCRIPT_FILENAME' => $script,
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'SERVER_NAME' => 'failed-startup.local',
+ 'USER_CACHE_DEBUG_FAIL_BOUNDARY_SEGMENT' => $failStartup ? '1' : '0',
+ ]
+ );
+ fclose($pipes[0]);
+ $stdout = trim(preg_replace('/\A(?:[A-Za-z-]+: [^\r\n]*\r?\n)+\r?\n/', '', stream_get_contents($pipes[1])));
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ return $stdout;
+}
+
+try {
+ echo 'small, failed startup: ', run_cgi($ini, $docRoot, $smallDir . '/index.php', 'store=p', true), "\n";
+ echo 'global: ', run_cgi($ini, $docRoot, $docRoot . '/index.php', 'store=q', false), "\n";
+ echo 'small: ', run_cgi($ini, $docRoot, $smallDir . '/index.php', 'fetch=q', false), "\n";
+ echo 'small, failed startup: ', run_cgi($ini, $docRoot, $smallDir . '/index.php', 'fetch=q', true), "\n";
+ echo 'global: ', run_cgi($ini, $docRoot, $docRoot . '/index.php', 'fetch=p', false), "\n";
+} finally {
+ FcgiTester::removeBoundarySegments($lockfilePath);
+ foreach ([$smallDir . '/index.php', $docRoot . '/index.php', $ini] as $file) {
+ @unlink($file);
+ }
+ $private = $lockfilePath . '/.PhpUserCacheBnd.' . fileowner($root);
+ array_map('unlink', glob($private . '/{,.}[!.]*', GLOB_BRACE) ?: []);
+ @rmdir($private);
+ @rmdir($lockfilePath);
+ @rmdir($smallDir);
+ @rmdir($docRoot);
+ @rmdir($root);
+}
+?>
+--EXPECT--
+small, failed startup: 0 bool(false)
+global: 8388608 bool(true)
+small: 8388608 string(8) "from-doc"
+small, failed startup: 0 NULL
+global: 8388608 NULL
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_gc_reentry.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_gc_reentry.phpt
new file mode 100644
index 000000000000..8afba50a0606
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_gc_reentry.phpt
@@ -0,0 +1,329 @@
+--TEST--
+UserCache\Cache: destructors run by the cycle collector inside CGI boundary requests may re-create a deleted pool, refill a key record whose value is being released, see an empty pool registry at request shutdown and see consistent records while records are dropped
+--SKIPIF--
+
+--FILE--
+ '1',
+ 'REQUEST_METHOD' => 'GET',
+ 'SCRIPT_FILENAME' => $script,
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'SERVER_NAME' => $name . '.local',
+ 'USE_ZEND_ALLOC' => getenv('USE_ZEND_ALLOC') ?: '1',
+ ]
+ );
+ fclose($pipes[0]);
+ /* A startup notice flushes the CGI headers before -q applies. */
+ echo trim(preg_replace('/\A(?:[A-Za-z-]+: [^\r\n]*\r?\n)+\r?\n/', '', stream_get_contents($pipes[1]))), "\n";
+ $stderr = trim(stream_get_contents($pipes[2]));
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ echo 'exit: ', proc_close($process), $stderr !== '' ? " [stderr: $stderr]" : '', "\n";
+ } finally {
+ FcgiTester::removeBoundarySegments($root);
+ foreach ([$script, $docRoot, $lockfilePath . '/.PhpUserCacheBnd.' . fileowner($root)] as $path) {
+ if (is_dir($path)) {
+ array_map('unlink', glob($path . '/{,.}[!.]*', GLOB_BRACE) ?: []);
+ @rmdir($path);
+ } elseif (file_exists($path)) {
+ unlink($path);
+ }
+ }
+ @rmdir($lockfilePath);
+ @rmdir($root);
+ }
+}
+
+echo "delete pool destructor:\n";
+/* Boundary partitions keep fetched arrays as private copies in the key records,
+ * so deletePool() releases collectable values. */
+user_cache_cgi_boundary_run('delete-pool', <<<'PHP'
+store('from-destructor', 1);
+ echo "destructor re-created the pool\n";
+ }
+}
+
+$pool = Cache::getPool('delete-pool');
+$arrays = 3000;
+for ($i = 0; $i < $arrays; $i++) {
+ $pool->store("k$i", [$i, $i + 1, $i + 2]);
+}
+$held = [];
+for ($i = 0; $i < $arrays; $i++) {
+ $held[] = $pool->fetch("k$i");
+}
+gc_collect_cycles();
+/* Fill the root buffer so that releasing the record values starts a collection. */
+$keep = [];
+for ($i = 0; $i < 8990; $i++) {
+ $object = new stdClass;
+ $keep[] = $object;
+}
+unset($object);
+$probe = new Probe;
+$probe->self = $probe;
+unset($probe, $pool);
+$replacement = null;
+var_dump(Cache::deletePool('delete-pool'));
+var_dump($replacement instanceof Cache);
+var_dump(Cache::getPool('delete-pool') === $replacement);
+var_dump(Cache::getPool('delete-pool')->fetch('from-destructor'));
+PHP);
+
+echo "\nrecord value reentry:\n";
+/* Boundary partitions keep fetched arrays as collectable private copies in the key records, so releasing them can start
+ * a collection. */
+user_cache_cgi_boundary_run('record-reentry', <<<'PHP'
+action)();
+ }
+}
+
+function arm(Closure $action): void
+{
+ gc_collect_cycles();
+ $probe = new Probe($action);
+ $probe->self = $probe;
+ unset($probe);
+ $GLOBALS['keep'] = [];
+ while (gc_status()['roots'] < gc_status()['threshold'] - 2) {
+ $object = new stdClass;
+ $GLOBALS['keep'][] = $object;
+ }
+}
+
+/* Two pool objects share one scope, so a destructor can put a new private copy into the record of the other one. */
+$a = Cache::getPool('record-reentry');
+Cache::deletePool('record-reentry');
+$b = Cache::getPool('record-reentry');
+
+$b->store('z', 1);
+$b->store('z', 2);
+$b->store('k', [1, 2, 3]);
+$held = $a->fetch('k');
+arm(function () use ($a, $b): void {
+ echo "destructor before the scalar write\n";
+ $b->store('k', [4, 5, 6]);
+ $GLOBALS['held2'] = $a->fetch('k');
+ $b->store('k', 5);
+});
+var_dump($a->store('k', 7), $a->fetch('k'));
+
+$b->store('n', [1, 2, 3]);
+$held3 = $a->fetch('n');
+$b->store('n', 10);
+arm(function () use ($a, $b): void {
+ echo "destructor during the increment\n";
+ $b->store('n', str_repeat('x', 300));
+ var_dump(strlen($a->fetch('n')));
+});
+var_dump($a->increment('n', 1, 60), $a->fetch('n') === str_repeat('x', 300));
+echo "done\n";
+PHP, ['-d', 'report_memleaks=1']);
+
+echo "\nshutdown pool registry:\n";
+/* Boundary partitions keep fetched arrays as collectable private copies in the key records, so releasing the pools at
+ * request shutdown can start a collection. */
+user_cache_cgi_boundary_run('shutdown-pools', <<<'PHP'
+fetch('k0'));
+ }
+}
+
+$held = [];
+$pool = Cache::getPool('shutdown-pools');
+for ($i = 0; $i < 300; $i++) {
+ $pool->store("k$i", [$i, 1, 2]);
+ $held[] = $pool->fetch("k$i");
+}
+unset($pool);
+
+/* Output callbacks run after the destructor phase, so only the cycle collector destroys what they create. */
+ob_start(function (string $buffer): string {
+ gc_collect_cycles();
+ $probe = new Probe;
+ $probe->self = $probe;
+ unset($probe);
+ $keep = [];
+ while (gc_status()['roots'] < gc_status()['threshold'] - 100) {
+ $object = new stdClass;
+ $keep[] = $object;
+ }
+ $GLOBALS['keep'] = $keep;
+
+ return $buffer . "output callback done\n";
+});
+echo "main done\n";
+PHP);
+
+echo "\nrecord trim destructor:\n";
+/* Boundary partitions keep fetched arrays as private copies in the key records,
+ * so dropping records releases collectable values. */
+user_cache_cgi_boundary_run('record-trim', <<<'PHP'
+fetch('k0') === [0, 1, 2], true), "\n";
+ }
+}
+
+$pool = Cache::getPool('record-trim');
+$limit = max(16384, Cache::getStatus()->getEntryCapacity());
+$arrays = 3000;
+for ($i = 0; $i < $arrays; $i++) {
+ $pool->store("k$i", [$i, $i + 1, $i + 2]);
+}
+$held = [];
+for ($i = 0; $i < $arrays; $i++) {
+ $held[] = $pool->fetch("k$i");
+}
+for ($i = 0; $i < $limit - $arrays; $i++) {
+ $pool->has("miss-$i");
+}
+gc_collect_cycles();
+/* Fill the root buffer so that releasing the dropped values starts a collection. */
+$keep = [];
+for ($i = 0; $i < 8990; $i++) {
+ $object = new stdClass;
+ $keep[] = $object;
+}
+unset($object);
+$probe = new Probe;
+$probe->self = $probe;
+unset($probe);
+$dropping = true;
+$pool->has('trigger');
+$dropping = false;
+echo "done\n";
+PHP);
+?>
+--EXPECT--
+delete pool destructor:
+destructor re-created the pool
+bool(true)
+bool(true)
+bool(true)
+int(1)
+exit: 0
+
+record value reentry:
+destructor before the scalar write
+bool(true)
+int(7)
+destructor during the increment
+int(300)
+int(11)
+bool(true)
+done
+exit: 0
+
+shutdown pool registry:
+main done
+output callback done
+destructor at request shutdown
+bool(false)
+bool(true)
+array(0) {
+}
+NULL
+exit: 0
+
+record trim destructor:
+destructor while dropping records: true
+dropped key still readable: true
+done
+exit: 0
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_lockfile_replaced.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_lockfile_replaced.phpt
new file mode 100644
index 000000000000..4cfafdc8a667
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_lockfile_replaced.phpt
@@ -0,0 +1,90 @@
+--TEST--
+CGI/FastCGI: a process that finds the lock file of an fcntl-locked boundary segment replaced retires the segment name without writing to the old segment, so the old and new processes use separate segments
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+ glob('/dev/shm/PUC.*') ?: [];
+$before = $segments();
+$old = new FcgiTester('lockfile-replaced-old');
+$new = new FcgiTester('lockfile-replaced-new');
+
+try {
+ $root = $old->docRoot('site', <<<'PHP'
+store('key', $_GET['value']);
+}
+echo $cache->fetch('key', 'MISS'), ' ', UserCache\Cache::getStatus()->getAvailability()->name;
+PHP);
+ file_put_contents($root . '/ready.php', 'path('locks');
+ mkdir($locks);
+ $ini = ['user_cache.enable=1', 'user_cache.shm_size=8M', 'user_cache.lockfile_path=' . $locks];
+
+ putenv('USER_CACHE_DEBUG_FORCE_FCNTL_LOCK_MODEL=1');
+
+ $old->start($ini, $root . '/ready.php', $root, 'lockfile.local', '');
+ echo 'old: ', $old->request($script, $root, 'lockfile.local', 'value=old'), "\n";
+ $created = array_values(array_diff($segments(), $before));
+ var_dump(count($created));
+
+ /* The scalar header fields (sequence, counts, allocator state) of the segment the old process uses; the name may be reused later. */
+ $oldSegment = fopen($created[0], 'rb');
+ $oldHeader = function () use ($oldSegment): string {
+ rewind($oldSegment);
+
+ return fread($oldSegment, 176);
+ };
+ $oldHeaderBefore = $oldHeader();
+
+ /* A temp cleaner deletes the lock file while the old process keeps its descriptor. */
+ foreach (glob($locks . '/.PhpUserCacheBnd.*/*.lock') as $lock) {
+ unlink($lock);
+ }
+
+ $new->start($ini, $root . '/ready.php', $root, 'lockfile.local', '');
+ echo 'new: ', $new->request($script, $root, 'lockfile.local', ''), "\n";
+ var_dump($oldHeader() === $oldHeaderBefore);
+ echo 'new: ', $new->request($script, $root, 'lockfile.local', 'value=new'), "\n";
+ echo 'old: ', $old->request($script, $root, 'lockfile.local', ''), "\n";
+ echo 'new: ', $new->request($script, $root, 'lockfile.local', ''), "\n";
+ var_dump(count(array_diff($segments(), $before)));
+
+ putenv('USER_CACHE_DEBUG_FORCE_FCNTL_LOCK_MODEL');
+} finally {
+ if (isset($oldSegment)) {
+ fclose($oldSegment);
+ }
+ $new->stop();
+ $old->stop();
+ FcgiTester::removeBoundarySegments($locks ?? $old->path('locks'));
+ foreach (array_diff($segments(), $before) as $segment) {
+ @unlink($segment);
+ }
+ $new->cleanup();
+ $old->cleanup();
+}
+?>
+--EXPECT--
+old: old Available
+int(1)
+new: MISS Available
+bool(true)
+new: new Available
+old: old Available
+new: new Available
+int(1)
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_per_host_ini.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_per_host_ini.phpt
new file mode 100644
index 000000000000..2865bfcc2468
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_per_host_ini.phpt
@@ -0,0 +1,92 @@
+--TEST--
+CGI: a per-host INI section that changes user_cache.entries_hint does not break writes of the process that created the boundary segment
+--SKIPIF--
+
+--FILE--
+store('pid', getmypid()), true), ' ', var_export($cache->fetch('pid') === getmypid(), true), "\n";
+PHP);
+
+try {
+ for ($i = 1; $i <= 2; $i++) {
+ $process = proc_open(
+ [user_cache_cgi_binary(), '-q', '-c', $ini],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $pipes,
+ $docRoot,
+ [
+ 'REDIRECT_STATUS' => '1',
+ 'REQUEST_METHOD' => 'GET',
+ 'SCRIPT_FILENAME' => $script,
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'SERVER_NAME' => 'per-host-ini.local',
+ ]
+ );
+ fclose($pipes[0]);
+ $lines = preg_split('/\r?\n/', trim(stream_get_contents($pipes[1])));
+ $stderr = trim(stream_get_contents($pipes[2]));
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+ echo "run $i: ", end($lines), $stderr !== '' ? " [stderr: $stderr]" : '', "\n";
+ }
+} finally {
+ FcgiTester::removeBoundarySegments($root);
+ foreach ([$script, $ini] as $file) {
+ @unlink($file);
+ }
+ $private = $lockfilePath . '/.PhpUserCacheBnd.' . fileowner($root);
+ array_map('unlink', glob($private . '/{,.}[!.]*', GLOB_BRACE) ?: []);
+ @rmdir($private);
+ @rmdir($lockfilePath);
+ @rmdir($docRoot);
+ @rmdir($root);
+}
+?>
+--EXPECT--
+run 1: 5000 true true
+run 2: 5000 true true
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_private_dir.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_private_dir.phpt
new file mode 100644
index 000000000000..0553762ae9d0
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_private_dir.phpt
@@ -0,0 +1,201 @@
+--TEST--
+CGI/FastCGI: boundary rendezvous objects live in a per-uid private directory and fail closed when it is squatted
+--SKIPIF--
+
+--FILE--
+ '1',
+ 'REQUEST_METHOD' => 'GET',
+ 'SCRIPT_FILENAME' => $script,
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'SERVER_NAME' => 'private-dir.local',
+ 'QUERY_STRING' => $query,
+ ]
+ );
+ if (!is_resource($process)) {
+ throw new RuntimeException('Unable to start php-cgi');
+ }
+
+ fclose($pipes[0]);
+ $stdout = stream_get_contents($pipes[1]);
+ $stderr = stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ /* A warning raised during request startup flushes the CGI headers
+ * before -q applies; the script body is the last line either way. */
+ $lines = preg_split('/\r?\n/', trim($stdout));
+
+ return [end($lines), trim($stderr)];
+}
+
+function user_cache_cgi_rm_rf(string $path): void
+{
+ if (!file_exists($path) && !is_link($path)) {
+ return;
+ }
+
+ if (!is_dir($path) || is_link($path)) {
+ unlink($path);
+ return;
+ }
+
+ foreach (scandir($path) as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ user_cache_cgi_rm_rf($path . DIRECTORY_SEPARATOR . $entry);
+ }
+
+ rmdir($path);
+}
+
+function user_cache_private_dir_entries(string $dir): array
+{
+ $entries = scandir($dir);
+
+ return $entries === false ? [] : array_values(array_diff($entries, ['.', '..']));
+}
+
+$root = sys_get_temp_dir() . '/php-user-cache-cgi-private-dir-' . getmypid();
+$docRoot = $root . '/doc';
+$script = $docRoot . '/index.php';
+
+user_cache_cgi_rm_rf($root);
+mkdir($docRoot, 0777, true);
+
+/* A directory created by this process is owned by the effective uid. */
+$uid = fileowner($root);
+$privateName = '.PhpUserCacheBnd.' . $uid;
+
+file_put_contents($script, <<<'PHP'
+store('key', 'seeded');
+}
+echo UserCache\Cache::getStatus()->getAvailability()->name, ':', $cache->fetch('key', 'MISS'), "\n";
+PHP);
+
+try {
+ $phpCgi = user_cache_cgi_binary();
+
+ /* Independently started processes share the private directory. */
+ $lockfilePath = $root . '/lock';
+ mkdir($lockfilePath, 0777);
+
+ [$stdout, $stderr] = user_cache_cgi_run($phpCgi, $script, $docRoot, $lockfilePath, 'action=seed');
+ echo "first: $stdout", $stderr !== '' ? " [stderr: $stderr]" : '', "\n";
+ [$stdout, $stderr] = user_cache_cgi_run($phpCgi, $script, $docRoot, $lockfilePath, 'action=fetch');
+ echo "second: $stdout", $stderr !== '' ? " [stderr: $stderr]" : '', "\n";
+
+ $privateDir = $lockfilePath . '/' . $privateName;
+ var_dump(is_dir($privateDir) && !is_link($privateDir));
+ var_dump(fileowner($privateDir) === $uid);
+ var_dump(sprintf('%04o', fileperms($privateDir) & 0777));
+
+ $entries = user_cache_private_dir_entries($privateDir);
+ sort($entries);
+ var_dump(count($entries));
+ var_dump(filesize($privateDir . '/salt'));
+ var_dump(sprintf('%04o', fileperms($privateDir . '/salt') & 0777));
+ var_dump(preg_match('/^[0-9a-f]{24}\.lock$/', $entries[0]) === 1);
+ var_dump(sprintf('%04o', fileperms($privateDir . '/' . $entries[0]) & 0777));
+ /* Nothing but the private directory lands in user_cache.lockfile_path. */
+ var_dump(user_cache_private_dir_entries($lockfilePath) === [$privateName]);
+
+ /* Reject a directory owned by this uid with unsafe permissions. */
+ $lockfilePath = $root . '/lock-mode';
+ mkdir($lockfilePath . '/' . $privateName, 0755, true);
+
+ [$stdout, $stderr] = user_cache_cgi_run($phpCgi, $script, $docRoot, $lockfilePath, 'action=seed');
+ echo "wrong-mode: $stdout\n";
+ echo str_replace($root, '%ROOT%', $stderr), "\n";
+ var_dump(user_cache_private_dir_entries($lockfilePath . '/' . $privateName));
+
+ /* Symlink planted at the directory name: never followed. */
+ $lockfilePath = $root . '/lock-symlink';
+ mkdir($lockfilePath, 0777);
+ mkdir($root . '/symlink-target', 0700);
+ symlink($root . '/symlink-target', $lockfilePath . '/' . $privateName);
+
+ [$stdout, $stderr] = user_cache_cgi_run($phpCgi, $script, $docRoot, $lockfilePath, 'action=seed');
+ echo "symlink: $stdout\n";
+ echo str_replace($root, '%ROOT%', $stderr), "\n";
+ var_dump(user_cache_private_dir_entries($root . '/symlink-target'));
+
+ echo "Done\n";
+} finally {
+ FcgiTester::removeBoundarySegments($root);
+ user_cache_cgi_rm_rf($root);
+}
+
+?>
+--EXPECTF--
+first: Available:seeded
+second: Available:seeded
+bool(true)
+bool(true)
+string(4) "0700"
+int(2)
+int(32)
+string(4) "0600"
+bool(true)
+string(4) "0600"
+bool(true)
+wrong-mode: UnavailableBySharedMemoryInitializationFailed:MISS
+UserCache: boundary directory %ROOT%/lock-mode/.PhpUserCacheBnd.%d is unusable (not a private directory owned by this uid); it must be a directory owned by uid %d with mode 0700 (see user_cache.lockfile_path)
+UserCache: partition startup failed; UserCache will be unavailable
+array(0) {
+}
+symlink: UnavailableBySharedMemoryInitializationFailed:MISS
+UserCache: boundary directory %ROOT%/lock-symlink/.PhpUserCacheBnd.%d is unusable (%s); it must be a directory owned by uid %d with mode 0700 (see user_cache.lockfile_path)
+UserCache: partition startup failed; UserCache will be unavailable
+array(0) {
+}
+Done
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_segment_lifetime.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_segment_lifetime.phpt
new file mode 100644
index 000000000000..937b0b2cf7b8
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_segment_lifetime.phpt
@@ -0,0 +1,190 @@
+--TEST--
+CGI: idle boundary segments outlive php-cgi restarts, and a process that attaches a boundary removes the idle segments of the same boundary with another layout, orphaned lock files and the least recently used idle segments beyond 32
+--SKIPIF--
+
+--FILE--
+store('k', $_GET['value']);
+}
+if (isset($_GET['release'])) {
+ if (pcntl_fork() === 0) {
+ while (!file_exists($_GET['release'])) {
+ usleep(10000);
+ }
+ exit(0);
+ }
+}
+echo $cache->fetch('k', 'MISS');
+PHP;
+file_put_contents($site . '/index.php', $script);
+
+function cgi(string $docRoot, string $lockPath, string $shmSize, string $query = ''): string
+{
+ $out = $docRoot . '/../out.txt';
+ $process = proc_open(
+ [FcgiTester::binary(), '-q', '-n', '-d', 'user_cache.lockfile_path=' . $lockPath, '-d', 'user_cache.shm_size=' . $shmSize],
+ [['pipe', 'r'], ['file', $out, 'w'], ['file', $out . '.err', 'w']],
+ $pipes,
+ $docRoot,
+ [
+ 'REDIRECT_STATUS' => '1',
+ 'REQUEST_METHOD' => 'GET',
+ 'SCRIPT_FILENAME' => $docRoot . '/index.php',
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'SERVER_NAME' => 'segment-lifetime.local',
+ 'QUERY_STRING' => $query,
+ ]
+ );
+ fclose($pipes[0]);
+ proc_close($process);
+
+ $response = explode("\r\n\r\n", file_get_contents($out), 2);
+
+ return trim(end($response) . file_get_contents($out . '.err'));
+}
+
+function lock_ids(string $lockPath): array
+{
+ return array_map(fn($lock) => basename($lock, '.lock'), glob($lockPath . '/.PhpUserCacheBnd.*/*.lock') ?: []);
+}
+
+function segments_exist(array $ids): bool
+{
+ foreach ($ids as $id) {
+ if (!is_file('/dev/shm/PUC.' . $id)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function wait_for_lock_count(string $lockPath, int $count): int
+{
+ for ($i = 0; $i < 500 && count(lock_ids($lockPath)) !== $count; $i++) {
+ usleep(10000);
+ }
+
+ return count(lock_ids($lockPath));
+}
+
+try {
+ echo "restart:\n";
+ echo cgi($site, $locks, '1M', 'value=first'), "\n";
+ echo cgi($site, $locks, '1M'), "\n";
+ $first = lock_ids($locks);
+ var_dump(count($first), segments_exist($first));
+
+ echo "other layout replaces the idle segment:\n";
+ echo cgi($site, $locks, '2M'), "\n";
+ $second = lock_ids($locks);
+ var_dump(count($second), $second !== $first, segments_exist($second), is_file('/dev/shm/PUC.' . $first[0]));
+ echo cgi($site, $locks, '1M'), "\n";
+ var_dump(count(lock_ids($locks)), is_file('/dev/shm/PUC.' . $second[0]));
+
+ echo "orphaned lock file:\n";
+ $private = dirname(glob($locks . '/.PhpUserCacheBnd.*/*.lock')[0]);
+ touch($private . '/000000000000000000000000.lock');
+ chmod($private . '/000000000000000000000000.lock', 0600);
+ touch($private . '/not-a-lock-file.lock');
+ echo cgi($site, $locks, '1M', 'value=second'), "\n";
+ var_dump(file_exists($private . '/000000000000000000000000.lock'), file_exists($private . '/not-a-lock-file.lock'));
+ unlink($private . '/not-a-lock-file.lock');
+
+ echo "a segment in use by a forked child is kept until the child detaches:\n";
+ $release = $root . '/release';
+ echo cgi($site, $locks, '1M', 'value=forked&release=' . urlencode($release)), "\n";
+ $held = lock_ids($locks);
+ echo cgi($site, $locks, '2M'), "\n";
+ $both = lock_ids($locks);
+ var_dump(count($both), in_array($held[0], $both, true), segments_exist($both));
+ touch($release);
+ var_dump(wait_for_lock_count($locks, 1), in_array($held[0], lock_ids($locks), true), segments_exist(lock_ids($locks)));
+
+ echo "idle segments beyond the limit:\n";
+ for ($i = 1; $i <= 34; $i++) {
+ @mkdir($root . "/sites/s$i", 0777, true);
+ file_put_contents($root . "/sites/s$i/index.php", $script);
+ $out = cgi($root . "/sites/s$i", $capLocks, '1M', "value=s$i");
+ if ($out !== "s$i") {
+ echo "site $i: $out\n";
+ }
+ }
+ $capped = lock_ids($capLocks);
+ var_dump(count($capped), segments_exist($capped));
+ echo cgi($root . '/sites/s1', $capLocks, '1M'), "\n";
+ echo cgi($root . '/sites/s34', $capLocks, '1M'), "\n";
+ echo cgi($root . '/sites/s3', $capLocks, '1M'), "\n";
+ echo cgi($root . '/sites/s2', $capLocks, '1M'), "\n";
+ var_dump(count(lock_ids($capLocks)), segments_exist(lock_ids($capLocks)));
+} finally {
+ @touch($root . '/release');
+ wait_for_lock_count($locks, 1);
+ FcgiTester::removeBoundarySegments($root);
+ $paths = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($paths as $path) {
+ $path->isDir() && !$path->isLink() ? rmdir($path->getPathname()) : unlink($path->getPathname());
+ }
+ rmdir($root);
+}
+?>
+--EXPECT--
+restart:
+first
+first
+int(1)
+bool(true)
+other layout replaces the idle segment:
+MISS
+int(1)
+bool(true)
+bool(true)
+bool(false)
+MISS
+int(1)
+bool(false)
+orphaned lock file:
+second
+bool(false)
+bool(true)
+a segment in use by a forked child is kept until the child detaches:
+forked
+MISS
+int(2)
+bool(true)
+bool(true)
+int(1)
+bool(false)
+bool(true)
+idle segments beyond the limit:
+int(33)
+bool(true)
+MISS
+s34
+s3
+MISS
+int(33)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_cgi_boundary_sparse_segment.phpt b/ext/user_cache/tests/user_cache_cgi_boundary_sparse_segment.phpt
new file mode 100644
index 000000000000..6bbd0b2bc610
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_boundary_sparse_segment.phpt
@@ -0,0 +1,69 @@
+--TEST--
+UserCache\Cache: a process that attaches to a boundary segment whose creator died before reserving it reserves the pages itself
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+ glob('/dev/shm/PUC.*') ?: [];
+$before = $segments();
+$server = new FcgiTester('sparse-segment');
+
+try {
+ $root = $server->docRoot('site', <<<'PHP'
+store('k', 'stored');
+echo $cache->fetch('k', 'MISS');
+PHP);
+ $script = $root . '/index.php';
+ $ini = ['user_cache.enable=1', 'user_cache.shm_size=8M'];
+
+ putenv('USER_CACHE_DEBUG_EXIT_IN_BOUNDARY_SEGMENT_CREATE=1');
+ try {
+ $server->start($ini, $script, $root, 'sparse.local', '');
+ echo "creator survived\n";
+ } catch (RuntimeException) {
+ echo "creator died after sizing the segment\n";
+ }
+ $server->stop();
+ putenv('USER_CACHE_DEBUG_EXIT_IN_BOUNDARY_SEGMENT_CREATE');
+
+ $created = array_values(array_diff($segments(), $before));
+ var_dump(count($created));
+ clearstatcache();
+ $st = stat($created[0]);
+ echo "left sparse: ";
+ var_dump($st['size'] === 8 << 20 && $st['blocks'] * 512 < $st['size']);
+
+ $server->start($ini, $script, $root, 'sparse.local', '');
+ echo $server->request($script, $root, 'sparse.local', ''), "\n";
+ $server->stop();
+
+ clearstatcache();
+ $st = stat($created[0]);
+ echo "reserved by the next process: ";
+ var_dump($st['blocks'] * 512 >= $st['size']);
+} finally {
+ $server->cleanup();
+ foreach (array_diff($segments(), $before) as $segment) {
+ @unlink($segment);
+ }
+}
+?>
+--EXPECT--
+creator died after sizing the segment
+int(1)
+left sparse: bool(true)
+stored
+reserved by the next process: bool(true)
diff --git a/ext/user_cache/tests/user_cache_cgi_fastcgi_boundary.phpt b/ext/user_cache/tests/user_cache_cgi_fastcgi_boundary.phpt
new file mode 100644
index 000000000000..a973d31f5bf1
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_fastcgi_boundary.phpt
@@ -0,0 +1,173 @@
+--TEST--
+UserCache\Cache: FastCGI boundary partitions follow the normalized DOCUMENT_ROOT directory, ignore SERVER_NAME and HTTP_HOST and stop at the partition limit
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+clear();
+} elseif ($action === 'seed') {
+ $cache->store($key, $host . '-value');
+ $cache->store($complexKey, ['host' => $host, 'nested' => ['value' => 42]]);
+}
+
+$status = UserCache\Cache::getStatus();
+$complex = $cache->fetch($complexKey, ['host' => 'MISS', 'nested' => ['value' => 'MISS']]);
+echo $host, ':', $cache->fetch($key, 'MISS'), ':', $complex['host'], ':', $complex['nested']['value'], ':', ($status->getAvailability() === UserCache\CacheAvailability::Available ? 'available' : $status->getAvailability()->name), "\n";
+PHP;
+
+ $alphaRoot = $server->docRoot('alpha', $script);
+ $betaRoot = $server->docRoot('beta', $script);
+ $alphaScript = $alphaRoot . '/index.php';
+ $betaScript = $betaRoot . '/index.php';
+
+ $server->start([
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=8M',
+ 'opcache.file_update_protection=0',
+ ], $alphaScript, $alphaRoot, 'alpha.local', 'action=fetch');
+
+ $server->request($alphaScript, $alphaRoot, 'alpha.local', 'action=clear');
+ $server->request($betaScript, $betaRoot, 'beta.local', 'action=clear');
+
+ $checks = [
+ [$alphaScript, $alphaRoot, 'alpha.local', 'action=seed', 'alpha.local:alpha.local-value:alpha.local:42:available'],
+ [$alphaScript, $alphaRoot, 'alpha.local', 'action=fetch', 'alpha.local:alpha.local-value:alpha.local:42:available'],
+ [$alphaScript, $alphaRoot, 'beta.local', 'action=fetch', 'beta.local:alpha.local-value:alpha.local:42:available'],
+ [$alphaScript, $alphaRoot . '/', 'gamma.local', 'action=fetch', 'gamma.local:alpha.local-value:alpha.local:42:available'],
+ [$alphaScript, $betaRoot . '/../alpha', 'alpha.local', 'action=fetch', 'alpha.local:alpha.local-value:alpha.local:42:available'],
+ [$betaScript, $betaRoot, 'beta.local', 'action=fetch', 'beta.local:MISS:MISS:MISS:available'],
+ [$betaScript, $betaRoot, 'beta.local', 'action=seed', 'beta.local:beta.local-value:beta.local:42:available'],
+ [$alphaScript, $alphaRoot, 'alpha.local', 'action=fetch', 'alpha.local:alpha.local-value:alpha.local:42:available'],
+ [$betaScript, $betaRoot, 'beta.local', 'action=fetch', 'beta.local:beta.local-value:beta.local:42:available'],
+ [$alphaScript, $server->path('missing'), 'alpha.local', 'action=fetch', 'alpha.local:MISS:MISS:MISS:UnavailableByCgiFastCgiBoundary'],
+ [$alphaScript, 'alpha', 'alpha.local', 'action=fetch', 'alpha.local:MISS:MISS:MISS:UnavailableByCgiFastCgiBoundary'],
+ [$alphaScript, $alphaScript, 'alpha.local', 'action=fetch', 'alpha.local:MISS:MISS:MISS:UnavailableByCgiFastCgiBoundary'],
+ ];
+
+ foreach ($checks as [$file, $docRoot, $serverName, $query, $expected]) {
+ $server->expect($expected, $file, $docRoot, $serverName, $query);
+ }
+
+ /* A deployment symlink keeps its partition when it is switched to another release */
+ $current = $server->path('current');
+ symlink($alphaRoot, $current);
+ $server->expect('current.local:MISS:MISS:MISS:available', $alphaScript, $current, 'current.local', 'action=fetch');
+ $server->expect('current.local:current.local-value:current.local:42:available', $alphaScript, $current, 'current.local', 'action=seed');
+ unlink($current);
+ symlink($betaRoot, $current);
+ $server->expect('current.local:current.local-value:current.local:42:available', $betaScript, $current, 'current.local', 'action=fetch');
+ unlink($current);
+
+ /* Neither HTTP_HOST nor SERVER_NAME selects the partition */
+ $siteRoot = $server->docRoot('site', <<<'PHP'
+clear();
+} elseif ($action === 'seed') {
+ $cache->store('key', 'seeded-value');
+}
+$availability = UserCache\Cache::getStatus()->getAvailability();
+echo $cache->fetch('key', 'MISS'), ':', ($availability === UserCache\CacheAvailability::Available ? 'available' : $availability->name), "\n";
+PHP);
+ $siteScript = $siteRoot . '/index.php';
+
+ $checks = [
+ ['vhost.local', 'a000001.example', 'action=clear', 'MISS:available'],
+ ['vhost.local', 'a000001.example', 'action=seed', 'seeded-value:available'],
+ ['vhost.local', 'a000002.example', 'action=fetch', 'seeded-value:available'],
+ ['vhost.local', 'a000003.example', 'action=fetch', 'seeded-value:available'],
+ ['other.local', 'a000001.example', 'action=fetch', 'seeded-value:available'],
+ ['vhost.local', 'a999999.example', 'action=fetch', 'seeded-value:available'],
+ ];
+
+ foreach ($checks as [$serverName, $httpHost, $query, $expected]) {
+ $server->expect($expected, $siteScript, $siteRoot, $serverName, $query, $httpHost);
+ }
+} finally {
+ $server->cleanup();
+}
+
+/* The 33rd distinct boundary is refused and logged exactly once */
+$limit = new FcgiTester('boundary-limit');
+
+try {
+ $source = <<<'PHP'
+store('key', $host . '-value');
+}
+$availability = UserCache\Cache::getStatus()->getAvailability();
+echo $host, ':', $cache->fetch('key', 'MISS'), ':', $availability->name, "\n";
+PHP;
+ $docRoots = [];
+ for ($i = 1; $i <= 34; $i++) {
+ $docRoots[$i] = $limit->docRoot(sprintf('site%02d', $i), $source);
+ }
+ $script = $docRoots[1] . '/index.php';
+ $log = $limit->path('error.log');
+
+ $limit->start([
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=1M',
+ 'opcache.file_update_protection=0',
+ 'display_errors=0',
+ 'log_errors=1',
+ 'error_log=' . $log,
+ ], $script, $docRoots[1], 'host01.local', 'action=fetch');
+
+ $limit->expect('host01.local:host01.local-value:Available', $script, $docRoots[1], 'host01.local', 'action=seed');
+
+ for ($i = 2; $i <= 32; $i++) {
+ $host = sprintf('host%02d.local', $i);
+ $limit->expect($host . ':MISS:Available', $script, $docRoots[$i], $host, 'action=fetch');
+ }
+
+ foreach ([33, 34] as $i) {
+ $host = sprintf('host%02d.local', $i);
+ $limit->expect($host . ':MISS:UnavailableByCgiFastCgiBoundary', $script, $docRoots[$i], $host, 'action=seed');
+ }
+
+ $limit->expect('host01.local:host01.local-value:Available', $script, $docRoots[1], 'host01.local', 'action=fetch');
+
+ $contents = is_file($log) ? file_get_contents($log) : '';
+ $needle = 'UserCache: boundary partition limit (32) reached; creation of new partitions has been disabled';
+ if (substr_count($contents, $needle) !== 1) {
+ throw new RuntimeException("Expected one boundary-limit error-log entry, got:\n" . $contents);
+ }
+
+ echo "Done\n";
+} finally {
+ $limit->cleanup();
+}
+
+?>
+--EXPECT--
+Done
diff --git a/ext/user_cache/tests/user_cache_cgi_fastcgi_boundary_slot_claims.phpt b/ext/user_cache/tests/user_cache_cgi_fastcgi_boundary_slot_claims.phpt
new file mode 100644
index 000000000000..71521bf8c050
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_fastcgi_boundary_slot_claims.phpt
@@ -0,0 +1,70 @@
+--TEST--
+UserCache\Cache: one FastCGI process serving more boundaries than it caches slot claims for recycles reader and pin claims and keeps pinned views
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+clear();
+ $cache->store('graph', ['host' => $host, 'list' => range(1, 8), 'nested' => ['v' => 1.5]]);
+}
+$value = $cache->fetch('graph', ['host' => 'MISS']);
+$status = UserCache\Cache::getStatus();
+echo $host, ':', $value['host'], ':', getmypid(), ':', $status->getGraphPinnedReferences(), "\n";
+PHP;
+ $roots = [];
+ for ($i = 1; $i <= 6; $i++) {
+ $roots[$i] = $server->docRoot("site$i", $source);
+ }
+ $script = $roots[1] . '/index.php';
+
+ $server->start([
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=4M',
+ 'opcache.file_update_protection=0',
+ ], $script, $roots[1], 'claims1.local', 'action=fetch');
+
+ /* Six boundaries in one process exceed the four cached reader and pin slot claims */
+ $pids = [];
+ $rounds = [
+ ['seed', [1, 2, 3, 4, 5, 6]],
+ ['fetch', [1, 2, 5, 6, 3, 4, 1]],
+ ];
+ foreach ($rounds as [$action, $hosts]) {
+ foreach ($hosts as $i) {
+ $host = "claims$i.local";
+ $line = trim($server->request($script, $roots[$i], $host, "action=$action"));
+ [$served, $seen, $pid, $pinned] = explode(':', $line) + [null, null, null, null];
+ if ($served !== $host || $seen !== $host || $pinned !== '1') {
+ throw new RuntimeException("Unexpected response for $action $host: $line");
+ }
+ $pids[$pid] = true;
+ }
+ }
+
+ var_dump(count($pids));
+ echo "Done\n";
+} finally {
+ $server->cleanup();
+}
+?>
+--EXPECT--
+int(1)
+Done
diff --git a/ext/user_cache/tests/user_cache_cgi_fastcgi_record_copy.phpt b/ext/user_cache/tests/user_cache_cgi_fastcgi_record_copy.phpt
new file mode 100644
index 000000000000..740c5173f7ec
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_fastcgi_record_copy.phpt
@@ -0,0 +1,102 @@
+--TEST--
+UserCache\Cache: FastCGI boundary fetches of object-free values reuse one private copy per key without sharing references or stale values
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+docRoot('site', <<<'PHP'
+ $i, 'name' => "item-$i"];
+ }
+
+ return $list;
+}
+
+if ($action === 'seed') {
+ $ref = 1;
+ $cache->store('list', build_list());
+ $cache->store('refs', ['a' => &$ref, 'b' => &$ref]);
+ $cache->store('object', ['o' => new ArrayObject([1])]);
+ echo "seeded:ok\n";
+ exit;
+}
+
+if ($action === 'warmup') {
+ echo "warmup:ok\n";
+ exit;
+}
+
+$out = [];
+
+$first = $cache->fetch('list');
+$out[] = $first === build_list() ? 'equal' : 'differs';
+
+$before = memory_get_usage();
+$kept = [];
+for ($i = 0; $i < 100; $i++) {
+ $kept[] = $cache->fetch('list');
+}
+$out[] = memory_get_usage() - $before < 100000 ? 'shared' : 'copied';
+
+$first[0]['name'] = 'changed';
+$first[] = 'appended';
+$again = $cache->fetch('list');
+$out[] = $again === build_list() ? 'isolated' : 'leaked';
+
+$refs = $cache->fetch('refs');
+$refs['a'] = 2;
+$refs_again = $cache->fetch('refs');
+$out[] = $refs_again['a'] === 1 && $refs_again['b'] === 1 ? 'refs-isolated' : 'refs-leaked';
+$refs_again['a'] = 3;
+$out[] = $refs_again['b'] === 3 ? 'refs-kept' : 'refs-broken';
+
+$object = $cache->fetch('object');
+$out[] = $cache->fetch('object')['o'] !== $object['o'] ? 'objects-fresh' : 'objects-shared';
+
+$cache->store('list', ['replaced']);
+$out[] = $cache->fetch('list') === ['replaced'] ? 'replaced' : 'stale';
+$cache->store('list', build_list());
+
+echo implode(',', $out), "\n";
+PHP);
+ $script = $docRoot . '/index.php';
+
+ $server->start([
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=32M',
+ 'opcache.file_update_protection=0',
+ 'display_errors=0',
+ ], $script, $docRoot, 'record-copy.local', 'action=warmup');
+
+ $server->expect('seeded:ok', $script, $docRoot, 'record-copy.local', 'action=seed');
+ for ($i = 0; $i < 2; $i++) {
+ echo $server->request($script, $docRoot, 'record-copy.local', 'action=verify'), "\n";
+ }
+} finally {
+ $server->cleanup();
+}
+
+?>
+--EXPECT--
+equal,shared,isolated,refs-isolated,refs-kept,objects-fresh,replaced
+equal,shared,isolated,refs-isolated,refs-kept,objects-fresh,replaced
diff --git a/ext/user_cache/tests/user_cache_cgi_fastcgi_value_matrix.phpt b/ext/user_cache/tests/user_cache_cgi_fastcgi_value_matrix.phpt
new file mode 100644
index 000000000000..51840c8f18c7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_fastcgi_value_matrix.phpt
@@ -0,0 +1,188 @@
+--TEST--
+UserCache\Cache: FastCGI round-trips a rich value matrix across the cross-process named segment
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+docRoot('site', <<<'PHP'
+ $this->state];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->state = $data['state'];
+ }
+}
+
+class MatrixSleep
+{
+ public int $kept = 5;
+ public string $dropped = 'gone';
+
+ public function __sleep(): array
+ {
+ return ['kept'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+class MatrixLegacy implements Serializable
+{
+ public function __construct(public int $v = 0)
+ {
+ }
+
+ public function serialize(): string
+ {
+ return (string) $this->v;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->v = (int) $data;
+ }
+}
+
+function matrix_build(): array
+{
+ $shared = new stdClass();
+ $shared->tag = 'shared';
+
+ $cycleA = new stdClass();
+ $cycleB = new stdClass();
+ $cycleA->peer = $cycleB;
+ $cycleB->peer = $cycleA;
+
+ $ref = 1;
+ $withRef = ['a' => &$ref, 'b' => &$ref];
+
+ $storage = new SplObjectStorage();
+ $storageKey = new stdClass();
+ $storageKey->id = 1;
+ $storage[$storageKey] = ['data' => [1, 2, 3]];
+
+ $stack = new SplStack();
+ $stack->push('a');
+ $stack->push('b');
+
+ return [
+ 'scalars' => [null, true, PHP_INT_MIN, PHP_INT_MAX, 1.5, 'str', "bin\0\xff\xfe"],
+ 'arrays' => ['packed' => [1, 2, 3], 'hashed' => ['a' => 1, -5 => 'neg'], 'nested' => ['x' => ['y' => [1, 2]]]],
+ 'refs' => $withRef,
+ 'cycle' => $cycleA,
+ 'plain' => new MatrixPlain(),
+ 'enum' => MatrixSuit::Spades,
+ 'magic' => new MatrixMagic(['id' => 7, 'items' => [1, 2, 3]]),
+ 'sleep' => new MatrixSleep(),
+ 'legacy' => new MatrixLegacy(99),
+ 'date' => new DateTimeImmutable('2026-07-09 12:34:56.123456', new DateTimeZone('Asia/Tokyo')),
+ 'interval' => new DateInterval('P1Y2DT3H'),
+ 'period' => new DatePeriod(new DateTimeImmutable('2026-01-01'), new DateInterval('P1D'), 2),
+ 'stack' => $stack,
+ 'arrayobject' => new ArrayObject(['k' => 'v', 'n' => [1, 2]]),
+ 'storage' => $storage,
+ 'sharedid' => [$shared, $shared],
+ ];
+}
+
+if ($action === 'seed') {
+ foreach (matrix_build() as $key => $value) {
+ if (!$cache->store('m_' . $key, $value)) {
+ echo 'seed-fail:', $key, "\n";
+ exit;
+ }
+ }
+ echo "seeded:ok\n";
+ exit;
+}
+
+if ($action === 'warmup') {
+ echo "warmup:ok\n";
+ exit;
+}
+
+$mismatch = [];
+foreach (matrix_build() as $key => $expected) {
+ $missing = new stdClass();
+ $fetched = $cache->fetch('m_' . $key, $missing);
+ if ($fetched === $missing) {
+ $mismatch[] = $key . ':MISS';
+ continue;
+ }
+ if (serialize($fetched) !== serialize($expected)) {
+ $mismatch[] = $key . ':DIFF';
+ }
+}
+
+echo $mismatch === [] ? "verify:ok\n" : ('verify:FAIL ' . implode(',', $mismatch) . "\n");
+PHP);
+ $script = $docRoot . '/index.php';
+
+ $server->start([
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=32M',
+ 'opcache.file_update_protection=0',
+ 'date.timezone=UTC',
+ 'error_reporting=E_ALL & ~E_DEPRECATED',
+ 'display_errors=0',
+ ], $script, $docRoot, 'matrix.local', 'action=warmup');
+
+ $checks = [
+ ['action=seed', 'seeded:ok'],
+ ['action=verify', 'verify:ok'],
+ ];
+
+ foreach ($checks as [$query, $expected]) {
+ $server->expect($expected, $script, $docRoot, 'matrix.local', $query);
+ }
+
+ echo "Done\n";
+} finally {
+ $server->cleanup();
+}
+
+?>
+--EXPECT--
+Done
diff --git a/ext/user_cache/tests/user_cache_cgi_no_boundary.phpt b/ext/user_cache/tests/user_cache_cgi_no_boundary.phpt
new file mode 100644
index 000000000000..05669e14c2a8
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_cgi_no_boundary.phpt
@@ -0,0 +1,29 @@
+--TEST--
+CGI: UserCache\Cache is unavailable without a cache boundary
+--CGI--
+--INI--
+user_cache.enable=1
+user_cache.shm_size=16M
+--ENV--
+DOCUMENT_ROOT=
+SERVER_NAME=
+--FILE--
+getPoolStatus();
+
+var_dump($status->getAvailability()->name, $poolStatus->getEntryCount(), $poolStatus->getEntryKeys(), $poolStatus->getUsedMemory());
+var_dump($cache->store('key', 'value'));
+var_dump($cache->fetch('key', 'MISS'));
+var_dump($cache->delete('key'));
+?>
+--EXPECT--
+string(31) "UnavailableByCgiFastCgiBoundary"
+int(0)
+array(0) {
+}
+int(0)
+bool(false)
+string(4) "MISS"
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_clock.inc b/ext/user_cache/tests/user_cache_clock.inc
new file mode 100644
index 000000000000..bb86ed8843c7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_clock.inc
@@ -0,0 +1,23 @@
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=64M
+--FILE--
+ ['p' => $p, 'k' => $k, 'f' => $k / 4],
+ 1 => str_repeat("v$p:$k;", 20),
+ default => $k % 2 ? $k * 1.5 : $p * 1000 + $k,
+ };
+}
+
+/* Map the segment before forking so every process shares it */
+UserCache\Cache::getPool('fresh-reads-init')->store('init', true);
+
+/* Another process writes the keys: this process has no key records for them */
+$pid = pcntl_fork();
+if ($pid < 0) die("fork failed\n");
+if ($pid === 0) {
+ for ($p = 0; $p < POOLS; $p++) {
+ $cache = UserCache\Cache::getPool("fresh-reads-$p");
+ for ($k = 0; $k < KEYS; $k++) {
+ if (!$cache->store("key-$k", fresh_value($p, $k))) exit(1);
+ }
+ }
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+/* Writers keep entering write sections until told to stop */
+$writers = [];
+for ($i = 0; $i < WRITERS; $i++) {
+ [$parent, $child] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+ $pid = pcntl_fork();
+ if ($pid < 0) die("fork failed\n");
+ if ($pid === 0) {
+ fclose($parent);
+ $cache = UserCache\Cache::getPool("fresh-reads-writer-$i");
+ $pad = str_repeat(chr(65 + $i), 1500);
+ fwrite($child, 'r');
+ stream_set_blocking($child, false);
+ for ($n = 0; ; $n++) {
+ if (!$cache->store('w' . ($n % 32), [$n, $pad])) exit(1);
+ if ($n % 16 === 0 && fread($child, 1) === 'q') {
+ exit($cache->store('last', $n) ? 0 : 2);
+ }
+ }
+ }
+ fclose($child);
+ $writers[$pid] = $parent;
+}
+foreach ($writers as $socket) {
+ if (fread($socket, 1) !== 'r') die("writer did not start\n");
+}
+
+$bad = 0;
+for ($p = 0; $p < POOLS; $p++) {
+ $cache = UserCache\Cache::getPool("fresh-reads-$p");
+ for ($k = 0; $k < KEYS; $k++) {
+ $expected = fresh_value($p, $k);
+ switch ($k % 4) {
+ case 0:
+ $ok = $cache->fetch("key-$k") === $expected;
+ break;
+ case 1:
+ $ok = $cache->has("key-$k") && !$cache->has("absent-$k");
+ break;
+ case 2:
+ $ok = $cache->fetch("absent-$k", 'DEFAULT') === 'DEFAULT'
+ && $cache->fetch("key-$k") === $expected;
+ break;
+ default:
+ $ok = $cache->fetchMultiple(["key-$k", "absent-$k"], false) === [
+ "key-$k" => $expected,
+ "absent-$k" => false,
+ ];
+ break;
+ }
+ if (!$ok) {
+ $bad++;
+ }
+ }
+}
+var_dump($bad);
+
+$exits = [];
+foreach ($writers as $pid => $socket) {
+ fwrite($socket, 'q');
+ pcntl_waitpid($pid, $status);
+ $exits[] = pcntl_wifexited($status) ? pcntl_wexitstatus($status) : -1;
+ fclose($socket);
+}
+var_dump($exits);
+for ($i = 0; $i < WRITERS; $i++) {
+ var_dump(is_int(UserCache\Cache::getPool("fresh-reads-writer-$i")->fetch('last')));
+}
+?>
+--EXPECT--
+bool(true)
+int(0)
+array(2) {
+ [0]=>
+ int(0)
+ [1]=>
+ int(0)
+}
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_dateperiod_safe_direct.phpt b/ext/user_cache/tests/user_cache_dateperiod_safe_direct.phpt
new file mode 100644
index 000000000000..91c70a563159
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_dateperiod_safe_direct.phpt
@@ -0,0 +1,232 @@
+--TEST--
+UserCache\Cache: DatePeriod safe-direct state round-trips (incl. subclasses)
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+--FILE--
+label = $label;
+ $this->revision = $revision;
+ }
+
+ public function describe(): string
+ {
+ return $this->label . ':' . $this->revision;
+ }
+}
+
+class MagicDatePeriod extends DatePeriod
+{
+ public static int $serializeCount = 0;
+ public static int $unserializeCount = 0;
+
+ public string $note = 'none';
+
+ public function __serialize(): array
+ {
+ self::$serializeCount++;
+
+ return parent::__serialize() + ['note' => 'serialized-' . $this->note];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCount++;
+ parent::__unserialize($data);
+ $this->note = $data['note'];
+ }
+}
+
+function period_dates(DatePeriod $period): array
+{
+ $dates = [];
+ foreach ($period as $date) {
+ $dates[] = $date->format('Y-m-d');
+ }
+ return $dates;
+}
+
+$cache = UserCache\Cache::getPool('dateperiod-safe-direct');
+$cache->clear();
+
+/* Compare serialization before iteration mutates the DatePeriod cursor. */
+$byRecurrences = new DatePeriod(new DateTimeImmutable('2026-01-01'), new DateInterval('P1D'), 3);
+$cache->store('recurrences', $byRecurrences);
+$r = $cache->fetch('recurrences');
+var_dump($r instanceof DatePeriod);
+var_dump(serialize($r) === serialize($byRecurrences));
+var_dump(period_dates($r) === ['2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']);
+
+$byEnd = new DatePeriod(
+ new DateTimeImmutable('2026-03-01'),
+ new DateInterval('P1M'),
+ new DateTimeImmutable('2026-05-15'),
+);
+$cache->store('end', $byEnd);
+$r = $cache->fetch('end');
+var_dump(serialize($r) === serialize($byEnd));
+var_dump(period_dates($r) === ['2026-03-01', '2026-04-01', '2026-05-01']);
+
+$excludeStart = new DatePeriod(
+ new DateTimeImmutable('2026-01-01'),
+ new DateInterval('P1D'),
+ 2,
+ DatePeriod::EXCLUDE_START_DATE,
+);
+$cache->store('exclude-start', $excludeStart);
+$r = $cache->fetch('exclude-start');
+var_dump(serialize($r) === serialize($excludeStart));
+var_dump(period_dates($r) === ['2026-01-02', '2026-01-03']);
+
+$includeEnd = new DatePeriod(
+ new DateTimeImmutable('2026-01-01'),
+ new DateInterval('P1D'),
+ new DateTimeImmutable('2026-01-03'),
+ DatePeriod::INCLUDE_END_DATE,
+);
+$cache->store('include-end', $includeEnd);
+var_dump(serialize($cache->fetch('include-end')) === serialize($includeEnd));
+var_dump(period_dates($cache->fetch('include-end')) === ['2026-01-01', '2026-01-02', '2026-01-03']);
+
+/* Inherited serialization uses the safe-direct route. */
+$tagged = new TaggedDatePeriod(new DateTimeImmutable('2026-02-01'), new DateInterval('P1D'), 2);
+$tagged->tag('window', 9);
+$cache->store('tagged', $tagged);
+$t = $cache->fetch('tagged');
+var_dump($t instanceof TaggedDatePeriod);
+var_dump($t->label);
+var_dump($t->describe());
+var_dump(period_dates($t) === ['2026-02-01', '2026-02-02', '2026-02-03']);
+
+/* Overriding __serialize() selects the magic-hook route. */
+$magic = new MagicDatePeriod(new DateTimeImmutable('2026-04-01'), new DateInterval('P1D'), 1);
+$magic->note = 'hello';
+$cache->store('magic', $magic);
+$m = $cache->fetch('magic');
+var_dump($m instanceof MagicDatePeriod);
+var_dump($m->note);
+var_dump(period_dates($m) === ['2026-04-01', '2026-04-02']);
+var_dump(MagicDatePeriod::$serializeCount > 0);
+var_dump(MagicDatePeriod::$unserializeCount > 0);
+
+$interval = new DateInterval('P2D');
+$graph = [
+ 'period' => new DatePeriod(new DateTimeImmutable('2026-06-01'), $interval, 2),
+ 'interval' => $interval,
+ 'zone' => new DateTimeZone('Asia/Tokyo'),
+];
+$cache->store('graph', $graph);
+$g = $cache->fetch('graph');
+var_dump(serialize($g) === serialize($graph));
+var_dump($g['interval'] instanceof DateInterval && $g['zone']->getName() === 'Asia/Tokyo');
+var_dump(period_dates($g['period']) === ['2026-06-01', '2026-06-03', '2026-06-05']);
+
+$offsetPeriod = new DatePeriod(
+ new DateTimeImmutable('2026-01-01 00:00:00 +09:00'),
+ new DateInterval('P1D'),
+ new DateTimeImmutable('2026-01-04 00:00:00 +09:00'),
+);
+$cache->store('offset-tz', $offsetPeriod);
+$o = $cache->fetch('offset-tz');
+var_dump(serialize($o) === serialize($offsetPeriod));
+var_dump(period_dates($o) === ['2026-01-01', '2026-01-02', '2026-01-03']);
+var_dump($o->getStartDate()->getTimezone()->getName());
+
+$abbrPeriod = new DatePeriod(
+ new DateTimeImmutable('2026-01-01 00:00:00 PST'),
+ new DateInterval('P1D'),
+ 2,
+);
+$cache->store('abbr-tz', $abbrPeriod);
+$a = $cache->fetch('abbr-tz');
+var_dump(serialize($a) === serialize($abbrPeriod));
+var_dump($a->getStartDate()->getTimezone()->getName());
+
+/* Iteration leaves cursor state to restore. */
+$iterated = new DatePeriod(new DateTimeImmutable('2026-01-01'), new DateInterval('P1D'), 2);
+foreach ($iterated as $unused) {
+}
+$cache->store('iterated', $iterated);
+var_dump(serialize($cache->fetch('iterated')) === serialize($iterated));
+
+$anonymousStart = new DatePeriod(new class('2020-01-01') extends DateTimeImmutable {}, new DateInterval('P1D'), 2);
+try {
+ $cache->store('anonymous-start', $anonymousStart);
+} catch (Exception $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->fetch('anonymous-start', 'not stored'));
+
+/* Cursors advanced by an unserialize()d or weekday-relative interval carry that interval's stale relative fields and still restore. */
+echo "\ncursor relative state:\n";
+$unserializedInterval = unserialize(serialize(new DateInterval('P1D')));
+$fromUnserialized = new DatePeriod(new DateTimeImmutable('2026-01-01'), $unserializedInterval, 2);
+foreach ($fromUnserialized as $unused) {
+}
+$cache->store('cursor-unserialized-interval', $fromUnserialized);
+$u = $cache->fetch('cursor-unserialized-interval', 'unrestorable');
+var_dump($u instanceof DatePeriod && serialize($u) === serialize($fromUnserialized));
+var_dump(period_dates($u) === ['2026-01-01', '2026-01-02', '2026-01-03']);
+
+$byWeekday = new DatePeriod(new DateTimeImmutable('2026-01-01'), DateInterval::createFromDateString('next monday'), 2);
+foreach ($byWeekday as $unused) {
+}
+$cache->store('cursor-weekday-interval', $byWeekday);
+$w = $cache->fetch('cursor-weekday-interval', 'unrestorable');
+var_dump($w instanceof DatePeriod && serialize($w) === serialize($byWeekday));
+var_dump(period_dates($w) === period_dates($byWeekday), period_dates($w));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(6) "window"
+string(8) "window:9"
+bool(true)
+bool(true)
+string(5) "hello"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(6) "+09:00"
+bool(true)
+string(3) "PST"
+bool(true)
+Serialization of 'DateTimeImmutable@anonymous' is not allowed
+string(10) "not stored"
+
+cursor relative state:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+array(3) {
+ [0]=>
+ string(10) "2026-01-01"
+ [1]=>
+ string(10) "2026-01-05"
+ [2]=>
+ string(10) "2026-01-12"
+}
diff --git a/ext/user_cache/tests/user_cache_dateperiod_safe_direct_start_class.phpt b/ext/user_cache/tests/user_cache_dateperiod_safe_direct_start_class.phpt
new file mode 100644
index 000000000000..28d680792765
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_dateperiod_safe_direct_start_class.phpt
@@ -0,0 +1,75 @@
+--TEST--
+UserCache\Cache: a DatePeriod whose start class the fetching process cannot load, or can only load as abstract, is unrestorable
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+--FILE--
+clear();
+
+spl_autoload_register(static function (string $class): void {
+ echo "autoload: $class\n";
+ if ($class === 'UserCacheAbstractLaterStart') {
+ eval('abstract class UserCacheAbstractLaterStart extends DateTimeImmutable {}');
+ }
+});
+
+$pid = pcntl_fork();
+if ($pid < 0) {
+ die("fork failed\n");
+}
+if ($pid === 0) {
+ eval('class UserCacheAbstractLaterStart extends DateTimeImmutable {}');
+ eval('class UserCacheMissingLaterStart extends DateTimeImmutable {}');
+ $stored = $cache->store('abstract-start', new DatePeriod(new UserCacheAbstractLaterStart('2026-01-01'), new DateInterval('P1D'), 1))
+ && $cache->store('missing-start', new DatePeriod(new UserCacheMissingLaterStart('2026-01-01'), new DateInterval('P1D'), 1))
+ && $cache->store('plain-start', new DatePeriod(new DateTimeImmutable('2026-01-01'), new DateInterval('P1D'), 1));
+ exit($stored ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+
+echo "stored by child:\n";
+var_dump($cache->has('abstract-start'), $cache->has('missing-start'), $cache->has('plain-start'));
+
+echo "abstract start class:\n";
+var_dump($cache->fetch('abstract-start', 'unrestorable'));
+var_dump(class_exists('UserCacheAbstractLaterStart', false));
+
+echo "missing start class:\n";
+var_dump($cache->fetch('missing-start', 'unrestorable'));
+
+echo "plain start class:\n";
+$plain = $cache->fetch('plain-start', 'unrestorable');
+var_dump($plain instanceof DatePeriod);
+var_dump($plain->getStartDate()->format('Y-m-d'));
+
+echo "unrestorable entries are dropped:\n";
+var_dump($cache->has('abstract-start'), $cache->has('missing-start'), $cache->has('plain-start'));
+?>
+--EXPECT--
+int(0)
+stored by child:
+bool(true)
+bool(true)
+bool(true)
+abstract start class:
+autoload: UserCacheAbstractLaterStart
+string(12) "unrestorable"
+bool(true)
+missing start class:
+autoload: UserCacheMissingLaterStart
+string(12) "unrestorable"
+plain start class:
+bool(true)
+string(10) "2026-01-01"
+unrestorable entries are dropped:
+bool(false)
+bool(false)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_datetime_safe_direct.phpt b/ext/user_cache/tests/user_cache_datetime_safe_direct.phpt
new file mode 100644
index 000000000000..143135d7dd74
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_datetime_safe_direct.phpt
@@ -0,0 +1,503 @@
+--TEST--
+UserCache\Cache: DateTime and DateInterval safe-direct state is restored for subclasses and diffs
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+--FILE--
+cachedSelfId = spl_object_id($this);
+ $this->publicSelfId = spl_object_id($this);
+ }
+
+ public function cachedSelfId(): int
+ {
+ return $this->cachedSelfId;
+ }
+}
+
+class UserCacheMagicDateTime extends DateTime
+{
+ public static int $serializeCount = 0;
+ public static int $unserializeCount = 0;
+
+ private string $label;
+
+ public function __construct(string $time, DateTimeZone $timezone, string $label)
+ {
+ parent::__construct($time, $timezone);
+ $this->label = $label;
+ }
+
+ public function __serialize(): array
+ {
+ self::$serializeCount++;
+
+ return parent::__serialize() + ['label' => 'serialized-' . $this->label];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCount++;
+ parent::__unserialize($data);
+ $this->label = $data['label'];
+ }
+
+ public function label(): string
+ {
+ return $this->label;
+ }
+}
+
+class UserCacheMagicSelfIdDateTime extends DateTime
+{
+ public static int $serializeCount = 0;
+ public static int $unserializeCount = 0;
+
+ private int $constructedObjectId;
+
+ public function __construct(string $time, DateTimeZone $timezone)
+ {
+ parent::__construct($time, $timezone);
+ $this->constructedObjectId = spl_object_id($this);
+ }
+
+ public function __serialize(): array
+ {
+ self::$serializeCount++;
+
+ return parent::__serialize() + ['constructedObjectId' => $this->constructedObjectId];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCount++;
+ parent::__unserialize($data);
+ $this->constructedObjectId = spl_object_id($this);
+ }
+
+ public function constructedObjectId(): int
+ {
+ return $this->constructedObjectId;
+ }
+}
+
+class UserCacheMagicFilteredDateTime extends DateTime
+{
+ public static int $serializeCount = 0;
+ public static int $unserializeCount = 0;
+
+ public Closure $hidden;
+ private string $label;
+
+ public function __construct(string $time, DateTimeZone $timezone, string $label)
+ {
+ parent::__construct($time, $timezone);
+ $this->hidden = static fn(): int => 1;
+ $this->label = $label;
+ }
+
+ public function __serialize(): array
+ {
+ self::$serializeCount++;
+ $timezone = $this->getTimezone();
+
+ return [
+ 'date' => $this->format('Y-m-d H:i:s.u'),
+ 'timezone_type' => 3,
+ 'timezone' => $timezone->getName(),
+ 'label' => $this->label,
+ ];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCount++;
+ parent::__unserialize($data);
+ $this->hidden = static fn(): int => 2;
+ $this->label = $data['label'];
+ }
+
+ public function label(): string
+ {
+ return $this->label;
+ }
+}
+
+class UserCacheWakefulDateTime extends DateTime
+{
+ public static int $sleepCount = 0;
+ public static int $wakeupCount = 0;
+
+ private string $label;
+
+ public function __construct(string $time, DateTimeZone $timezone, string $label)
+ {
+ parent::__construct($time, $timezone);
+ $this->label = $label;
+ }
+
+ public function __sleep(): array
+ {
+ self::$sleepCount++;
+
+ return ['label'];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCount++;
+ }
+
+ public function label(): string
+ {
+ return $this->label;
+ }
+}
+
+class UserCacheTaggedTimeZone extends DateTimeZone
+{
+ private string $label;
+
+ public function __construct(string $timezone, string $label)
+ {
+ parent::__construct($timezone);
+ $this->label = $label;
+ }
+
+ public function label(): string
+ {
+ return $this->label;
+ }
+}
+
+class UserCacheTaggedInterval extends DateInterval
+{
+ private string $label;
+ protected int $revision;
+
+ public function __construct(string $duration, string $label, int $revision)
+ {
+ parent::__construct($duration);
+ $this->label = $label;
+ $this->revision = $revision;
+ }
+
+ public function describe(): string
+ {
+ return $this->label . ':' . $this->revision;
+ }
+}
+
+$cache = UserCache\Cache::getPool('datetime-safe-direct');
+
+$date = new UserCacheCarbonLikeDateTime('2024-01-02 03:04:05.123456', new DateTimeZone('Asia/Tokyo'));
+$date->label = 'tokyo';
+
+$model = new UserCacheDateModel(
+ new UserCacheCarbonLikeDateTime('2026-06-29 09:00:00.000001', new DateTimeZone('UTC')),
+ new UserCacheCarbonLikeDateTime('2026-06-29 09:30:00.000002', new DateTimeZone('Europe/Paris')),
+ new UserCacheCarbonLikeDateTime('2026-06-29 10:00:00.000003', new DateTimeZone('America/New_York')),
+);
+
+$payload = [
+ 'date' => $date,
+ 'model' => $model,
+ 'offset' => new DateTimeImmutable('2023-10-27 10:00:00.000001 +05:30'),
+ 'abbr' => new DateTimeImmutable('2023-10-27 10:00:00.000002 EST'),
+ 'timezone' => new DateTimeZone('Europe/Paris'),
+ 'interval' => new DateInterval('P1DT2H'),
+ 'taggedTimezone' => new UserCacheTaggedTimeZone('Europe/Paris', 'paris'),
+ 'taggedInterval' => new UserCacheTaggedInterval('P1Y2M3DT4H5M6S', 'window', 9),
+ 'relativeInterval' => DateInterval::createFromDateString('2 days 4 hours'),
+ 'selfId' => new UserCacheSelfIdDateTime('2026-06-15 10:15:00.333333', new DateTimeZone('UTC')),
+ 'magicDate' => new UserCacheMagicDateTime('2026-06-15 10:45:00.654321', new DateTimeZone('UTC'), 'magic'),
+ 'magicSelfId' => new UserCacheMagicSelfIdDateTime('2026-06-15 11:45:00.111111', new DateTimeZone('UTC')),
+ 'magicFiltered' => new UserCacheMagicFilteredDateTime('2026-06-15 12:00:00.222222', new DateTimeZone('UTC'), 'filtered'),
+ 'wakefulDate' => new UserCacheWakefulDateTime('2026-06-15 12:15:00.987654', new DateTimeZone('UTC'), 'wakeful'),
+];
+
+var_dump($cache->store('payload', $payload));
+
+$fetched = $cache->fetch('payload');
+
+var_dump($fetched['date'] instanceof UserCacheCarbonLikeDateTime);
+var_dump($fetched['date']->label);
+var_dump($fetched['date']->format('Y-m-d H:i:s.u P e'));
+
+var_dump($fetched['model'] instanceof UserCacheDateModel);
+var_dump($fetched['model']->createdAt->format('Y-m-d H:i:s.u e'));
+var_dump($fetched['model']->updatedAt->format('Y-m-d H:i:s.u e'));
+var_dump($fetched['model']->deletedAt->format('Y-m-d H:i:s.u e'));
+
+var_dump($fetched['offset']->format('Y-m-d H:i:s.u P e'));
+var_dump($fetched['abbr']->format('Y-m-d H:i:s.u P e'));
+var_dump($fetched['timezone']->getName());
+var_dump($fetched['interval']->format('%d %h'));
+var_dump($fetched['taggedTimezone'] instanceof UserCacheTaggedTimeZone);
+var_dump($fetched['taggedTimezone']->getName());
+var_dump($fetched['taggedTimezone']->label());
+var_dump($fetched['taggedInterval'] instanceof UserCacheTaggedInterval);
+var_dump($fetched['taggedInterval']->format('%y-%m-%d %h:%i:%s'));
+var_dump($fetched['taggedInterval']->describe());
+var_dump($fetched['relativeInterval'] instanceof DateInterval);
+var_dump($fetched['relativeInterval']->format('%d %h'));
+
+var_dump($fetched['selfId'] instanceof UserCacheSelfIdDateTime);
+var_dump($fetched['selfId']->cachedSelfId() === $payload['selfId']->cachedSelfId());
+var_dump($fetched['selfId']->cachedSelfId() !== spl_object_id($fetched['selfId']));
+var_dump($fetched['selfId']->publicSelfId === spl_object_id($payload['selfId']));
+var_dump($fetched['selfId']->publicSelfId === spl_object_id($fetched['selfId']));
+
+var_dump($fetched['magicDate'] instanceof UserCacheMagicDateTime);
+var_dump($fetched['magicDate']->format('Y-m-d H:i:s.u e'));
+var_dump($fetched['magicDate']->label());
+var_dump(UserCacheMagicDateTime::$serializeCount);
+var_dump(UserCacheMagicDateTime::$unserializeCount);
+
+var_dump($fetched['magicSelfId'] instanceof UserCacheMagicSelfIdDateTime);
+var_dump($fetched['magicSelfId']->format('Y-m-d H:i:s.u e'));
+var_dump($fetched['magicSelfId']->constructedObjectId() === spl_object_id($fetched['magicSelfId']));
+var_dump($fetched['magicSelfId']->constructedObjectId() !== spl_object_id($payload['magicSelfId']));
+var_dump(UserCacheMagicSelfIdDateTime::$serializeCount);
+var_dump(UserCacheMagicSelfIdDateTime::$unserializeCount);
+
+var_dump($fetched['magicFiltered'] instanceof UserCacheMagicFilteredDateTime);
+var_dump($fetched['magicFiltered']->format('Y-m-d H:i:s.u e'));
+var_dump($fetched['magicFiltered']->label());
+var_dump(($fetched['magicFiltered']->hidden)());
+var_dump(UserCacheMagicFilteredDateTime::$serializeCount);
+var_dump(UserCacheMagicFilteredDateTime::$unserializeCount);
+
+var_dump($fetched['wakefulDate'] instanceof UserCacheWakefulDateTime);
+var_dump($fetched['wakefulDate']->format('Y-m-d H:i:s.u e'));
+var_dump($fetched['wakefulDate']->label());
+var_dump(UserCacheWakefulDateTime::$sleepCount);
+var_dump(UserCacheWakefulDateTime::$wakeupCount);
+
+/* DateInterval from diff() and relative strings keeps days, invert and special fields */
+$cache = UserCache\Cache::getPool('datetime-interval-diff');
+
+$diff = (new DateTimeImmutable('2026-01-01 00:00:00'))->diff(new DateTimeImmutable('2026-03-15 10:30:00'));
+$cache->store('diff', $diff);
+$d = $cache->fetch('diff');
+var_dump(serialize($d) === serialize($diff));
+var_dump($d->days);
+var_dump($d->invert);
+var_dump($d->format('%a days %h:%i'));
+
+$inv = (new DateTimeImmutable('2026-03-15'))->diff(new DateTimeImmutable('2026-01-01'));
+$cache->store('inv', $inv);
+var_dump($cache->fetch('inv')->invert);
+
+$rel = DateInterval::createFromDateString('last day of next month');
+$cache->store('rel', $rel);
+$rl = $cache->fetch('rel');
+var_dump(serialize($rl) === serialize($rel));
+$base = new DateTimeImmutable('2026-07-23');
+var_dump($base->add($rl)->format('Y-m-d') === $base->add($rel)->format('Y-m-d'));
+
+/* Validated restore keeps parity with native unserialize(serialize()) for edge values */
+echo "\ndatetime edge values:\n";
+$cache = UserCache\Cache::getPool('datetime-edge-values');
+$cache->clear();
+
+function edge(string $label, DateTimeInterface $value): void
+{
+ global $cache;
+ $cache->store($label, $value);
+ $fetched = $cache->fetch($label, 'unrestorable');
+ $native = unserialize(serialize($value));
+ echo $label, ': ';
+ if (!$fetched instanceof DateTimeInterface) {
+ var_dump($fetched);
+ return;
+ }
+ echo serialize($fetched) === serialize($native) ? 'serialize-same' : 'serialize-differs';
+ echo ' ', $fetched == $native ? 'same-instant' : 'other-instant';
+ echo ' ', $fetched->format('Y-m-d H:i:s.u T P U'), "\n";
+}
+
+$newYork = new DateTimeZone('America/New_York');
+$utc = new DateTimeZone('UTC');
+edge('max-year', new DateTimeImmutable('@9223372036854775807'));
+edge('min-year', new DateTimeImmutable('@-9223372036854775808'));
+edge('negative-year', new DateTimeImmutable('-0044-03-15 12:00:00', $utc));
+edge('year-100000', new DateTimeImmutable('+100000-01-01 00:00:00', new DateTimeZone('Europe/Paris')));
+edge('microseconds-max', new DateTimeImmutable('2024-12-31 23:59:59.999999', $utc));
+edge('abbr-dst', new DateTimeImmutable('2024-07-01 12:00:00 EDT'));
+edge('abbr-set-timezone', (new DateTimeImmutable('2024-01-01 12:00:00 UTC'))->setTimezone(new DateTimeZone('CEST')));
+edge('offset-negative', new DateTimeImmutable('2024-07-01 12:00:00 -03:30'));
+edge('dst-fall-back-first', new DateTimeImmutable('2024-11-03 01:30:00', $newYork));
+edge('dst-spring-gap-settime', (new DateTime('2024-03-10 00:00:00', $newYork))->setTime(2, 30));
+edge('add-weekday-relative', (new DateTimeImmutable('2026-01-01 10:00:00', $utc))->add(DateInterval::createFromDateString('next monday')));
+edge('add-special-relative', (new DateTimeImmutable('2026-01-01 10:00:00', $utc))->add(DateInterval::createFromDateString('first monday of next month')));
+edge('add-weekdays', (new DateTimeImmutable('2026-01-01 10:00:00', $utc))->add(DateInterval::createFromDateString('+3 weekdays')));
+edge('modify-last-day-of', (new DateTime('2026-01-31 10:00:00', $newYork))->modify('last day of next month'));
+edge('modify-this-week', (new DateTime('2026-01-01 10:00:00', $newYork))->modify('monday this week'));
+edge('set-iso-date', (new DateTime('2026-01-01 10:00:00', $newYork))->setISODate(2026, 53, 7));
+edge('from-format-u', DateTime::createFromFormat('U.u', '1700000000.123456'));
+
+/* The stored instant survives where native serialization is lossy: the second 01:30 of a DST fall-back, and a +99:59 offset */
+$fallBackSecond = (new DateTimeImmutable('now', $newYork))->setTimestamp(1730615400);
+$cache->store('dst-fall-back-second', $fallBackSecond);
+var_dump($cache->fetch('dst-fall-back-second')->format('Y-m-d H:i:s T U'));
+var_dump(unserialize(serialize($fallBackSecond))->format('Y-m-d H:i:s T U'));
+$wideOffset = (new DateTimeImmutable('2024-07-01 12:00:00 UTC'))->setTimezone(new DateTimeZone('+99:59'));
+$cache->store('offset-wide', $wideOffset);
+var_dump($cache->fetch('offset-wide')->format('Y-m-d H:i:s P U') === $wideOffset->format('Y-m-d H:i:s P U'));
+try {
+ unserialize(serialize($wideOffset));
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+/* DateInterval restore accepts every field combination native construction and unserialize() produce */
+echo "\ninterval edge values:\n";
+function interval_edge(string $label, DateInterval $interval): void
+{
+ global $cache;
+ $cache->store($label, $interval);
+ $fetched = $cache->fetch($label, 'unrestorable');
+ echo $label, ': ';
+ if (!$fetched instanceof DateInterval) {
+ var_dump($fetched);
+ return;
+ }
+ $base = new DateTimeImmutable('2026-07-23 08:00:00', new DateTimeZone('UTC'));
+ echo serialize($fetched) === serialize($interval) ? 'serialize-same' : 'serialize-differs';
+ echo ' ', $base->add($fetched) == $base->add($interval) ? 'adds-same' : 'adds-differently';
+ echo ' ', $fetched->format('%R %y-%m-%d %h:%i:%s.%f %a'), "\n";
+}
+interval_edge('iso', new DateInterval('P1Y2M3DT4H5M6S'));
+interval_edge('negative-components', DateInterval::createFromDateString('-2 days -3 hours'));
+interval_edge('first-weekday-of', DateInterval::createFromDateString('first monday of next month'));
+interval_edge('last-weekday-of', DateInterval::createFromDateString('last friday of this month'));
+interval_edge('weekdays', DateInterval::createFromDateString('+3 weekdays'));
+interval_edge('next-weekday', DateInterval::createFromDateString('next sunday'));
+interval_edge('last-weekday', DateInterval::createFromDateString('last sunday'));
+interval_edge('this-week', DateInterval::createFromDateString('monday this week'));
+interval_edge('diff-inverted-microseconds', (new DateTimeImmutable('2026-03-15 10:00:00.5'))->diff(new DateTimeImmutable('2026-01-01')));
+interval_edge('from-period', (new DatePeriod(new DateTimeImmutable('2026-01-01'), new DateInterval('P1D'), 2))->getDateInterval());
+interval_edge('unserialized', unserialize(serialize(new DateInterval('P1D'))));
+interval_edge('set-state', DateInterval::__set_state(['y' => 1, 'm' => 2, 'd' => 3, 'h' => 4, 'i' => 5, 's' => 6, 'f' => 0.5, 'invert' => 1, 'days' => false]));
+interval_edge('set-state-empty', DateInterval::__set_state([]));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+string(5) "tokyo"
+string(44) "2024-01-02 03:04:05.123456 +09:00 Asia/Tokyo"
+bool(true)
+string(30) "2026-06-29 09:00:00.000001 UTC"
+string(39) "2026-06-29 09:30:00.000002 Europe/Paris"
+string(43) "2026-06-29 10:00:00.000003 America/New_York"
+string(40) "2023-10-27 10:00:00.000001 +05:30 +05:30"
+string(37) "2023-10-27 10:00:00.000002 -05:00 EST"
+string(12) "Europe/Paris"
+string(3) "1 2"
+bool(true)
+string(12) "Europe/Paris"
+string(5) "paris"
+bool(true)
+string(11) "1-2-3 4:5:6"
+string(8) "window:9"
+bool(true)
+string(3) "2 4"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(true)
+string(30) "2026-06-15 10:45:00.654321 UTC"
+string(16) "serialized-magic"
+int(1)
+int(1)
+bool(true)
+string(30) "2026-06-15 11:45:00.111111 UTC"
+bool(true)
+bool(true)
+int(1)
+int(1)
+bool(true)
+string(30) "2026-06-15 12:00:00.222222 UTC"
+string(8) "filtered"
+int(2)
+int(1)
+int(1)
+bool(true)
+string(30) "2026-06-15 12:15:00.987654 UTC"
+string(7) "wakeful"
+int(0)
+int(0)
+bool(true)
+int(73)
+int(0)
+string(13) "73 days 10:30"
+int(1)
+bool(true)
+bool(true)
+
+datetime edge values:
+max-year: serialize-same same-instant 292277026596-12-04 15:30:07.000000 GMT+0000 +00:00 9223372036854775807
+min-year: serialize-same same-instant -292277022657-01-27 08:29:52.000000 GMT+0000 +00:00 -9223372036854775808
+negative-year: serialize-same same-instant -0044-03-15 12:00:00.000000 UTC +00:00 -63549316800
+year-100000: serialize-same same-instant 100000-01-01 00:00:00.000000 CET +01:00 3093527977200
+microseconds-max: serialize-same same-instant 2024-12-31 23:59:59.999999 UTC +00:00 1735689599
+abbr-dst: serialize-same same-instant 2024-07-01 12:00:00.000000 EDT -04:00 1719849600
+abbr-set-timezone: serialize-same same-instant 2024-01-01 14:00:00.000000 CEST +02:00 1704110400
+offset-negative: serialize-same same-instant 2024-07-01 12:00:00.000000 GMT-0330 -03:30 1719847800
+dst-fall-back-first: serialize-same same-instant 2024-11-03 01:30:00.000000 EDT -04:00 1730611800
+dst-spring-gap-settime: serialize-same same-instant 2024-03-10 03:30:00.000000 EDT -04:00 1710055800
+add-weekday-relative: serialize-same same-instant 2026-01-05 10:00:00.000000 UTC +00:00 1767607200
+add-special-relative: serialize-same same-instant 2026-02-02 10:00:00.000000 UTC +00:00 1770026400
+add-weekdays: serialize-same same-instant 2026-01-06 10:00:00.000000 UTC +00:00 1767693600
+modify-last-day-of: serialize-same same-instant 2026-02-28 10:00:00.000000 EST -05:00 1772290800
+modify-this-week: serialize-same same-instant 2025-12-29 00:00:00.000000 EST -05:00 1766984400
+set-iso-date: serialize-same same-instant 2027-01-03 10:00:00.000000 EST -05:00 1798988400
+from-format-u: serialize-same same-instant 2023-11-14 22:13:20.123456 GMT+0000 +00:00 1700000000
+string(34) "2024-11-03 01:30:00 EST 1730615400"
+string(34) "2024-11-03 01:30:00 EDT 1730611800"
+bool(true)
+Invalid serialization data for DateTimeImmutable object
+
+interval edge values:
+iso: serialize-same adds-same + 1-2-3 4:5:6.0 (unknown)
+negative-components: serialize-same adds-same + 0-0--2 -3:0:0.0 (unknown)
+first-weekday-of: serialize-same adds-same + 0-1-0 0:0:0.0 (unknown)
+last-weekday-of: serialize-same adds-same + 0-0--7 0:0:0.0 (unknown)
+weekdays: serialize-same adds-same + 0-0-0 0:0:0.0 (unknown)
+next-weekday: serialize-same adds-same + 0-0-0 0:0:0.0 (unknown)
+last-weekday: serialize-same adds-same + 0-0--7 0:0:0.0 (unknown)
+this-week: serialize-same adds-same + 0-0-0 0:0:0.0 (unknown)
+diff-inverted-microseconds: serialize-same adds-same - 0-2-14 10:0:0.500000 73
+from-period: serialize-same adds-same + 0-0-1 0:0:0.0 (unknown)
+unserialized: serialize-same adds-same + 0-0-1 0:0:0.0 (unknown)
+set-state: serialize-same adds-same - 1-2-3 4:5:6.500000 (unknown)
+set-state-empty: serialize-same adds-same + -1--1--1 -1:-1:-1.0 -1
diff --git a/ext/user_cache/tests/user_cache_deferred_restore_hooks.phpt b/ext/user_cache/tests/user_cache_deferred_restore_hooks.phpt
new file mode 100644
index 000000000000..4d6a3cd0eebf
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_deferred_restore_hooks.phpt
@@ -0,0 +1,418 @@
+--TEST--
+UserCache\Cache: restore hooks observe completed graphs, isolate nested fetches, failures, Fiber switches and native unserialize() contexts, may store, delete or clear the key being fetched, and release interrupted decodes on a fatal error
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+parent->name;
+ if ($this->nested) {
+ UserCache\Cache::getPool('deferred-restore')->fetch('nested');
+ self::$events[] = 'returned:' . $this->parent->name;
+ }
+ }
+}
+
+class RestoreUnserialize
+{
+ public RestoreParent $parent;
+
+ public function __serialize(): array
+ {
+ return ['parent' => $this->parent];
+ }
+
+ public function __unserialize(array $state): void
+ {
+ $this->parent = $state['parent'];
+ RestoreWakeup::$events[] = 'unserialize:' . $this->parent->name;
+ }
+}
+
+function graph(object $child, string $name): RestoreParent
+{
+ $parent = new RestoreParent;
+ $parent->child = $child;
+ $parent->name = $name;
+ $child->parent = $parent;
+ return $parent;
+}
+
+$cache = UserCache\Cache::getPool('deferred-restore');
+$cache->store('nested', graph(new RestoreUnserialize, 'nested'));
+$wakeup = new RestoreWakeup;
+$wakeup->nested = true;
+$value = [graph($wakeup, 'outer'), graph(new RestoreUnserialize, 'sibling')];
+$cache->store('value', $value);
+
+RestoreWakeup::$events = [];
+$native = unserialize(serialize($value));
+$expected = RestoreWakeup::$events;
+RestoreWakeup::$events = [];
+$result = $cache->fetch('value');
+var_dump(RestoreWakeup::$events === $expected);
+echo implode(',', RestoreWakeup::$events), "\n";
+var_dump($result[0]->child->parent === $result[0]);
+var_dump($result[1]->child->parent === $result[1]);
+
+class ThrowingRestore
+{
+ public static array $events = [];
+ public static array $destroyed = [];
+ public static bool $throw = false;
+ public function __construct(public string $name) {}
+ public function __wakeup(): void
+ {
+ self::$events[] = $this->name;
+ if (self::$throw && $this->name === 'second') {
+ throw new RuntimeException('restore failed');
+ }
+ }
+ public function __destruct()
+ {
+ self::$destroyed[] = $this->name;
+ }
+}
+
+$cache->store('throwing', [new ThrowingRestore('first'), new ThrowingRestore('second'), new ThrowingRestore('third')]);
+ThrowingRestore::$throw = true;
+ThrowingRestore::$destroyed = [];
+try {
+ $cache->fetch('throwing');
+} catch (RuntimeException $exception) {
+ echo $exception->getMessage(), "\n";
+}
+echo implode(',', ThrowingRestore::$events), "\n";
+echo 'destructors:', implode(',', ThrowingRestore::$destroyed), "\n";
+ThrowingRestore::$throw = false;
+ThrowingRestore::$events = [];
+$result = $cache->fetch('throwing');
+echo implode(',', ThrowingRestore::$events), "\n";
+var_dump(count($result));
+RestoreWakeup::$events = [];
+$cache->fetch('value');
+var_dump(RestoreWakeup::$events === $expected);
+
+class CatchingRestore
+{
+ public function __wakeup(): void
+ {
+ try {
+ UserCache\Cache::getPool('deferred-restore')->fetch('throwing');
+ } catch (RuntimeException $exception) {
+ echo 'nested: ', $exception->getMessage(), "\n";
+ }
+ }
+}
+
+$cache->store('catching', [new CatchingRestore, graph(new RestoreUnserialize, 'after-catch')]);
+ThrowingRestore::$throw = true;
+ThrowingRestore::$events = [];
+RestoreWakeup::$events = [];
+$result = $cache->fetch('catching');
+echo implode(',', ThrowingRestore::$events), "\n";
+echo implode(',', RestoreWakeup::$events), "\n";
+var_dump($result[1]->child->parent === $result[1]);
+
+/* Release builds do not collect cycles at shutdown. */
+$native = $result = $value = $wakeup = null;
+gc_collect_cycles();
+
+class Node
+{
+ public $tag;
+ public $peer;
+
+ public function __wakeup(): void
+ {
+ if (Fiber::getCurrent() !== null) {
+ Fiber::suspend($this->tag);
+ }
+ }
+}
+
+function fetch_restore_hook_fiber_switch(): void
+{
+ $cache = UserCache\Cache::getPool('fetch-restore-hook-fiber-switch');
+ foreach (['a', 'b'] as $key) {
+ $node = new Node;
+ $node->tag = "$key-node";
+ $node->peer = $node;
+ $cache->store($key, [$node, $node]);
+ }
+
+ $fibers = [];
+ foreach (['a', 'b'] as $key) {
+ $fibers[$key] = new Fiber(function () use ($cache, $key): string {
+ try {
+ $cache->fetch($key);
+
+ return 'fetched';
+ } catch (FiberError $e) {
+ return $e->getMessage();
+ }
+ });
+ }
+ foreach ($fibers as $key => $fiber) {
+ var_dump($fiber->start(), $fiber->getReturn());
+ }
+
+ $fetched = $cache->fetch('a');
+ var_dump($fetched[0]->tag, $fetched[0] === $fetched[1], $fetched[0]->peer === $fetched[0]);
+
+ /* Release builds do not collect cycles at shutdown. */
+ $node = $fetched = null;
+ gc_collect_cycles();
+}
+
+echo "\nfetch restore hook fiber switch:\n";
+fetch_restore_hook_fiber_switch();
+
+class Waker
+{
+ public $probe;
+
+ public function __wakeup(): void
+ {
+ $this->probe = unserialize('a:2:{i:0;O:8:"stdClass":1:{s:3:"tag";s:5:"inner";}i:1;r:2;}');
+ }
+}
+
+class Probe
+{
+ public $probe;
+
+ public function __unserialize(array $data): void
+ {
+ $this->probe = unserialize('a:2:{i:0;O:8:"stdClass":1:{s:3:"tag";s:5:"inner";}i:1;r:2;}');
+ }
+
+ public function __serialize(): array
+ {
+ return [];
+ }
+}
+
+class Outer implements Serializable
+{
+ public static array $fetched = [];
+
+ public function serialize(): string
+ {
+ return '';
+ }
+
+ public function unserialize(string $data): void
+ {
+ $cache = UserCache\Cache::getPool('graph-restore-serialize-lock');
+ self::$fetched[] = $cache->fetch('waker');
+ self::$fetched[] = $cache->fetch('probe');
+ }
+
+ public function __serialize(): array
+ {
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+function graph_restore_serialize_lock(): void
+{
+ $cache = UserCache\Cache::getPool('graph-restore-serialize-lock');
+ $cache->store('waker', new Waker());
+ $cache->store('probe', new Probe());
+
+ $outer = unserialize('a:2:{i:0;O:8:"stdClass":1:{s:3:"tag";s:5:"outer";}i:1;C:5:"Outer":0:{}}');
+ var_dump($outer[0]->tag, get_class($outer[1]));
+ foreach (Outer::$fetched as $fetched) {
+ var_dump($fetched->probe[0]->tag, $fetched->probe[1]->tag);
+ }
+}
+
+echo "\ngraph restore serialize lock:\n";
+graph_restore_serialize_lock();
+
+class WakeupWritesFetchedKey
+{
+ public static array $returns = [];
+
+ public function __construct(public string $action) {}
+
+ public function __wakeup(): void
+ {
+ self::$returns[] = hook_write_fetched_key($this->action);
+ }
+}
+
+class UnserializeWritesFetchedKey
+{
+ public function __construct(public string $action) {}
+
+ public function __serialize(): array
+ {
+ return ['action' => $this->action];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->action = $data['action'];
+ WakeupWritesFetchedKey::$returns[] = hook_write_fetched_key($this->action);
+ }
+}
+
+function hook_write_fetched_key(string $action): bool
+{
+ $cache = UserCache\Cache::getPool('restore-hook-writes-fetched-key');
+
+ return match ($action) {
+ 'store' => $cache->store('fetched', 'stored by hook'),
+ 'delete' => $cache->delete('fetched'),
+ 'clear' => $cache->clear(),
+ 'deletePool' => UserCache\Cache::deletePool('restore-hook-writes-fetched-key'),
+ };
+}
+
+function restore_hook_writes_fetched_key(): void
+{
+ $payload = str_repeat('p', 5000);
+ foreach ([WakeupWritesFetchedKey::class, UnserializeWritesFetchedKey::class] as $class) {
+ foreach (['store', 'delete', 'clear', 'deletePool'] as $action) {
+ $cache = UserCache\Cache::getPool('restore-hook-writes-fetched-key');
+ $cache->store('fetched', [new $class($action), $payload]);
+ $cache->store('sibling', 'sibling');
+ WakeupWritesFetchedKey::$returns = [];
+
+ $fetched = $cache->fetch('fetched');
+ $current = UserCache\Cache::getPool('restore-hook-writes-fetched-key');
+ for ($i = 0; $i < 8; $i++) {
+ $current->store("filler-$i", str_repeat('q', 5000));
+ }
+ printf(
+ "%s %s: hook %s, fetched the stored value %s, then %s, sibling %s, same pool object %s\n",
+ $class,
+ $action,
+ implode(',', array_map(fn($returned) => var_export($returned, true), WakeupWritesFetchedKey::$returns)),
+ var_export($fetched[0] instanceof $class && $fetched[0]->action === $action && $fetched[1] === $payload, true),
+ var_export($cache->fetch('fetched', 'MISS'), true),
+ var_export($current->fetch('sibling', 'MISS'), true),
+ var_export($current === $cache, true)
+ );
+ $current->clear();
+ }
+ }
+}
+
+echo "\nrestore hook writes fetched key:\n";
+restore_hook_writes_fetched_key();
+
+class BailoutInner
+{
+ public $peer;
+
+ public function __wakeup(): void
+ {
+ eval('class GraphRestoreHookBailout {} class GraphRestoreHookBailout {}');
+ }
+}
+
+class BailoutOuter
+{
+ public $peer;
+
+ public function __wakeup(): void
+ {
+ echo "Outer::__wakeup\n";
+ UserCache\Cache::getPool('graph-restore-hook-bailout')->fetch('inner');
+ }
+}
+
+function graph_restore_hook_bailout(): void
+{
+ $cache = UserCache\Cache::getPool('graph-restore-hook-bailout');
+
+ /* Shared objects make both decodes keep identity maps alive while their
+ * restore hooks run. */
+ $inner = new BailoutInner();
+ $inner->peer = $inner;
+ $cache->store('inner', [$inner, $inner]);
+
+ $outer = new BailoutOuter();
+ $outer->peer = $outer;
+ $cache->store('outer', [$outer, $outer]);
+
+ /* Release builds do not collect cycles at shutdown. */
+ $inner = $outer = null;
+ gc_collect_cycles();
+
+ $cache->fetch('outer');
+ echo "unreachable\n";
+}
+
+echo "\ngraph restore hook bailout:\n";
+graph_restore_hook_bailout();
+?>
+--EXPECTF--
+bool(true)
+wakeup:outer,unserialize:nested,returned:outer,unserialize:sibling
+bool(true)
+bool(true)
+restore failed
+first,second
+destructors:first
+first,second,third
+int(3)
+bool(true)
+nested: restore failed
+first,second
+unserialize:after-catch
+bool(true)
+
+fetch restore hook fiber switch:
+NULL
+string(49) "Cannot switch fibers in current execution context"
+NULL
+string(49) "Cannot switch fibers in current execution context"
+string(6) "a-node"
+bool(true)
+bool(true)
+
+graph restore serialize lock:
+string(5) "outer"
+string(5) "Outer"
+string(5) "inner"
+string(5) "inner"
+string(5) "inner"
+string(5) "inner"
+
+restore hook writes fetched key:
+WakeupWritesFetchedKey store: hook true, fetched the stored value true, then 'stored by hook', sibling 'sibling', same pool object true
+WakeupWritesFetchedKey delete: hook true, fetched the stored value true, then 'MISS', sibling 'sibling', same pool object true
+WakeupWritesFetchedKey clear: hook true, fetched the stored value true, then 'MISS', sibling 'MISS', same pool object true
+WakeupWritesFetchedKey deletePool: hook true, fetched the stored value true, then 'MISS', sibling 'MISS', same pool object false
+UnserializeWritesFetchedKey store: hook true, fetched the stored value true, then 'stored by hook', sibling 'sibling', same pool object true
+UnserializeWritesFetchedKey delete: hook true, fetched the stored value true, then 'MISS', sibling 'sibling', same pool object true
+UnserializeWritesFetchedKey clear: hook true, fetched the stored value true, then 'MISS', sibling 'MISS', same pool object true
+UnserializeWritesFetchedKey deletePool: hook true, fetched the stored value true, then 'MISS', sibling 'MISS', same pool object false
+
+graph restore hook bailout:
+Outer::__wakeup
+
+Fatal error: Cannot redeclare class GraphRestoreHookBailout (previously declared in %s) in %s on line 1
diff --git a/ext/user_cache/tests/user_cache_disabled.phpt b/ext/user_cache/tests/user_cache_disabled.phpt
new file mode 100644
index 000000000000..de58ae0c8f38
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_disabled.phpt
@@ -0,0 +1,94 @@
+--TEST--
+UserCache\Cache: degraded mode when disabled by INI size (including deletePool()) and the availability enum surface
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=0
+--FILE--
+getPoolStatus();
+var_dump($poolStatus->getPoolName(), $status->getAvailability()->name, $poolStatus->getEntryCount(), $poolStatus->getEntryKeys(), $poolStatus->getUsedMemory());
+var_dump($status->getConfiguredMemory());
+var_dump($cache->store('key', 1));
+var_dump($cache->add('key', 1));
+var_dump($cache->storeMultiple(['key' => 1]));
+var_dump($cache->increment('key'));
+var_dump($cache->decrement('key'));
+var_dump($cache->has('key'));
+var_dump($cache->fetch('key', 'default'));
+var_dump($cache->fetchMultiple(['key', 'other'], 'default'));
+var_dump($cache->delete('key'));
+var_dump($cache->deleteMultiple(['key', 'other']));
+var_dump($cache->clear());
+var_dump($cache->lock('key'));
+var_dump($cache->unlock('key'));
+var_dump($cache->remember('key', fn() => 42));
+
+var_dump($status->getAvailability());
+
+/* The availability enum case list stays stable. */
+foreach (UserCache\CacheAvailability::cases() as $case) {
+ echo $case->name, "\n";
+}
+
+echo "\ndeletePool() while disabled:\n";
+var_dump(
+ UserCache\Cache::hasPool('off'),
+ UserCache\Cache::deletePool('off'),
+ UserCache\Cache::hasPool('off'),
+ UserCache\Cache::getPools(),
+ UserCache\Cache::deletePool('never-used')
+);
+var_dump(UserCache\Cache::getPool('off') !== $cache, $cache->store('key', 1));
+?>
+--EXPECT--
+string(3) "off"
+string(13) "DisabledByIni"
+int(0)
+array(0) {
+}
+int(0)
+int(0)
+bool(false)
+bool(false)
+bool(false)
+NULL
+NULL
+bool(false)
+string(7) "default"
+array(2) {
+ ["key"]=>
+ string(7) "default"
+ ["other"]=>
+ string(7) "default"
+}
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+int(42)
+enum(UserCache\CacheAvailability::DisabledByIni)
+Available
+DisabledByIni
+DisabledBySapi
+UnavailableBySharedMemoryInitializationFailed
+UnavailableByBackendNotInitializedBeforeWorkerStartup
+UnavailableByBackendInitializedAfterWorkerStartup
+UnavailableByCgiFastCgiBoundary
+UnavailableByApacheBoundary
+UnavailableByLsapiBoundary
+UnavailableByUnknownReason
+
+deletePool() while disabled:
+bool(true)
+bool(true)
+bool(false)
+array(0) {
+}
+bool(true)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_dtor_reentry.phpt b/ext/user_cache/tests/user_cache_dtor_reentry.phpt
new file mode 100644
index 000000000000..fd50794b61a9
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_dtor_reentry.phpt
@@ -0,0 +1,291 @@
+--TEST--
+UserCache\Cache: mutations do not run hidden destructors, while fetched objects and cycle-collector destructors during prototype cloning or fetchMultiple() cleanup may re-enter the pool
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+ }
+ }
+}
+
+class DeletePoolReentrantDtor
+{
+ public static bool $armed = false;
+
+ public int $n = 2;
+
+ public function __destruct()
+ {
+ if (self::$armed) {
+ self::$armed = false;
+
+ UserCache\Cache::deletePool('clear-dtor-reentry');
+ }
+ }
+}
+
+function seed_request_local_slot(UserCache\Cache $cache, string $key, object $value): void
+{
+ $cache->store($key, $value);
+
+ $marked = $cache->fetch($key);
+ unset($marked);
+
+ $seeded = $cache->fetch($key);
+ unset($seeded);
+}
+
+$cache = UserCache\Cache::getPool('clear-dtor-reentry');
+$cache->clear();
+
+seed_request_local_slot($cache, 'single', new ClearReentrantDtor());
+
+ClearReentrantDtor::$armed = true;
+var_dump($cache->clear());
+ClearReentrantDtor::$armed = false;
+
+var_dump($cache->has('single'));
+
+foreach (['multi-1', 'multi-2', 'multi-3'] as $key) {
+ seed_request_local_slot($cache, $key, new ClearReentrantDtor());
+}
+
+ClearReentrantDtor::$armed = true;
+var_dump($cache->clear());
+ClearReentrantDtor::$armed = false;
+
+var_dump($cache->has('multi-1'));
+var_dump($cache->has('multi-3'));
+
+$cache = UserCache\Cache::getPool('clear-dtor-reentry');
+seed_request_local_slot($cache, 'delete-pool', new DeletePoolReentrantDtor());
+
+DeletePoolReentrantDtor::$armed = true;
+var_dump(UserCache\Cache::deletePool('clear-dtor-reentry'));
+DeletePoolReentrantDtor::$armed = false;
+
+var_dump(UserCache\Cache::getPool('clear-dtor-reentry')->has('delete-pool'));
+
+/* Only the caller's live objects may clear the pool from their destructors. */
+class SlotReplaceDtor
+{
+ public int $n = 1;
+ public static bool $armed = false;
+
+ public function __destruct()
+ {
+ if (self::$armed) {
+ UserCache\Cache::getPool('slot-replace')->clear();
+ }
+ }
+}
+
+$cache = UserCache\Cache::getPool('slot-replace');
+$cache->clear();
+
+$obj = new SlotReplaceDtor();
+var_dump($cache->store('k', $obj));
+
+$first = $cache->fetch('k');
+$second = $cache->fetch('k');
+var_dump($second->n);
+
+SlotReplaceDtor::$armed = true;
+var_dump($cache->store('k', str_repeat('a', 300)));
+var_dump($cache->fetch('k') === str_repeat('a', 300));
+unset($obj, $first, $second);
+SlotReplaceDtor::$armed = false;
+
+var_dump($cache->fetch('k'));
+
+/* delete() does not keep an extra object with a user destructor; a live fetched object re-enters the pool once released. */
+class DeleteReentrantDtor
+{
+ public static bool $armed = false;
+
+ public function __destruct()
+ {
+ if (self::$armed) {
+ self::$armed = false;
+ $cache = UserCache\Cache::getPool('delete-dtor-reentry');
+ $cache->clear();
+ $cache->store('from-destructor', str_repeat('y', 300));
+ }
+ }
+}
+
+$cache = UserCache\Cache::getPool('delete-dtor-reentry');
+$cache->clear();
+$cache->store('object', new DeleteReentrantDtor());
+$cache->fetch('object');
+$cache->fetch('object');
+$held = $cache->fetch('object');
+DeleteReentrantDtor::$armed = true;
+var_dump($cache->delete('object'));
+var_dump($cache->fetch('from-destructor') === null);
+unset($held);
+var_dump($cache->fetch('from-destructor') === str_repeat('y', 300));
+var_dump($cache->has('object'));
+echo "done\n";
+
+echo "\nrequest-local prototype gc release:\n";
+
+class Dto
+{
+ public int $a = 1;
+ public array $b = [1, 2];
+}
+
+class DeletingGarbage
+{
+ public $self;
+
+ public function __destruct()
+ {
+ global $cache, $deletes;
+
+ $deletes++;
+ $cache->delete('proto');
+ }
+}
+
+$cache = Cache::getPool('prototype-gc');
+$deletes = 0;
+$value = ['a' => new Dto(), 'b' => new Dto(), 'c' => [new Dto(), new Dto()]];
+
+for ($round = 0; $round < 4; $round++) {
+ $cache->store('proto', $value);
+ $cache->fetch('proto');
+ $cache->fetch('proto');
+
+ gc_collect_cycles();
+ $status = gc_status();
+ for ($i = $status['roots']; $i < $status['threshold'] - 1; $i++) {
+ $garbage = new DeletingGarbage();
+ $garbage->self = $garbage;
+ unset($garbage);
+ }
+
+ $fetched = $cache->fetch('proto');
+ var_dump($fetched == $value);
+ unset($fetched);
+}
+
+gc_collect_cycles();
+var_dump($deletes > 0);
+
+echo "\nfetchMultiple exception cleanup:\n";
+
+class StoringDestructor
+{
+ public $first;
+ public $second;
+
+ public function __destruct()
+ {
+ global $cache, $armed;
+
+ if ($armed) {
+ var_dump($cache->store('from-destructor', [1, 'x']));
+ }
+ }
+}
+
+class ThrowingGarbage
+{
+ public $self;
+
+ public function __destruct()
+ {
+ global $armed, $thrown;
+
+ if ($armed && !$thrown) {
+ $thrown = true;
+
+ throw new RuntimeException('from garbage');
+ }
+ }
+}
+
+$cache = UserCache\Cache::getPool('fetch-multiple-exception-cleanup');
+$armed = false;
+$thrown = false;
+
+$shared = new stdClass();
+$value = new StoringDestructor();
+$value->first = $shared;
+$value->second = $shared;
+var_dump($cache->store('value', $value));
+unset($value, $shared);
+
+gc_collect_cycles();
+$status = gc_status();
+for ($i = $status['roots']; $i < $status['threshold'] - 1; $i++) {
+ $garbage = new ThrowingGarbage();
+ $garbage->self = $garbage;
+ unset($garbage);
+}
+
+$armed = true;
+try {
+ $cache->fetchMultiple(['value']);
+ echo "no exception\n";
+} catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+}
+$armed = false;
+
+var_dump($cache->fetch('from-destructor'));
+gc_collect_cycles();
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+int(1)
+bool(true)
+bool(true)
+NULL
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+done
+
+request-local prototype gc release:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+fetchMultiple exception cleanup:
+bool(true)
+bool(true)
+from garbage
+array(2) {
+ [0]=>
+ int(1)
+ [1]=>
+ string(1) "x"
+}
diff --git a/ext/user_cache/tests/user_cache_entry_reuse.phpt b/ext/user_cache/tests/user_cache_entry_reuse.phpt
new file mode 100644
index 000000000000..dca4c0bddb46
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_entry_reuse.phpt
@@ -0,0 +1,114 @@
+--TEST--
+UserCache\Cache: overwrites reuse entries and any value type can replace any other
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+store('same', ['i' => $i, 'payload' => str_repeat('x', 1024)])) {
+ echo "same failed at $i\n";
+ exit;
+ }
+}
+
+$same = $cache->fetch('same');
+var_dump($same['i']);
+var_dump(strlen($same['payload']));
+
+for ($i = 0; $i < 5000; $i++) {
+ if (!$cache->delete('same')) {
+ echo "delete failed at $i\n";
+ exit;
+ }
+ if (!$cache->store('same', ['i' => $i])) {
+ echo "store failed at $i\n";
+ exit;
+ }
+}
+
+$churned = $cache->fetch('same');
+var_dump($churned['i']);
+
+/* Scalar and payload entries replace each other without disturbing held values or accounting. */
+$cache = UserCache\Cache::getPool('entry-reuse-transitions');
+$values = [
+ null, false, true, 0, -0.0, '', str_repeat("a\0b", 170),
+ str_repeat('long', 2048), ['object' => (object) ['n' => 7]],
+];
+
+foreach ($values as $oldIndex => $old) {
+ foreach ($values as $newIndex => $new) {
+ $cache->clear();
+ if (!$cache->store('value', $old)) {
+ die("initial store failed: $oldIndex\n");
+ }
+ $held = $cache->fetch('value');
+ if (!$cache->store('value', $new)) {
+ die("replacement failed: $oldIndex/$newIndex\n");
+ }
+ for ($read = 0; $read < 3; $read++) {
+ if (serialize($cache->fetch('value')) !== serialize($new)) {
+ die("fetch mismatch: $oldIndex/$newIndex/$read\n");
+ }
+ }
+ if (serialize($held) !== serialize($old)) {
+ die("held value changed: $oldIndex/$newIndex\n");
+ }
+ $cache->store('epoch', 1);
+ if (serialize($cache->fetch('value')) !== serialize($new)) {
+ die("fetch after mutation mismatch: $oldIndex/$newIndex\n");
+ }
+ if (!$cache->delete('value') || $cache->has('value')) {
+ die("delete failed: $oldIndex/$newIndex\n");
+ }
+ }
+}
+echo "transitions OK\n";
+
+$strings = [];
+foreach ([255, 256, 4095, 4096] as $length) {
+ $key = "string-$length";
+ $value = str_repeat('s', $length);
+ if (!$cache->store($key, ['old' => [1, 2, 3]])
+ || !$cache->store($key, $value) || $cache->fetch($key) !== $value) {
+ die("string replacement failed: $length\n");
+ }
+ $strings[$key] = $value;
+}
+$cache->clear();
+if (!$cache->storeMultiple($strings)) {
+ die("bulk string store failed\n");
+}
+foreach ($strings as $key => $value) {
+ if ($cache->fetch($key) !== $value) {
+ die("bulk string mismatch: $key\n");
+ }
+}
+echo "string boundaries OK\n";
+
+$cache->clear();
+$cache->store('scalar', 0);
+$scalarMemory = $cache->getPoolStatus()->getUsedMemory();
+foreach ([null, false, true, 0, 65536, PHP_INT_MIN, PHP_INT_MAX, 1.25, -0.0, INF, NAN] as $value) {
+ $cache->store('scalar', $value);
+ if ($cache->getPoolStatus()->getUsedMemory() !== $scalarMemory) {
+ die("scalar memory depends on its bits\n");
+ }
+}
+$cache->clear();
+var_dump($cache->getPoolStatus()->getUsedMemory());
+echo "accounting OK\n";
+?>
+--EXPECT--
+int(19999)
+int(1024)
+int(4999)
+transitions OK
+string boundaries OK
+int(0)
+accounting OK
diff --git a/ext/user_cache/tests/user_cache_eviction.phpt b/ext/user_cache/tests/user_cache_eviction.phpt
new file mode 100644
index 000000000000..6ca76e917ce8
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_eviction.phpt
@@ -0,0 +1,150 @@
+--TEST--
+UserCache\Cache: LRU eviction keeps hot and locked entries and absorbs size-class and slot pressure without clearing
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+user_cache.entries_hint=2048
+--FILE--
+clear();
+$base = UserCache\Cache::getStatus();
+$blob = str_repeat('x', 8192);
+
+$stored = 0;
+for ($i = 0; $i < 476; $i++) {
+ if (!$cache->store('k' . $i, $blob . $i)) {
+ break;
+ }
+ $stored++;
+}
+var_dump($stored > 400);
+var_dump($cache->lock('k3'));
+
+sleep(1);
+$cache->store('clock', 'tick', 60);
+
+for ($i = 10; $i < 20; $i++) {
+ $cache->fetch('k' . $i);
+}
+
+$pressured_ok = 0;
+for ($i = 0; $i < 30; $i++) {
+ if ($cache->store('new' . $i, $blob . 'n' . $i)) {
+ $pressured_ok++;
+ }
+}
+var_dump($pressured_ok === 30);
+
+$touched_alive = 0;
+for ($i = 10; $i < 20; $i++) {
+ if ($cache->fetch('k' . $i) !== null) {
+ $touched_alive++;
+ }
+}
+$new_alive = 0;
+for ($i = 0; $i < 30; $i++) {
+ if ($cache->has('new' . $i)) {
+ $new_alive++;
+ }
+}
+
+$status = UserCache\Cache::getStatus();
+var_dump($touched_alive === 10);
+var_dump($new_alive === 30);
+var_dump($status->getEntryCount() > 400);
+var_dump($status->getEvictionCount() > $base->getEvictionCount());
+var_dump($status->getExpungeCount() === $base->getExpungeCount());
+var_dump($status->getStoreFailureCount() === $base->getStoreFailureCount());
+
+for ($i = 0; $i < 600; $i++) {
+ $cache->store('churn' . $i, $blob . 'c' . $i);
+}
+
+var_dump($cache->fetch('k3') !== null);
+var_dump($cache->fetch('k2', 'MISS') === 'MISS');
+var_dump($cache->fetch('k4', 'MISS') === 'MISS');
+var_dump(UserCache\Cache::getStatus()->getExpungeCount() === $base->getExpungeCount());
+
+var_dump($cache->unlock('k3'));
+$cache->clear();
+
+/* A value one size class above the resident 8320-byte blocks evicts about one neighbour each. */
+$cache = UserCache\Cache::getPool('eviction-straddle');
+$cache->clear();
+$blob = str_repeat('x', 8196);
+
+$start = start_on_fresh_second();
+for ($i = 0; $i < 476; $i++) {
+ $cache->store('k' . $i, $blob . $i);
+}
+
+$base = UserCache\Cache::getStatus();
+
+for ($i = 100; $i < 700; $i++) {
+ if (!$cache->store('wide' . $i, $blob . 'n' . $i)) {
+ exit("store failed at $i\n");
+ }
+}
+
+/* Neighbours stamped in a later second are refused, so extra victims or a clear may follow. */
+$split = crossed_second($start);
+
+$status = UserCache\Cache::getStatus();
+$evictions = $status->getEvictionCount() - $base->getEvictionCount();
+
+var_dump($split || $evictions <= 600 + 600 / 50);
+var_dump($split || $status->getWastedMemory() < 64 * 1024);
+var_dump($split || $status->getExpungeCount() === $base->getExpungeCount());
+var_dump($status->getStoreFailureCount() === $base->getStoreFailureCount());
+var_dump($cache->fetch('wide699') === $blob . 'n699');
+$cache->clear();
+
+/* A full entry table combined with full memory evicts instead of clearing. */
+$randomizer = new Random\Randomizer(new Random\Engine\Xoshiro256StarStar(7));
+$cache = UserCache\Cache::getPool('eviction-slots');
+$cache->clear();
+$base = UserCache\Cache::getStatus();
+$sizes = [100, 300, 700, 1100, 1500, 2100, 3000, 3900];
+
+for ($i = 0; $i < 6000; $i++) {
+ if (!$cache->store('m' . $i, str_repeat('x', $sizes[$randomizer->getInt(0, 7)] + $randomizer->getInt(0, 300)))) {
+ exit("store failed at $i\n");
+ }
+}
+
+$status = UserCache\Cache::getStatus();
+var_dump($status->getEvictionCount() > $base->getEvictionCount());
+var_dump($status->getExpungeCount() === $base->getExpungeCount());
+var_dump($status->getStoreFailureCount() === $base->getStoreFailureCount());
+var_dump($status->getEntryCount() > 1000);
+$cache->clear();
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_eviction_policies.phpt b/ext/user_cache/tests/user_cache_eviction_policies.phpt
new file mode 100644
index 000000000000..97f326a144e3
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_eviction_policies.phpt
@@ -0,0 +1,54 @@
+--TEST--
+UserCache\Cache: user_cache.eviction_policy selects lru, clear or none behavior
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+--FILE--
+store('k' . $i, $blob . $i)) {
+ $ok++;
+ }
+}
+/* Every entry that survived eviction or wiping still holds the value stored under its key. */
+$keys = $cache->getPoolStatus()->getEntryKeys();
+$intact = $keys !== [];
+foreach ($keys as $key) {
+ if ($cache->fetch($key) !== $blob . substr($key, 1)) {
+ $intact = false;
+ }
+}
+$status = UserCache\Cache::getStatus();
+printf(
+ "stored=%d full=%d evicted=%d wiped=%d failed=%d intact=%d\n",
+ $ok,
+ (int) ($status->getEntryCount() > 400),
+ (int) ($status->getEvictionCount() > 0),
+ (int) ($status->getExpungeCount() > 0),
+ (int) ($status->getStoreFailureCount() > 0),
+ (int) $intact
+);
+CODE;
+
+foreach (['lru', 'clear', 'none'] as $policy) {
+ $process = proc_open([$php, ...$args, '-d', "user_cache.eviction_policy=$policy", '-r', $code], [1 => ['pipe', 'w']], $pipes);
+ echo $policy, ': ', stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ proc_close($process);
+}
+?>
+--EXPECTF--
+lru: stored=600 full=1 evicted=1 wiped=0 failed=0 intact=1
+clear: stored=600 full=0 evicted=0 wiped=1 failed=0 intact=1
+none: stored=%d full=1 evicted=0 wiped=0 failed=1 intact=1
diff --git a/ext/user_cache/tests/user_cache_fcgi_tester.inc b/ext/user_cache/tests/user_cache_fcgi_tester.inc
new file mode 100644
index 000000000000..9d9d8c1aa0e8
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_fcgi_tester.inc
@@ -0,0 +1,300 @@
+root = sys_get_temp_dir() . '/php-user-cache-fcgi-' . $name . '-' . getmypid();
+ self::rmRf($this->root);
+ mkdir($this->root, 0777, true);
+ }
+
+ /* Returns the php-cgi binary path, or null when none is available. */
+ public static function binary(): ?string
+ {
+ $root = dirname(__DIR__, 3);
+ foreach ([getenv('TEST_PHP_CGI_EXECUTABLE') ?: null, $root . '/sapi/cgi/php-cgi'] as $candidate) {
+ if ($candidate !== null && is_file($candidate) && is_executable($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+
+ /* Reserves and returns a free loopback TCP port. */
+ public static function freePort(): int
+ {
+ $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+ if ($server === false) {
+ throw new RuntimeException($errstr);
+ }
+
+ $name = stream_socket_get_name($server, false);
+ fclose($server);
+
+ return (int) substr(strrchr($name, ':'), 1);
+ }
+
+ /* Returns an absolute path below the temp root. */
+ public function path(string $relative): string
+ {
+ return $this->root . '/' . $relative;
+ }
+
+ /* Creates a document root holding index.php with the given source and returns its path. */
+ public function docRoot(string $name, string $script): string
+ {
+ $dir = $this->path($name);
+ mkdir($dir, 0777, true);
+ file_put_contents($dir . '/index.php', $script);
+
+ return $dir;
+ }
+
+ /* Writes php.ini from $ini lines, starts php-cgi -b and waits until the given request is served. */
+ public function start(array $ini, string $script, string $docRoot, string $serverName, string $query): void
+ {
+ $binary = self::binary() ?? throw new RuntimeException('CGI SAPI binary not available');
+ if (!preg_grep('/^user_cache\.lockfile_path=/', $ini)) {
+ $ini[] = 'user_cache.lockfile_path=' . $this->root;
+ }
+ file_put_contents($this->path('php.ini'), implode("\n", $ini));
+ $this->port = self::freePort();
+
+ $this->process = proc_open(
+ [$binary, '-c', $this->root, '-b', "127.0.0.1:{$this->port}"],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $this->pipes,
+ $this->root
+ );
+ if (!is_resource($this->process)) {
+ throw new RuntimeException('Unable to start php-cgi');
+ }
+ stream_set_blocking($this->pipes[1], false);
+ stream_set_blocking($this->pipes[2], false);
+
+ for ($i = 0; $i < 50; $i++) {
+ if (!proc_get_status($this->process)['running']) {
+ throw new RuntimeException($this->stderr());
+ }
+
+ try {
+ $this->request($script, $docRoot, $serverName, $query);
+ return;
+ } catch (Throwable) {
+ usleep(100000);
+ }
+ }
+
+ throw new RuntimeException($this->stderr() ?: 'php-cgi did not become ready');
+ }
+
+ /* Sends one GET request and returns the response body; HTTP_HOST defaults to SERVER_NAME. */
+ public function request(string $script, string $docRoot, string $serverName, string $query, ?string $httpHost = null): string
+ {
+ $fp = @stream_socket_client("tcp://127.0.0.1:{$this->port}", $errno, $errstr, 2);
+ if ($fp === false) {
+ throw new RuntimeException($errstr);
+ }
+ stream_set_timeout($fp, 5);
+
+ $params = [
+ 'SCRIPT_FILENAME' => $script,
+ 'SCRIPT_NAME' => '/index.php',
+ 'QUERY_STRING' => $query,
+ 'REQUEST_METHOD' => 'GET',
+ 'SERVER_NAME' => $serverName,
+ 'HTTP_HOST' => $httpHost ?? $serverName,
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'REQUEST_URI' => '/index.php' . ($query !== '' ? '?' . $query : ''),
+ 'SERVER_PROTOCOL' => 'HTTP/1.1',
+ 'REMOTE_ADDR' => '127.0.0.1',
+ 'REDIRECT_STATUS' => '1',
+ ];
+
+ fwrite($fp, self::record(self::BEGIN_REQUEST, pack('nC6', self::RESPONDER, 0, 0, 0, 0, 0, 0)));
+ fwrite($fp, self::record(self::PARAMS, self::params($params)));
+ fwrite($fp, self::record(self::PARAMS, ''));
+ fwrite($fp, self::record(self::STDIN, ''));
+
+ $stdout = '';
+ $stderr = '';
+ $ended = false;
+
+ while (!feof($fp)) {
+ $header = self::readExact($fp, 8);
+ $type = ord($header[1]);
+ $contentLength = unpack('n', substr($header, 4, 2))[1];
+ $paddingLength = ord($header[6]);
+ $content = $contentLength > 0 ? self::readExact($fp, $contentLength) : '';
+ if ($paddingLength > 0) {
+ self::readExact($fp, $paddingLength);
+ }
+
+ if ($type === self::STDOUT) {
+ $stdout .= $content;
+ } elseif ($type === self::STDERR) {
+ $stderr .= $content;
+ } elseif ($type === self::END_REQUEST) {
+ $ended = true;
+ break;
+ }
+ }
+
+ fclose($fp);
+
+ if (!$ended) {
+ throw new RuntimeException('FastCGI connection closed before the end of the request');
+ }
+
+ if ($stderr !== '') {
+ throw new RuntimeException($stderr);
+ }
+
+ $parts = preg_split("/\r?\n\r?\n/", $stdout, 2);
+
+ return trim($parts[1] ?? $stdout);
+ }
+
+ /* Throws unless the response body equals $expected, appending php-cgi stderr to the message. */
+ public function expect(string $expected, string $script, string $docRoot, string $serverName, string $query, ?string $httpHost = null): void
+ {
+ $actual = $this->request($script, $docRoot, $serverName, $query, $httpHost);
+ if ($actual !== $expected) {
+ $stderr = $this->stderr();
+ throw new RuntimeException("Expected $expected, got $actual" . ($stderr !== '' ? "\n$stderr" : ''));
+ }
+ }
+
+ /* Returns what php-cgi has written to stderr so far. */
+ public function stderr(): string
+ {
+ return isset($this->pipes[2]) ? (string) stream_get_contents($this->pipes[2]) : '';
+ }
+
+ /* Terminates php-cgi. */
+ public function stop(): void
+ {
+ if (is_resource($this->process)) {
+ proc_terminate($this->process);
+ proc_close($this->process);
+ }
+ $this->process = null;
+ $this->pipes = [];
+ }
+
+ /* Stops php-cgi and removes the temp root and the boundary segments created below it. */
+ public function cleanup(): void
+ {
+ $this->stop();
+ self::removeBoundarySegments($this->root);
+ self::rmRf($this->root);
+ }
+
+ /* Boundary segments outlive their processes; their lock files name them. */
+ public static function removeBoundarySegments(string $dir): void
+ {
+ $locks = array_merge(
+ glob($dir . '/.PhpUserCacheBnd.*/*.lock') ?: [],
+ glob($dir . '/*/.PhpUserCacheBnd.*/*.lock') ?: []
+ );
+ foreach ($locks as $lock) {
+ $segment = '/dev/shm/PUC.' . basename($lock, '.lock');
+ if (is_file($segment)) {
+ unlink($segment);
+ }
+ }
+ }
+
+ private static function record(int $type, string $content, int $requestId = 1): string
+ {
+ $padding = (8 - (strlen($content) % 8)) % 8;
+
+ return chr(1)
+ . chr($type)
+ . pack('n', $requestId)
+ . pack('n', strlen($content))
+ . chr($padding)
+ . "\0"
+ . $content
+ . str_repeat("\0", $padding);
+ }
+
+ private static function nameValue(string $name, string $value): string
+ {
+ $nameLen = strlen($name);
+ $valueLen = strlen($value);
+
+ return ($nameLen < 128 ? chr($nameLen) : pack('N', $nameLen | 0x80000000))
+ . ($valueLen < 128 ? chr($valueLen) : pack('N', $valueLen | 0x80000000))
+ . $name
+ . $value;
+ }
+
+ private static function params(array $params): string
+ {
+ $body = '';
+ foreach ($params as $name => $value) {
+ $body .= self::nameValue($name, $value);
+ }
+
+ return $body;
+ }
+
+ private static function readExact($fp, int $length): string
+ {
+ $buffer = '';
+ while (strlen($buffer) < $length && !feof($fp)) {
+ $chunk = fread($fp, $length - strlen($buffer));
+ if ($chunk === false) {
+ throw new RuntimeException('Failed to read FastCGI response');
+ }
+ if ($chunk === '') {
+ if (stream_get_meta_data($fp)['timed_out']) {
+ throw new RuntimeException('Timed out reading FastCGI response');
+ }
+ usleep(10000);
+ continue;
+ }
+ $buffer .= $chunk;
+ }
+
+ if (strlen($buffer) !== $length) {
+ throw new RuntimeException('Truncated FastCGI response');
+ }
+
+ return $buffer;
+ }
+
+ private static function rmRf(string $path): void
+ {
+ if (!file_exists($path)) {
+ return;
+ }
+ if (!is_dir($path) || is_link($path)) {
+ unlink($path);
+ return;
+ }
+ foreach (scandir($path) as $entry) {
+ if ($entry !== '.' && $entry !== '..') {
+ self::rmRf($path . DIRECTORY_SEPARATOR . $entry);
+ }
+ }
+ rmdir($path);
+ }
+}
diff --git a/ext/user_cache/tests/user_cache_fork_pins.phpt b/ext/user_cache/tests/user_cache_fork_pins.phpt
new file mode 100644
index 000000000000..1c847c795fde
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_fork_pins.phpt
@@ -0,0 +1,126 @@
+--TEST--
+Values fetched before pcntl_fork() stay pinned in the child after the parent releases them
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+--FILE--
+ str_repeat('A', 300), 'list' => range(1, 64), 'nested' => ['k' => str_repeat('B', 100)]];
+$result = tempnam(sys_get_temp_dir(), 'ucache_fork_pins');
+
+function same(array $cfg, array $expected): bool
+{
+ return $cfg['name'] === $expected['name'] && $cfg['list'] === $expected['list'] && $cfg['nested']['k'] === $expected['nested']['k'];
+}
+
+function waitFor(int $pid): string
+{
+ pcntl_waitpid($pid, $status);
+ if (pcntl_wifsignaled($status)) {
+ return 'signal ' . pcntl_wtermsig($status);
+ }
+
+ return 'exit ' . pcntl_wexitstatus($status);
+}
+
+echo "retained in child while the parent request is alive\n";
+var_dump($pool->store('cfg', $expected));
+$cfg = $pool->fetch('cfg');
+$pinned = UserCache\Cache::getStatus()->getGraphPinnedReferences();
+var_dump($pinned);
+
+$child = pcntl_fork();
+if ($child === 0) {
+ $after = UserCache\Cache::getStatus()->getGraphPinnedReferences();
+ $ok = $after === $pinned + 1;
+ for ($i = 0; $i < 50 && $ok; $i++) {
+ $pool->store('cfg', ['name' => str_repeat('C', 310 + $i), 'list' => range(1, 60), 'nested' => ['k' => 'x']]);
+ $pool->store('filler' . ($i % 8), str_repeat('F', 500 + $i));
+ $ok = same($cfg, $expected);
+ }
+ exit($ok ? 0 : 1);
+}
+echo "child ", waitFor($child), "\n";
+var_dump(same($cfg, $expected));
+var_dump($pool->store('cfg', $expected));
+
+echo "retained by children forked back to back\n";
+$children = [];
+for ($i = 0; $i < 16; $i++) {
+ $child = pcntl_fork();
+ if ($child === 0) {
+ usleep(20000);
+ $ok = true;
+ for ($j = 0; $j < 20 && $ok; $j++) {
+ $ok = same($cfg, $expected);
+ usleep(1000);
+ }
+ exit($ok ? 0 : 1);
+ }
+ $children[] = $child;
+}
+for ($i = 0; $i < 400; $i++) {
+ $pool->store('cfg', ['name' => str_repeat('D', 300 + $i % 9), 'list' => range(1, 64), 'nested' => ['k' => 'y']]);
+ $pool->store('filler' . ($i % 8), str_repeat('G', 400 + $i % 50));
+}
+$exits = [];
+foreach ($children as $child) {
+ $exits[] = waitFor($child);
+}
+var_dump(array_unique($exits));
+var_dump($pool->store('cfg', $expected));
+
+echo "retained in the grandchild after the parent request ended\n";
+$parent = pcntl_fork();
+if ($parent === 0) {
+ $cfg = $pool->fetch('cfg');
+ $grandchild = pcntl_fork();
+ if ($grandchild === 0) {
+ $ok = true;
+ $until = microtime(true) + 1.5;
+ while ($ok && microtime(true) < $until) {
+ $ok = same($cfg, $expected);
+ usleep(1000);
+ }
+ file_put_contents($GLOBALS['result'], $ok ? 'consistent' : 'changed');
+ exit(0);
+ }
+ exit(0);
+}
+echo "parent ", waitFor($parent), "\n";
+for ($i = 0; $i < 3000; $i++) {
+ $pool->store('cfg', ['name' => str_repeat(chr(65 + $i % 26), 300 + $i % 7), 'list' => range(1, 64 + $i % 5), 'nested' => ['k' => str_repeat('Z', 100)]]);
+ $pool->store('filler' . ($i % 40), str_repeat('F', 300 + $i % 100));
+ if ($i % 500 === 0) {
+ $pool->delete('cfg');
+ }
+}
+for ($i = 0; $i < 200 && filesize($result) === 0; $i++) {
+ usleep(50000);
+ clearstatcache(true, $result);
+}
+echo "grandchild ", file_get_contents($result), "\n";
+unlink($result);
+
+?>
+--EXPECT--
+retained in child while the parent request is alive
+bool(true)
+int(1)
+child exit 0
+bool(true)
+bool(true)
+retained by children forked back to back
+array(1) {
+ [0]=>
+ string(6) "exit 0"
+}
+bool(true)
+retained in the grandchild after the parent request ended
+parent exit 0
+grandchild consistent
diff --git a/ext/user_cache/tests/user_cache_fork_pins_adoption_sweep.phpt b/ext/user_cache/tests/user_cache_fork_pins_adoption_sweep.phpt
new file mode 100644
index 000000000000..18d301ec6cdb
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_fork_pins_adoption_sweep.phpt
@@ -0,0 +1,146 @@
+--TEST--
+UserCache\Cache: a dead-pin sweep never mistakes a child taking over its parent's pins after pcntl_fork() for a dead owner
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+posix
+--ENV--
+USER_CACHE_DEBUG_PAUSE_IN_DEAD_PIN_STRIP=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+display_errors=0
+--FILE--
+clear();
+$expected = ['name' => str_repeat('A', 300), 'list' => range(1, 64)];
+$cache->store('cfg', $expected);
+
+function wait_exit(int $pid): string
+{
+ pcntl_waitpid($pid, $status);
+
+ return pcntl_wifsignaled($status) ? 'signal ' . pcntl_wtermsig($status) : 'exit ' . pcntl_wexitstatus($status);
+}
+
+function wait_for_file(string $path): string
+{
+ for ($i = 0; $i < 400; $i++) {
+ clearstatcache(true, $path);
+ if (filesize($path) > 0) {
+ return file_get_contents($path);
+ }
+ usleep(25000);
+ }
+
+ return 'timeout';
+}
+
+function reclaimed(): int
+{
+ return UserCache\Cache::getStatus()->getDeadPinOwnersReclaimed();
+}
+
+function in_pinning_process(callable $body): string
+{
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $body();
+ exit(0);
+ }
+
+ return wait_exit($pid);
+}
+
+echo "a sweep during the takeover leaves the child's pins alone:\n";
+$go = tempnam(sys_get_temp_dir(), 'ucache_adopt_go');
+$swept = tempnam(sys_get_temp_dir(), 'ucache_adopt_swept');
+$result = tempnam(sys_get_temp_dir(), 'ucache_adopt_result');
+$report = tempnam(sys_get_temp_dir(), 'ucache_adopt_report');
+$before = reclaimed();
+
+$holder = in_pinning_process(function () use ($cache, $expected, $go, $swept, $result, $report) {
+ $cfg = $cache->fetch('cfg');
+
+ $sibling = pcntl_fork();
+ if ($sibling === 0) {
+ wait_for_file($go);
+ exit(0);
+ }
+
+ putenv('USER_CACHE_DEBUG_PAUSE_IN_FORK_ADOPTION=1');
+ $child = pcntl_fork();
+ if ($child === 0) {
+ wait_for_file($swept);
+ file_put_contents($result, $cfg === $expected ? 'kept' : 'changed');
+ exit(0);
+ }
+ putenv('USER_CACHE_DEBUG_PAUSE_IN_FORK_ADOPTION');
+
+ usleep(500000);
+ file_put_contents($go, 'go');
+ $sibling_exit = wait_exit($sibling);
+ file_put_contents($swept, 'swept');
+ file_put_contents($report, "sibling $sibling_exit, child " . wait_exit($child));
+});
+echo "holder $holder\n";
+echo file_get_contents($report), "\n";
+echo "child value ", file_get_contents($result), "\n";
+var_dump(reclaimed() - $before);
+var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences());
+
+echo "a takeover after a sweep found the parent dead fails:\n";
+$forked = tempnam(sys_get_temp_dir(), 'ucache_adopt_forked');
+file_put_contents($result, '');
+$before = reclaimed();
+
+$parent = pcntl_fork();
+if ($parent === 0) {
+ $cfg = $cache->fetch('cfg');
+ putenv('USER_CACHE_DEBUG_PAUSE_BEFORE_FORK_ADOPTION=1');
+ $grandchild = pcntl_fork();
+ if ($grandchild === 0) {
+ file_put_contents($result, error_get_last()['message'] ?? 'adopted');
+ exit(0);
+ }
+ file_put_contents($forked, 'forked');
+ sleep(30);
+ exit(0);
+}
+
+wait_for_file($forked);
+posix_kill($parent, SIGKILL);
+echo "parent ", wait_exit($parent), "\n";
+$sweeper = in_pinning_process(fn () => $cache->fetch('cfg'));
+echo "sweeper $sweeper\n";
+echo "grandchild ", wait_for_file($result), "\n";
+var_dump(reclaimed() - $before);
+$reader = in_pinning_process(function () use ($cache, $expected) {
+ exit($cache->fetch('cfg') === $expected ? 0 : 1);
+});
+echo "reader $reader\n";
+var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences());
+
+foreach ([$go, $swept, $result, $report, $forked] as $file) {
+ unlink($file);
+}
+?>
+--EXPECT--
+a sweep during the takeover leaves the child's pins alone:
+holder exit 0
+sibling exit 0, child exit 0
+child value kept
+int(0)
+int(0)
+a takeover after a sweep found the parent dead fails:
+parent signal 9
+sweeper exit 0
+grandchild UserCache: values fetched before fork() could not be retained in the child process; the cache is disabled for the rest of this request
+int(2)
+reader exit 0
+int(0)
diff --git a/ext/user_cache/tests/user_cache_fork_pins_no_slot.phpt b/ext/user_cache/tests/user_cache_fork_pins_no_slot.phpt
new file mode 100644
index 000000000000..bdb9d7306be9
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_fork_pins_no_slot.phpt
@@ -0,0 +1,104 @@
+--TEST--
+A child that cannot take over the parent's pins after pcntl_fork() is warned and loses the cache for the rest of its request
+--EXTENSIONS--
+pcntl
+posix
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+ str_repeat('A', 300), 'list' => range(1, 64)];
+var_dump($pool->store('cfg', $expected));
+var_dump($pool->store('holder', ['held' => range(1, 8)]));
+
+$parent = pcntl_fork();
+if ($parent === 0) {
+ $cfg = $pool->fetch('cfg');
+ var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences());
+
+ echo "every other pin slot is taken by a live process\n";
+ $children = [];
+ $ready = '';
+ for ($i = 0; $i < HOLDERS; $i++) {
+ [$ready_read, $ready_write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ fclose($ready_read);
+ $held = UserCache\Cache::getPool('fork-no-slot')->fetch('holder');
+ fwrite($ready_write, $held['held'] === range(1, 8) ? 'y' : 'n');
+ fclose($ready_write);
+ pcntl_exec('/bin/sleep', ['60']);
+ exit(1);
+ }
+ fclose($ready_write);
+ $ready .= fread($ready_read, 1);
+ fclose($ready_read);
+ $children[] = $pid;
+ }
+ var_dump($ready === str_repeat('y', HOLDERS));
+ var_dump(UserCache\Cache::getStatus()->getGraphPinSlotsInUse());
+
+ echo "child without a pin slot\n";
+ $child = pcntl_fork();
+ if ($child === 0) {
+ var_dump($pool->fetch('cfg'));
+ var_dump(UserCache\Cache::getStatus()->getAvailability());
+ var_dump($pool->store('from-child', 1));
+ exit(0);
+ }
+ pcntl_waitpid($child, $status);
+ echo "child exit ", pcntl_wexitstatus($status), "\n";
+
+ echo "the parent keeps its pins\n";
+ var_dump($cfg === $expected);
+ var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences() === 1 + 2 * HOLDERS);
+ var_dump($pool->has('from-child'));
+
+ foreach ($children as $pid) {
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $status);
+ }
+
+ exit(0);
+}
+pcntl_waitpid($parent, $status);
+echo "parent exit ", pcntl_wexitstatus($status), "\n";
+var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences(), UserCache\Cache::getStatus()->getGraphPinSlotsInUse());
+
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+int(1)
+every other pin slot is taken by a live process
+bool(true)
+int(256)
+child without a pin slot
+
+Warning: UserCache: values fetched before fork() could not be retained in the child process; the cache is disabled for the rest of this request in %s on line %d
+NULL
+enum(UserCache\CacheAvailability::UnavailableByUnknownReason)
+bool(false)
+child exit 0
+the parent keeps its pins
+bool(true)
+bool(true)
+bool(false)
+parent exit 0
+int(0)
+int(0)
diff --git a/ext/user_cache/tests/user_cache_free_memory_accounting.phpt b/ext/user_cache/tests/user_cache_free_memory_accounting.phpt
new file mode 100644
index 000000000000..e0eb0fd33562
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_free_memory_accounting.phpt
@@ -0,0 +1,100 @@
+--TEST--
+UserCache: free-memory accounting follows fragmentation, splitting, coalescing and bulk rollback
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.entries_hint=127
+user_cache.eviction_policy=none
+--FILE--
+getUsedMemory() + $status->getFreeMemory() !== $status->getSharedMemorySize()
+ || $status->getWastedMemory() > $status->getFreeMemory()) {
+ throw new RuntimeException('Inconsistent memory totals');
+ }
+ return [$status->getUsedMemory(), $status->getFreeMemory(), $status->getWastedMemory()];
+}
+
+function check(bool $condition, string $message): void
+{
+ if (!$condition) {
+ throw new RuntimeException($message);
+ }
+}
+
+$cache = UserCache\Cache::getPool('free-memory-accounting');
+$cache->clear();
+$empty = memoryState();
+$sizes = [];
+foreach (['a', 'b', 'c', 'd', 'e'] as $key) {
+ $before = memoryState();
+ check($cache->store($key, str_repeat($key, 16 * 1024)), 'Initial store failed');
+ $after = memoryState();
+ $sizes[$key] = $after[0] - $before[0];
+}
+check(memoryState()[2] === 0, 'Tail allocation created a free-list block');
+
+/* Separate holes become one block when their live neighbour is deleted. */
+$cache->delete('b');
+$cache->delete('d');
+check(memoryState()[2] === $sizes['b'] + $sizes['d'], 'Separate holes miscounted');
+$cache->delete('c');
+$fragmented = memoryState();
+check($fragmented[2] === $sizes['b'] + $sizes['c'] + $sizes['d'], 'Coalescing miscounted');
+echo "fragmentation and coalescing OK\n";
+
+/* Both values fit individually, but the second allocation fails after the
+ * first commit. Rolling back must restore the old holes and unused tail. */
+check(!$cache->storeMultiple([
+ 'rollback-first' => str_repeat('x', 400 * 1024),
+ 'rollback-second' => str_repeat('y', 700 * 1024),
+]), 'Oversized batch unexpectedly succeeded');
+check(!$cache->has('rollback-first') && !$cache->has('rollback-second'), 'Rollback left an entry');
+check(memoryState() === $fragmented, 'Rollback changed free-memory accounting');
+echo "rollback OK\n";
+
+/* A smaller allocation splits the hole, and deleting it joins it again. */
+check($cache->store('f', str_repeat('f', 512)), 'Hole reuse failed');
+$reused = memoryState();
+$allocated = $reused[0] - $fragmented[0];
+check($allocated > 0 && $reused[2] === $fragmented[2] - $allocated, 'Split remainder miscounted');
+$cache->delete('f');
+check(memoryState() === $fragmented, 'Freeing the split block changed the totals');
+
+/* Freeing the tail absorbs the preceding hole: it is no longer wasted memory. */
+$cache->delete('e');
+check(memoryState()[2] === 0, 'Tail coalescing left free-list bytes');
+$cache->delete('a');
+check(memoryState() === $empty, 'Freeing all blocks did not restore the empty state');
+echo "split, reuse and tail reclamation OK\n";
+
+/* Explicit key-lock storage uses the same allocator and must balance too. */
+check($cache->lock('first') && $cache->lock('second'), 'Lock allocation failed');
+check($cache->unlock('first'), 'First lock release failed');
+check(memoryState()[2] > 0, 'Lock release did not account for the interior hole');
+check($cache->unlock('second'), 'Second lock release failed');
+check(memoryState() === $empty, 'Lock storage was not fully reclaimed');
+echo "lock allocations OK\n";
+
+/* Clearing many differently sized blocks visits them in hash-table order. */
+for ($i = 0; $i < 48; $i++) {
+ $key = 'mixed-' . $i;
+ check($cache->store($key, $i % 3 === 0 ? $i : str_repeat('m', 100 + $i * 79)), 'Mixed store failed');
+}
+for ($i = 0; $i < 48; $i += 2) {
+ $cache->delete('mixed-' . $i);
+ memoryState();
+}
+check($cache->clear(), 'Clear failed');
+check(memoryState() === $empty, 'Clear did not reclaim fragmented allocations');
+echo "fragmented clear OK\n";
+?>
+--EXPECT--
+fragmentation and coalescing OK
+rollback OK
+split, reuse and tail reclamation OK
+lock allocations OK
+fragmented clear OK
diff --git a/ext/user_cache/tests/user_cache_full_segment_expired_reclaim.phpt b/ext/user_cache/tests/user_cache_full_segment_expired_reclaim.phpt
new file mode 100644
index 000000000000..71d01ae76461
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_full_segment_expired_reclaim.phpt
@@ -0,0 +1,139 @@
+--TEST--
+UserCache\Cache: a full segment without eviction reclaims expired entries once the earliest deadline is due, also for a shorter TTL stored after a sweep, and lock() reclaims key space held by expired lock records
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.eviction_policy=none
+--FILE--
+store(sprintf('%s-%04d', $prefix, $stored), $value, $ttl)) {
+ $stored++;
+ }
+
+ return $stored;
+}
+
+function ttl_expiry_floor(): void
+{
+ $cache = UserCache\Cache::getPool('expiry-floor');
+ $value = str_repeat('v', 2000);
+
+ /* Short TTLs first, then long TTLs until the segment is full: nothing has expired yet, so the store fails */
+ for ($i = 0; $i < 10; $i++) {
+ $cache->store(sprintf('shrt-%04d', $i), $value, 1);
+ }
+ $long = fill($cache, 'long', $value, 3600);
+ var_dump($long > 100);
+
+ sleep(2);
+
+ /* The short TTLs are due: a store under pressure sweeps them and the sweep records the long deadlines */
+ $after = 0;
+ for ($i = 0; $i < 10; $i++) {
+ $after += $cache->store(sprintf('aft1-%04d', $i), $value, 3600) ? 1 : 0;
+ }
+ var_dump($after, $cache->has('shrt-0000'), $cache->has('long-0000'));
+
+ /* A shorter TTL stored after that sweep lowers the recorded deadline again */
+ for ($i = 0; $i < 10; $i++) {
+ $cache->delete(sprintf('long-%04d', $i));
+ }
+ $short = 0;
+ for ($i = 0; $i < 10; $i++) {
+ $short += $cache->store(sprintf('shr2-%04d', $i), $value, 1) ? 1 : 0;
+ }
+ var_dump($short, fill($cache, 'xtra', $value, 3600));
+
+ sleep(2);
+
+ $after = 0;
+ for ($i = 0; $i < 10; $i++) {
+ $after += $cache->store(sprintf('aft2-%04d', $i), $value, 3600) ? 1 : 0;
+ }
+ $status = UserCache\Cache::getStatus();
+ var_dump($after, $cache->has('shr2-0000'), $cache->has('long-0010'), $status->getEvictionCount(), $status->getExpungeCount());
+}
+
+/* Low bits of the storage key's DJB hash select the lock-table slot. */
+function lock_slot(string $pool, string $key): int
+{
+ $h = 5381;
+ foreach (str_split("$pool\x1f$key") as $c) {
+ $h = ($h * 33 + ord($c)) & 127;
+ }
+
+ return $h;
+}
+
+function lock_full_segment(): void
+{
+ $cache = UserCache\Cache::getPool('lock-space');
+ $cache->clear();
+
+ /* Leased locks outlive their holder as ownerless records until they expire. */
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $ok = true;
+ for ($i = 0; $i < 8; $i++) {
+ $ok = $cache->lock("stale-$i", 1) && $ok;
+ }
+ exit($ok ? 0 : 1);
+ }
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+ /* The new key's lock-table chain must not pass the stale records. */
+ $stale = [];
+ for ($i = 0; $i < 8; $i++) {
+ $stale[lock_slot('lock-space', "stale-$i")] = true;
+ }
+ for ($i = 0; isset($stale[lock_slot('lock-space', "fresh-$i")]); $i++);
+ $fresh = "fresh-$i";
+
+ $n = 0;
+ foreach ([4000, 1000, 200, 40, 8] as $size) {
+ while ($cache->store('fill-' . $n++, str_repeat('x', $size)));
+ }
+ var_dump(UserCache\Cache::getStatus()->getFreeMemory() < 64);
+
+ sleep(2);
+ var_dump($cache->lock($fresh));
+ var_dump($cache->unlock($fresh));
+}
+
+/* The segment outlives the request (php --repeat): start from fresh pools. */
+UserCache\Cache::deletePool('expiry-floor');
+UserCache\Cache::deletePool('lock-space');
+
+echo "ttl expiry floor:\n";
+ttl_expiry_floor();
+
+echo "\nlock full segment:\n";
+UserCache\Cache::getPool('expiry-floor')->clear();
+lock_full_segment();
+?>
+--EXPECT--
+ttl expiry floor:
+bool(true)
+int(10)
+bool(false)
+bool(true)
+int(10)
+int(0)
+int(10)
+bool(false)
+bool(true)
+int(0)
+int(0)
+
+lock full segment:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_graph_array_next_free.phpt b/ext/user_cache/tests/user_cache_graph_array_next_free.phpt
new file mode 100644
index 000000000000..aa6dd9d19fbb
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_array_next_free.phpt
@@ -0,0 +1,69 @@
+--TEST--
+UserCache\Cache: empty arrays keep their next free index in verbatim, dynamic, root and __wakeup() state encodings
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+ false);
+
+$separated = $emptied;
+$separated['k'] = 1;
+unset($separated['k']);
+
+$cases = ['emptied' => $emptied, 'zero' => $zero, 'fresh' => $fresh, 'separated' => $separated, 'literal' => []];
+
+$cache = UserCache\Cache::getPool('graph-array-next-free');
+$object = new stdClass();
+$wakeup = new NextFreeWakeup();
+$wakeup->arrays = $cases;
+
+$cache->store('verbatim', $cases);
+$cache->store('dynamic', $cases + ['object' => $object]);
+$cache->store('wakeup', $wakeup);
+foreach ($cases as $name => $array) {
+ $cache->store("root:$name", $array);
+}
+
+foreach ($cases as $name => $array) {
+ $expected = probe($array);
+ $results = [
+ 'verbatim' => probe($cache->fetch('verbatim')[$name]),
+ 'dynamic' => probe($cache->fetch('dynamic')[$name]),
+ 'wakeup' => probe($cache->fetch('wakeup')->arrays[$name]),
+ 'root' => probe($cache->fetch("root:$name")),
+ ];
+ echo $name, ' ', $expected, ' ', count(array_unique($results)) === 1 && reset($results) === $expected ? 'same' : json_encode($results), "\n";
+}
+?>
+--EXPECT--
+emptied [-5,2] same
+zero [-5,0] same
+fresh [-5,-4] same
+separated [-5,2] same
+literal [-5,-4] same
diff --git a/ext/user_cache/tests/user_cache_graph_declared_properties.phpt b/ext/user_cache/tests/user_cache_graph_declared_properties.phpt
new file mode 100644
index 000000000000..be6dff866f1e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_declared_properties.phpt
@@ -0,0 +1,235 @@
+--TEST--
+UserCache\Cache: private, protected, typed, readonly and inherited private properties restore through their declared slots
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+secret = $secret;
+ $this->level = $level;
+ }
+
+ public function base(): string
+ {
+ return $this->secret . '/' . $this->level;
+ }
+}
+
+class Child extends Base
+{
+ private $secret = 'child-default';
+ private ?Child $peer = null;
+ protected array $tags = [];
+ public readonly int $id;
+ private int $ref = 0;
+ private int $alias = 0;
+
+ public function __construct(int $id)
+ {
+ $this->id = $id;
+ }
+
+ public function fill(string $secret, array $tags, ?Child $peer): void
+ {
+ $this->secret = $secret;
+ $this->tags = $tags;
+ $this->peer = $peer;
+ $this->ref = 7;
+ $this->alias = &$this->ref;
+ }
+
+ public function child(): string
+ {
+ return $this->secret . '/' . implode(',', $this->tags) . '/' . ($this->peer?->id ?? '-')
+ . '/' . $this->ref . '/' . $this->alias;
+ }
+
+ public function bumpAlias(): int
+ {
+ $this->alias++;
+
+ return $this->ref;
+ }
+}
+
+class Sleeper extends Base
+{
+ private string $name = '';
+ protected float $ratio = 0.0;
+ private $skipped = 'skip';
+
+ public function __construct(string $name, float $ratio)
+ {
+ $this->name = $name;
+ $this->ratio = $ratio;
+ $this->setBase("sleeper-$name", 3);
+ }
+
+ public function __sleep(): array
+ {
+ return ["\0Sleeper\0name", "\0*\0ratio", "\0Base\0secret", "\0*\0level"];
+ }
+
+ public function __wakeup(): void
+ {
+ echo "wakeup {$this->name}\n";
+ }
+
+ public function dump(): string
+ {
+ return $this->name . '/' . $this->ratio . '/' . $this->base() . '/' . $this->skipped;
+ }
+}
+
+class Waker
+{
+ private int $count = 0;
+ protected readonly string $label;
+
+ public function __construct(string $label, int $count)
+ {
+ $this->label = $label;
+ $this->count = $count;
+ }
+
+ public function __wakeup(): void
+ {
+ echo "wakeup {$this->label}\n";
+ }
+
+ public function dump(): string
+ {
+ return $this->label . '/' . $this->count;
+ }
+}
+
+$cache = UserCache\Cache::getPool('graph-declared-properties');
+$cache->clear();
+
+$first = new Child(1);
+$second = new Child(2);
+$first->setBase('first-base', 10);
+$first->fill('first', ['a', 'b'], $second);
+$second->fill('second', [], $first);
+var_dump($cache->store('children', [$first, $second]));
+[$a, $b] = $cache->fetch('children');
+var_dump($a->child(), $a->base(), $b->child(), $b->base());
+var_dump($a->id, $b->id, $a->child() === $first->child());
+var_dump($a->bumpAlias(), $a->child());
+try {
+ $a->id = 5;
+} catch (Error $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+var_dump(serialize($cache->fetch('children')) === serialize([$first, $second]));
+
+var_dump($cache->store('sleepers', [new Sleeper('x', 0.5), new Sleeper('y', 1.5)]));
+foreach ($cache->fetch('sleepers') as $sleeper) {
+ var_dump($sleeper->dump());
+}
+
+var_dump($cache->store('wakers', [new Waker('w1', 1), new Waker('w2', 2)]));
+foreach ($cache->fetch('wakers') as $waker) {
+ var_dump($waker->dump());
+}
+
+/* Uninitialized-property flags match native unserialize(). */
+class Lazy
+{
+ public int $pub;
+ private int $priv;
+ protected int $prot;
+
+ public function __construct()
+ {
+ $this->pub = 1;
+ $this->priv = 2;
+ $this->prot = 3;
+ }
+
+ public function __get($name)
+ {
+ return "get:$name";
+ }
+
+ public function probe(): string
+ {
+ $out = [];
+ foreach (['pub', 'priv', 'prot'] as $name) {
+ unset($this->$name);
+ try {
+ $out[] = $this->$name;
+ } catch (Error $e) {
+ $out[] = $e->getMessage();
+ }
+ }
+
+ return implode("\n", $out);
+ }
+}
+
+$lazy = new Lazy();
+$cache->store('lazy', [$lazy, $lazy]);
+var_dump($cache->fetch('lazy')[0]->probe() === unserialize(serialize($lazy))->probe());
+
+/* Release builds do not collect cycles at shutdown, and the child exits first. */
+$first = $second = $a = $b = null;
+gc_collect_cycles();
+
+/* A class whose property type changed after the store fails like unserialize(). */
+$pid = pcntl_fork();
+if ($pid === 0) {
+ eval('class Retyped { private string $value = "text"; }');
+ exit(UserCache\Cache::getPool('graph-declared-properties')->store('retyped', [new Retyped(), new Retyped()]) ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+eval('class Retyped { private int $value = 1; }');
+try {
+ var_dump($cache->fetch('retyped'));
+} catch (TypeError $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+try {
+ unserialize('O:7:"Retyped":1:{s:14:"' . "\0Retyped\0value" . '";s:4:"text";}');
+} catch (TypeError $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+bool(true)
+string(15) "first/a,b/2/7/7"
+string(13) "first-base/10"
+string(13) "second//1/7/7"
+string(14) "base-default/0"
+int(1)
+int(2)
+bool(true)
+int(8)
+string(15) "first/a,b/2/8/8"
+Error: Cannot modify readonly property Child::$id
+bool(true)
+bool(true)
+wakeup x
+wakeup y
+string(22) "x/0.5/sleeper-x/3/skip"
+string(22) "y/1.5/sleeper-y/3/skip"
+bool(true)
+wakeup w1
+wakeup w2
+string(4) "w1/1"
+string(4) "w2/2"
+bool(true)
+bool(true)
+TypeError: Cannot assign string to property Retyped::$value of type int
+TypeError: Cannot assign string to property Retyped::$value of type int
diff --git a/ext/user_cache/tests/user_cache_graph_decode_validation.phpt b/ext/user_cache/tests/user_cache_graph_decode_validation.phpt
new file mode 100644
index 000000000000..f6a2ee15ef4e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_decode_validation.phpt
@@ -0,0 +1,268 @@
+--TEST--
+UserCache\Cache: decoding bounds graph nesting, validates verbatim arrays once per request and keeps resolve caches per node kind
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=64M
+memory_limit=512M
+--FILE--
+start();
+
+ return $fiber->getReturn();
+}
+
+function nest(mixed $inner, int $depth): array
+{
+ for ($i = 0; $i < $depth; $i++) {
+ $inner = [$inner];
+ }
+
+ return $inner;
+}
+
+function nesting(mixed $val): int
+{
+ $depth = 0;
+ while (is_array($val) && $val !== []) {
+ $val = $val[0];
+ $depth++;
+ }
+
+ return $depth;
+}
+
+function chain(int $depth): stdClass
+{
+ $node = null;
+ for ($i = 0; $i < $depth; $i++) {
+ $next = new stdClass();
+ $next->depth = $i;
+ $next->next = $node;
+ $node = $next;
+ }
+
+ return $node;
+}
+
+function chain_depth(mixed $node): int
+{
+ $depth = 0;
+ while ($node instanceof stdClass) {
+ $node = $node->next ?? null;
+ $depth++;
+ }
+
+ return $depth;
+}
+
+/* Destroying a deep value recurses once per level: dismantle from the outside in. */
+function dismantle(mixed &$val): void
+{
+ while (is_array($val) && $val !== []) {
+ $val = $val[0];
+ }
+ while ($val instanceof stdClass) {
+ $val = $val->next ?? null;
+ }
+}
+
+function check_depth_limit(UserCache\Cache $cache, string $label, mixed $fits, mixed $deep, callable $measure, int $expected): void
+{
+ ok("$label stores", in_fiber(fn () => $cache->store("$label-fits", $fits) && $cache->store("$label-deep", $deep)));
+
+ $fetched = in_fiber(fn () => $cache->fetch("$label-fits"));
+ ok("$label at the limit decodes", $measure($fetched) === $expected);
+ dismantle($fetched);
+
+ $fetched = in_fiber(fn () => $cache->fetch("$label-fits"));
+ ok("$label at the limit decodes again", $measure($fetched) === $expected);
+ dismantle($fetched);
+
+ $result = in_fiber(fn () => $cache->fetch("$label-deep", 'DEFAULT'));
+ ok("$label beyond the limit yields the default or decodes without the counter", COUNTS_NESTING ? $result === 'DEFAULT' : $measure($result) === $expected + 1);
+ ok("$label beyond the limit keeps the entry", $cache->has("$label-deep"));
+ ok("$label beyond the limit is silent", error_get_last() === null);
+ ok("$label next fetch unaffected", $cache->fetch('control') === ['control' => true]);
+ dismantle($result);
+}
+
+$cache = UserCache\Cache::getPool('graph-decode-validation');
+$cache->clear();
+$cache->store('control', ['control' => true]);
+
+echo "nesting depth limit:\n";
+
+/* Verbatim (zero-copy) arrays are walked by the validator with the same limit. */
+$fits = nest([], MAX_DEPTH);
+$deep = nest([], MAX_DEPTH + 1);
+check_depth_limit($cache, 'verbatim', $fits, $deep, 'nesting', MAX_DEPTH);
+dismantle($fits);
+dismantle($deep);
+
+/* An object at the bottom makes the arrays dynamic nodes; the object itself is one level. */
+$fits = nest(new stdClass(), MAX_DEPTH - 1);
+$deep = nest(new stdClass(), MAX_DEPTH);
+check_depth_limit($cache, 'dynamic', $fits, $deep, 'nesting', MAX_DEPTH - 1);
+dismantle($fits);
+dismantle($deep);
+
+$fits = chain(MAX_DEPTH);
+$deep = chain(MAX_DEPTH + 1);
+check_depth_limit($cache, 'objects', $fits, $deep, 'chain_depth', MAX_DEPTH);
+dismantle($fits);
+dismantle($deep);
+
+echo "resolve cache kinds:\n";
+
+enum Suit: string
+{
+ case Hearts = 'H';
+ case Spades = 'S';
+}
+
+class Wakes
+{
+ public int $n = 1;
+ public bool $woke = false;
+
+ public function __wakeup(): void
+ {
+ $this->woke = true;
+ }
+}
+
+class Sleeps
+{
+ public int $a = 1;
+ public int $b = 2;
+
+ public function __sleep(): array
+ {
+ return ['a'];
+ }
+}
+
+class Plain
+{
+ public int $n = 3;
+}
+
+$kinds = [
+ 's' => Suit::Hearts,
+ 'w' => new Wakes(),
+ 'p' => new Sleeps(),
+ 'plain' => new Plain(),
+ 't' => Suit::Spades,
+ 'leaf' => [1, 2],
+ 'again' => [Suit::Hearts, new Wakes(), new Sleeps()],
+];
+ok('kinds store', $cache->store('kinds', $kinds));
+for ($i = 0; $i < 3; $i++) {
+ $v = $cache->fetch('kinds');
+ ok("kinds fetch $i", $v['s'] === Suit::Hearts && $v['t'] === Suit::Spades
+ && $v['w'] instanceof Wakes && $v['w']->woke && $v['w']->n === 1
+ && $v['p'] instanceof Sleeps && $v['p']->a === 1
+ && $v['plain'] instanceof Plain && $v['plain']->n === 3
+ && $v['leaf'] === [1, 2]
+ && $v['again'][0] === Suit::Hearts && $v['again'][1]->woke && $v['again'][2]->a === 1);
+}
+
+echo "verbatim arrays:\n";
+
+$leaf = ['x' => 1.5, 'y' => "str\0ing", 'z' => [true, null, false], 'e' => []];
+$holes = [1, 2, 3, 4];
+unset($holes[1], $holes[2]);
+$packedHoles = range(0, 15);
+unset($packedHoles[3]);
+$matrix = [
+ 'packed' => [1, 2, 3, 'four', 5.0, null, true, false, []],
+ 'hash' => ['a' => 1, 'b' => 'two', 7 => 'seven', -3 => 'minus', '' => 'empty key', 'nested' => ['k' => 'v']],
+ 'shared' => [$leaf, $leaf, ['inner' => $leaf, 'twice' => $leaf]],
+ 'holes' => $holes,
+ 'packed holes' => $packedHoles,
+ 'large' => range(1, 100000),
+ 'strings' => array_fill(0, 64, str_repeat('s', 100)),
+];
+foreach ($matrix as $label => $value) {
+ ok("$label store", $cache->store($label, $value));
+ ok("$label first fetch", $cache->fetch($label) === $value);
+ ok("$label second fetch", $cache->fetch($label) === $value);
+}
+ok('matrix store', $cache->store('matrix', $matrix));
+ok('matrix first fetch', $cache->fetch('matrix') === $matrix);
+ok('matrix second fetch', $cache->fetch('matrix') === $matrix);
+ok('matrix multiple', $cache->fetchMultiple(['matrix', 'control']) === ['matrix' => $matrix, 'control' => ['control' => true]]);
+
+/* Destroying a deep value recurses once per level: release the cached prototypes on a large fiber stack. */
+in_fiber(fn () => $cache->clear());
+?>
+--EXPECT--
+nesting depth limit:
+verbatim stores: OK
+verbatim at the limit decodes: OK
+verbatim at the limit decodes again: OK
+verbatim beyond the limit yields the default or decodes without the counter: OK
+verbatim beyond the limit keeps the entry: OK
+verbatim beyond the limit is silent: OK
+verbatim next fetch unaffected: OK
+dynamic stores: OK
+dynamic at the limit decodes: OK
+dynamic at the limit decodes again: OK
+dynamic beyond the limit yields the default or decodes without the counter: OK
+dynamic beyond the limit keeps the entry: OK
+dynamic beyond the limit is silent: OK
+dynamic next fetch unaffected: OK
+objects stores: OK
+objects at the limit decodes: OK
+objects at the limit decodes again: OK
+objects beyond the limit yields the default or decodes without the counter: OK
+objects beyond the limit keeps the entry: OK
+objects beyond the limit is silent: OK
+objects next fetch unaffected: OK
+resolve cache kinds:
+kinds store: OK
+kinds fetch 0: OK
+kinds fetch 1: OK
+kinds fetch 2: OK
+verbatim arrays:
+packed store: OK
+packed first fetch: OK
+packed second fetch: OK
+hash store: OK
+hash first fetch: OK
+hash second fetch: OK
+shared store: OK
+shared first fetch: OK
+shared second fetch: OK
+holes store: OK
+holes first fetch: OK
+holes second fetch: OK
+packed holes store: OK
+packed holes first fetch: OK
+packed holes second fetch: OK
+large store: OK
+large first fetch: OK
+large second fetch: OK
+strings store: OK
+strings first fetch: OK
+strings second fetch: OK
+matrix store: OK
+matrix first fetch: OK
+matrix second fetch: OK
+matrix multiple: OK
diff --git a/ext/user_cache/tests/user_cache_graph_decode_verbatim_fault.phpt b/ext/user_cache/tests/user_cache_graph_decode_verbatim_fault.phpt
new file mode 100644
index 000000000000..60bf444c159b
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_decode_verbatim_fault.phpt
@@ -0,0 +1,52 @@
+--TEST--
+UserCache\Cache: a verbatim array that fails structural validation makes the fetch unrestorable while other payloads keep decoding
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_VERBATIM_ARR_INVALID=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+ok('stores', $cache->store('root', [1, 2, 3])
+ && $cache->store('nested', ['o' => new stdClass(), 'leaf' => [1, 2]])
+ && $cache->store('objects', ['o' => new stdClass(), 'n' => 7])
+ && $cache->store('string', 'text'));
+
+ok('verbatim root yields the default', $cache->fetch('root', 'DEFAULT') === 'DEFAULT');
+ok('verbatim root is unrestorable', !$cache->has('root'));
+ok('nested verbatim leaf yields the default', $cache->fetch('nested', 'DEFAULT') === 'DEFAULT');
+ok('nested verbatim leaf is unrestorable', !$cache->has('nested'));
+ok('no error is raised', error_get_last() === null);
+
+$objects = $cache->fetch('objects');
+ok('object graph still decodes', $objects['o'] instanceof stdClass && $objects['n'] === 7);
+ok('string still decodes', $cache->fetch('string') === 'text');
+$multiple = $cache->fetchMultiple(['root', 'objects', 'string'], 'DEFAULT');
+ok('multiple', array_keys($multiple) === ['root', 'objects', 'string']
+ && $multiple['root'] === 'DEFAULT'
+ && $multiple['objects'] == $objects
+ && $multiple['string'] === 'text');
+?>
+--EXPECT--
+stores: OK
+verbatim root yields the default: OK
+verbatim root is unrestorable: OK
+nested verbatim leaf yields the default: OK
+nested verbatim leaf is unrestorable: OK
+no error is raised: OK
+object graph still decodes: OK
+string still decodes: OK
+multiple: OK
diff --git a/ext/user_cache/tests/user_cache_graph_gained_restore_hooks.phpt b/ext/user_cache/tests/user_cache_graph_gained_restore_hooks.phpt
new file mode 100644
index 000000000000..a6c38b47ecac
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_gained_restore_hooks.phpt
@@ -0,0 +1,286 @@
+--TEST--
+UserCache\Cache: objects stored before their class gained __wakeup() or __unserialize() are restored through the hook the class has when fetched, like unserialize(), with numeric property keys and fetches re-entered from those hooks; a safe-direct subclass that gained its own __serialize()/__unserialize() is not restored
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+clear();
+
+$pid = pcntl_fork();
+if ($pid < 0) die('fork failed');
+if ($pid === 0) {
+ eval(<<<'PHP'
+ class GainsWakeup { public $a = 1; protected $b = 2; private $c = 3; }
+ class GainsUnserialize { public $a = 1; protected $b = 2; private $c = 3; }
+ class GainsBoth { public $a = 1; }
+ class SleepGainsUnserialize { public $a = 1; public $skipped = 2; public function __sleep(): array { return ['a']; } }
+ class OverridingDate extends DateTimeImmutable {}
+ class PlainDate extends DateTimeImmutable {}
+ PHP);
+ $values = [
+ 'wakeup' => new GainsWakeup(),
+ 'unserialize' => new GainsUnserialize(),
+ 'both' => new GainsBoth(),
+ 'sleep' => [new SleepGainsUnserialize(), new SleepGainsUnserialize(), new SleepGainsUnserialize()],
+ 'overriding-date' => new OverridingDate('2020-01-02 03:04:05 UTC'),
+ 'plain-date' => new PlainDate('2020-01-02 03:04:05 UTC'),
+ ];
+ $stored = true;
+ foreach ($values as $key => $value) {
+ $stored = $stored && $cache->store($key, $value) && $cache->store("native-$key", serialize($value));
+ }
+ exit($stored ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+
+$log = [];
+eval(<<<'PHP'
+ class GainsWakeup {
+ public $a; protected $b; private $c;
+ public function __wakeup(): void { $GLOBALS['log'][] = ['wakeup', $this->a, $this->b, $this->c]; }
+ }
+ class GainsUnserialize {
+ public $a; protected $b; private $c;
+ public function __unserialize(array $data): void { $GLOBALS['log'][] = ['unserialize', array_map('bin2hex', array_keys($data)), array_values($data)]; }
+ }
+ class GainsBoth {
+ public $a;
+ public function __wakeup(): void { $GLOBALS['log'][] = ['wakeup']; }
+ public function __unserialize(array $data): void { $GLOBALS['log'][] = ['unserialize', $data]; }
+ }
+ class SleepGainsUnserialize {
+ public $a; public $skipped;
+ public function __unserialize(array $data): void { $GLOBALS['log'][] = ['unserialize', $data]; }
+ }
+ class OverridingDate extends DateTimeImmutable {
+ public function __serialize(): array { return []; }
+ public function __unserialize(array $data): void { $GLOBALS['log'][] = ['date unserialize']; }
+ }
+ class PlainDate extends DateTimeImmutable {}
+PHP);
+
+foreach (['wakeup', 'unserialize', 'both', 'sleep'] as $key) {
+ $log = [];
+ $native = unserialize($cache->fetch("native-$key"));
+ $native_log = $log;
+
+ $log = [];
+ $fetched = $cache->fetch($key);
+ echo $key, ': ', $log === $native_log ? 'same hooks as unserialize()' : 'DIFFERENT: ' . json_encode([$log, $native_log]), "\n";
+ var_dump($fetched == $native);
+}
+
+$log = [];
+$cache->fetch('unserialize');
+$cache->fetch('unserialize');
+echo 'repeated fetches call __unserialize(): ', count($log), "\n";
+
+$log = [];
+var_dump($cache->fetch('overriding-date', 'NOT RESTORED'), $cache->has('overriding-date'), $log);
+var_dump($cache->fetch('plain-date')->format(DATE_ATOM));
+
+class Plain
+{
+ public $x = 1;
+}
+
+class Outer
+{
+ public $i;
+
+ public function __sleep(): array
+ {
+ return ['i'];
+ }
+
+ public function __wakeup(): void
+ {
+ if ($this->i === 0) {
+ echo "Outer::__wakeup fetches nested\n";
+ $nested = UserCache\Cache::getPool('graph-restore-hook-frames')->fetch('nested');
+ echo "nested fetched: ", get_class($nested[0]), ' ', $nested[0]->a, "\n";
+ }
+ }
+}
+
+function graph_restore_hook_frames(): void
+{
+ $cache = UserCache\Cache::getPool('graph-restore-hook-frames');
+ $cache->clear();
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ /* Previous deployment: SP has no restore hook and the plain object lets
+ * the stored graph prefer request-local prototypes. */
+ eval('class SP { public $a = 1; public function __serialize(): array { return ["a" => $this->a]; } }');
+ $cache = UserCache\Cache::getPool('graph-restore-hook-frames');
+ $cache->store('nested', [new SP(), new Plain()]);
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wexitstatus($status));
+
+ /* Current deployment: SP gained __wakeup() without a cache reset. */
+ eval('class SP { public $a = 1; public function __serialize(): array { return ["a" => $this->a]; } public function __wakeup(): void { echo "SP::__wakeup\n"; } }');
+
+ /* Eight deferred hooks fill the outer queue, so a hook appended to it by the
+ * nested fetch would move the entry the outer loop is running. */
+ $outer = [];
+ for ($i = 0; $i < 8; $i++) {
+ $object = new Outer();
+ $object->i = $i;
+ $outer[] = $object;
+ }
+ var_dump($cache->store('outer', $outer));
+ $fetched = $cache->fetch('outer');
+ var_dump(count($fetched), $fetched[7]->i);
+}
+
+echo "\ngraph restore hook frames:\n";
+graph_restore_hook_frames();
+
+function graph_gained_unserialize_numeric_keys(): void
+{
+ $errorReporting = error_reporting(E_ALL & ~E_DEPRECATED);
+
+ $cache = UserCache\Cache::getPool('gained-unserialize-numeric-keys');
+ $cache->clear();
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ eval('class WakeupState { public $a = 1; public function __wakeup(): void {} }');
+ eval('class SleepState { public $a = 1; public $skipped = 2; public function __sleep(): array { return ["a", "7"]; } }');
+ $wakeup = new WakeupState();
+ $wakeup->{'0'} = 'zero';
+ $wakeup->{'12'} = 'twelve';
+ $sleep = new SleepState();
+ $sleep->{'7'} = 'seven';
+ $stored = $cache->store('wakeup', $wakeup) && $cache->store('native-wakeup', serialize($wakeup)) &&
+ $cache->store('sleep', $sleep) && $cache->store('native-sleep', serialize($sleep));
+ exit($stored ? 0 : 1);
+ }
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wexitstatus($status));
+
+ eval('class WakeupState { public $a; public array $data = []; public function __unserialize(array $data): void { $this->data = $data; } }');
+ eval('class SleepState { public $a; public array $data = []; public function __unserialize(array $data): void { $this->data = $data; } }');
+
+ foreach (['wakeup', 'sleep'] as $key) {
+ $native = unserialize($cache->fetch("native-$key"))->data;
+ $fetched = $cache->fetch($key)->data;
+ echo $key, ': ', $fetched === $native ? 'same as unserialize()' : 'DIFFERENT', ' ', json_encode(array_map(gettype(...), array_keys($fetched))), "\n";
+ var_dump($fetched[array_key_last($fetched)]);
+ }
+
+ error_reporting($errorReporting);
+}
+
+echo "\ngraph gained unserialize numeric keys:\n";
+graph_gained_unserialize_numeric_keys();
+
+function graph_gained_wakeup_leaks_before_prototype(): void
+{
+ $cache = UserCache\Cache::getPool('gained-wakeup-leaks');
+ $cache->clear();
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ eval('class LeakHolder { public $ao; public $p = 1; } class LeakingWakeup { public $sib; }');
+ $holder = new LeakHolder();
+ $holder->ao = new ArrayObject([new stdClass()]);
+ $leaking = new LeakingWakeup();
+ $leaking->sib = $holder;
+ $objectBacked = new LeakHolder();
+ $objectBacked->ao = @new ArrayObject(new stdClass());
+ try {
+ $cache->store('object-backed', ['holder' => $objectBacked]);
+ exit(2);
+ } catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+ }
+ exit($cache->store('k', ['holder' => $holder, 'leaking' => $leaking]) ? 0 : 1);
+ }
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wexitstatus($status));
+
+ /* __wakeup() publishes a sibling that the request-local prototype clone would copy next. */
+ eval('class LeakHolder { public $ao; public $p = 1; } class LeakingWakeup { public $sib; public function __wakeup(): void { $GLOBALS["leaked_holder"] = $this->sib; $GLOBALS["wakeups"]++; } }');
+ $GLOBALS['leaked_holder'] = null;
+ $GLOBALS['wakeups'] = 0;
+ $GLOBALS['deprecations'] = 0;
+ set_error_handler(function (int $errno): bool {
+ if ($errno === E_DEPRECATED) {
+ $GLOBALS['deprecations']++;
+ }
+ if ($GLOBALS['leaked_holder'] instanceof LeakHolder) {
+ $GLOBALS['leaked_holder']->ao = null;
+ }
+
+ return true;
+ });
+
+ for ($i = 0; $i < 3; $i++) {
+ $fetched = $cache->fetch('k');
+ }
+
+ /* An ArrayObject backed by an object was refused at store time, so no deprecation runs user code mid-fetch. */
+ $unstored = $cache->fetch('object-backed', 'unstored');
+
+ restore_error_handler();
+
+ echo 'wakeups: ', $GLOBALS['wakeups'], "\n";
+ var_dump($fetched['holder'] instanceof LeakHolder, $fetched['leaking']->sib === $fetched['holder']);
+ var_dump($unstored, $GLOBALS['deprecations']);
+
+ $GLOBALS['leaked_holder'] = null;
+}
+
+echo "\ngraph gained wakeup leaks before prototype:\n";
+graph_gained_wakeup_leaks_before_prototype();
+?>
+--EXPECT--
+int(0)
+wakeup: same hooks as unserialize()
+bool(true)
+unserialize: same hooks as unserialize()
+bool(true)
+both: same hooks as unserialize()
+bool(true)
+sleep: same hooks as unserialize()
+bool(true)
+repeated fetches call __unserialize(): 2
+string(12) "NOT RESTORED"
+bool(false)
+array(0) {
+}
+string(25) "2020-01-02T03:04:05+00:00"
+
+graph restore hook frames:
+int(0)
+bool(true)
+Outer::__wakeup fetches nested
+SP::__wakeup
+nested fetched: SP 1
+int(8)
+int(7)
+
+graph gained unserialize numeric keys:
+int(0)
+wakeup: same as unserialize() ["string","integer","integer"]
+string(6) "twelve"
+sleep: same as unserialize() ["string","integer"]
+string(5) "seven"
+
+graph gained wakeup leaks before prototype:
+The state of the ArrayObject object cannot be stored in the user cache
+int(0)
+wakeups: 3
+bool(true)
+bool(true)
+string(8) "unstored"
+int(0)
diff --git a/ext/user_cache/tests/user_cache_graph_internal_sleep_object.phpt b/ext/user_cache/tests/user_cache_graph_internal_sleep_object.phpt
new file mode 100644
index 000000000000..9e1b879d4008
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_internal_sleep_object.phpt
@@ -0,0 +1,173 @@
+--TEST--
+UserCache\Cache: an internal class with __sleep() is stored through the graph, restoring nested values, shared identity and hook counts as unserialize(serialize()) does, and strings keep the valid UTF-8 flag on zero-copy and private-copy fetches
+--EXTENSIONS--
+zend_test
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+ $this->data];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->data = $data['data'];
+ }
+}
+
+class SerializeOnly
+{
+ public $value = 'serialize-only';
+
+ public function __serialize(): array
+ {
+ return ['value' => $this->value];
+ }
+}
+
+class UnserializeOnly
+{
+ public $value = 'unserialize-only';
+ public $seen = null;
+
+ public function __unserialize(array $data): void
+ {
+ $this->value = $data['value'];
+ $this->seen = array_keys($data);
+ }
+}
+
+class Sleeper
+{
+ public static int $sleeps = 0;
+ public $kept = 'kept';
+ public $dropped = 'dropped';
+ public $woken = false;
+
+ public function __sleep(): array
+ {
+ self::$sleeps++;
+
+ return ['kept', 'woken'];
+ }
+
+ public function __wakeup(): void
+ {
+ $this->woken = true;
+ }
+}
+
+/* Built at runtime: OPcache folds a constant str_repeat() into an interned string that preg_match() cannot mark. */
+function valid_utf8(int $chars): string
+{
+ $string = str_repeat('é', $chars);
+ preg_match('//u', $string);
+
+ return $string;
+}
+
+$cache = UserCache\Cache::getPool('graph-internal-sleep-object');
+
+$shared = new Plain();
+$shared->value = 'shared';
+$reference = 'referenced';
+$holes = [0 => 'a', 5 => 'b'];
+unset($holes[5]);
+$value = [
+ 'packed' => [1, 2.5, true, null, 'text'],
+ 'hashed' => ['a' => 1, -3 => 'negative', 'b' => ['nested' => [1, 2]]],
+ 'holes' => $holes,
+ 'empty' => [],
+ 'strings' => array_fill(0, 40, 'repeated string'),
+ 'enum' => ZendTestUnitEnum::Bar,
+ 'objects' => [$shared, $shared, new WithSerialize(), new SerializeOnly(), new UnserializeOnly(), new Sleeper()],
+ 'references' => [&$reference, &$reference],
+ 'inner' => new ZendTestSleepObject('inner public', 'inner protected', 'inner private'),
+];
+$value['objects'][2]->data = ['x' => $shared];
+
+$object = new ZendTestSleepObject($value, 'outer protected', 'outer private');
+$object->typed = 42;
+$object->dynamic = 'dynamic';
+
+$expected = serialize(unserialize(serialize($object)));
+var_dump($cache->store('object', $object));
+
+$sleeper = new Sleeper();
+$outer = ['shared' => $shared, 'sleeper' => $sleeper, 'holder' => new ZendTestSleepObject(['shared' => $shared, 'sleeper' => $sleeper])];
+Sleeper::$sleeps = 0;
+var_dump($cache->store('outer', $outer));
+var_dump(Sleeper::$sleeps);
+$fetched = $cache->fetch('outer');
+var_dump($fetched['holder']->public['shared'] === $fetched['shared']);
+var_dump($fetched['holder']->public['sleeper'] === $fetched['sleeper']);
+
+for ($round = 0; $round < 2; $round++) {
+ $fetched = $cache->fetch('object');
+ var_dump(serialize($fetched) === $expected);
+ var_dump($fetched->wokenUp, $fetched->public['inner']->wokenUp, $fetched->public['objects'][5]->woken);
+ var_dump($fetched->public['objects'][0] === $fetched->public['objects'][1]);
+ var_dump($fetched->public['objects'][2]->data['x'] === $fetched->public['objects'][0]);
+ $fetched->public['references'][0] = 'changed';
+ var_dump($fetched->public['references'][1]);
+}
+
+echo "valid UTF-8 flag:\n";
+$utf8 = valid_utf8(4000);
+$overPinBudget = valid_utf8(2500000);
+var_dump(zend_test_is_string_marked_as_valid_utf8($utf8), zend_test_is_string_marked_as_valid_utf8($overPinBudget));
+var_dump($cache->store('utf8', $utf8));
+echo 'zero-copy root: ';
+var_dump(zend_test_is_string_marked_as_valid_utf8($cache->fetch('utf8')));
+var_dump($cache->store('utf8-private', $overPinBudget));
+echo 'private-copy root: ';
+var_dump(zend_test_is_string_marked_as_valid_utf8($cache->fetch('utf8-private')));
+var_dump($cache->delete('utf8-private'));
+var_dump($cache->store('utf8-private-nested', ['utf8' => $utf8, 'padding' => $overPinBudget]));
+echo 'private-copy nested: ';
+var_dump(zend_test_is_string_marked_as_valid_utf8($cache->fetch('utf8-private-nested')['utf8']));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(1)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(7) "changed"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(7) "changed"
+valid UTF-8 flag:
+bool(true)
+bool(true)
+bool(true)
+zero-copy root: bool(true)
+bool(true)
+private-copy root: bool(true)
+bool(true)
+bool(true)
+private-copy nested: bool(true)
diff --git a/ext/user_cache/tests/user_cache_graph_internal_wakeup_nested_values.phpt b/ext/user_cache/tests/user_cache_graph_internal_wakeup_nested_values.phpt
new file mode 100644
index 000000000000..6f8df7ba1f48
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_internal_wakeup_nested_values.phpt
@@ -0,0 +1,328 @@
+--TEST--
+UserCache\Cache: internal __wakeup classes round-trip nested floats, references, magic and __sleep objects like unserialize(serialize())
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+zend.exception_ignore_args=0
+--FILE--
+setValue($e, $trace);
+
+ return $e;
+}
+
+function native_copy(mixed $value): mixed
+{
+ return unserialize(serialize($value));
+}
+
+class SerdesMagic
+{
+ public static array $log = [];
+
+ public function __construct(public mixed $payload = null)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['payload' => $this->payload, 'ratio' => 0.75];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$log[] = 'unserialize';
+ $this->payload = $data;
+ }
+}
+
+class SerdesSleeper
+{
+ public int $kept = 1;
+ public float $weight = 2.5;
+ public string $state = 'built';
+
+ public function __sleep(): array
+ {
+ return ['kept', 'weight'];
+ }
+
+ public function __wakeup(): void
+ {
+ SerdesMagic::$log[] = 'wakeup';
+ $this->state = 'woken';
+ }
+}
+
+enum SerdesSuit: string
+{
+ case Hearts = 'H';
+}
+
+/* Exception and Error are internal __wakeup classes restored through the graph */
+$cache = UserCache\Cache::getPool('internal-wakeup-values');
+
+$shared = [1.5, -0.0];
+$magic = new SerdesMagic(['flags' => [true, false, null]]);
+$trace = [
+ 'floats' => [3.25, -0.0, 1.0e-300, INF, -INF, 0.1 + 0.2, PHP_FLOAT_EPSILON],
+ 'nan' => NAN,
+ 'magic' => $magic,
+ 'magic-again' => $magic,
+ 'sleeper' => new SerdesSleeper(),
+ 'shared' => &$shared,
+ 'shared-again' => &$shared,
+ 'enum' => SerdesSuit::Hearts,
+ 'scalars' => [true, false, null, PHP_INT_MIN, ''],
+ 'empty' => [],
+ 7 => 'int-key',
+ 'nested' => ['deep' => ['deeper' => -1.25]],
+];
+$root = with_trace(new Exception('outer', 3, new Error('inner')), $trace);
+
+ok('store', $cache->store('exception', $root));
+
+SerdesMagic::$log = [];
+$fetched = $cache->fetch('exception');
+$fetchLog = SerdesMagic::$log;
+SerdesMagic::$log = [];
+$native = native_copy($root);
+$nativeLog = SerdesMagic::$log;
+
+ok('class', get_class($fetched) === Exception::class);
+ok('native parity', serialize($fetched) === serialize($native));
+ok('hook order matches native', $fetchLog === $nativeLog && $fetchLog === ['unserialize', 'wakeup']);
+ok('previous', $fetched->getPrevious() instanceof Error && $fetched->getPrevious()->getMessage() === 'inner');
+ok('message and code', $fetched->getMessage() === 'outer' && $fetched->getCode() === 3);
+
+$fetchedTrace = $fetched->getTrace();
+ok('float bits', $fetchedTrace['floats'] === $trace['floats']
+ && fdiv(1.0, $fetchedTrace['floats'][1]) === -INF);
+ok('nan', is_nan($fetchedTrace['nan']));
+ok('object identity kept', $fetchedTrace['magic'] === $fetchedTrace['magic-again']);
+ok('magic payload', $fetchedTrace['magic']->payload === ['payload' => ['flags' => [true, false, null]], 'ratio' => 0.75]);
+ok('sleeper', $fetchedTrace['sleeper']->kept === 1
+ && $fetchedTrace['sleeper']->weight === 2.5
+ && $fetchedTrace['sleeper']->state === 'woken');
+ok('enum', $fetchedTrace['enum'] === SerdesSuit::Hearts);
+ok('scalars', $fetchedTrace['scalars'] === [true, false, null, PHP_INT_MIN, '']);
+ok('int key', $fetchedTrace[7] === 'int-key');
+ok('nested', $fetchedTrace['nested'] === ['deep' => ['deeper' => -1.25]]);
+
+/* getTrace() returns a copy; mutate the fetched graph through reflection */
+$traceProp = new ReflectionProperty(Exception::class, 'trace');
+$held = $traceProp->getValue($fetched);
+$held['shared'][] = 'appended';
+ok('reference kept', $held['shared-again'] === [1.5, -0.0, 'appended']);
+ok('reference detached from the stored source', $shared === [1.5, -0.0]);
+$again = $cache->fetch('exception');
+ok('fetches are isolated', $again !== $fetched
+ && $traceProp->getValue($again)['shared'] === [1.5, -0.0]
+ && $traceProp->getValue($again)['magic'] !== $held['magic']);
+
+$error = with_trace(new Error('plain error', 9), ['args' => [1.5, [2 => 'x'], new SerdesSleeper()]]);
+ok('Error root', $cache->store('error', $error)
+ && serialize($cache->fetch('error')) === serialize(native_copy($error)));
+
+/* Throwing nested hooks propagate from fetch() and fetchMultiple() and keep the entry */
+$cache = UserCache\Cache::getPool('internal-wakeup-hooks');
+
+class SerdesThrowingUnserialize
+{
+ public function __serialize(): array
+ {
+ return ['v' => 1.5];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ throw new RuntimeException('__unserialize failed');
+ }
+}
+
+class SerdesThrowingWakeup
+{
+ public float $v = 2.5;
+
+ public function __sleep(): array
+ {
+ return ['v'];
+ }
+
+ public function __wakeup(): void
+ {
+ throw new RuntimeException('__wakeup failed');
+ }
+}
+
+$cache->store('plain', 'fine');
+foreach (['unserialize' => new SerdesThrowingUnserialize(), 'wakeup' => new SerdesThrowingWakeup()] as $key => $hooked) {
+ $value = with_trace(new Exception('hooked'), [$hooked]);
+ ok($key . ' store', $cache->store($key, $value));
+
+ try {
+ native_copy($value);
+ $native = 'none';
+ } catch (RuntimeException $e) {
+ $native = $e->getMessage();
+ }
+
+ try {
+ $cache->fetch($key);
+ echo $key, " fetch: no exception\n";
+ } catch (RuntimeException $e) {
+ ok($key . ' fetch throws like unserialize()', $e->getMessage() === $native);
+ }
+
+ try {
+ $cache->fetchMultiple(['plain', $key, 'missing']);
+ echo $key, " fetchMultiple: no exception\n";
+ } catch (RuntimeException $e) {
+ ok($key . ' fetchMultiple throws like unserialize()', $e->getMessage() === $native);
+ }
+
+ ok($key . ' entry kept', $cache->has($key) && $cache->fetch('plain') === 'fine');
+}
+
+/* Unstorable members of an internal __wakeup object are refused without a partial store */
+$cache = UserCache\Cache::getPool('internal-wakeup-refused');
+
+class SerdesLazyTarget
+{
+ public int $x = 1;
+}
+
+class SerdesBadSleep
+{
+ public int $a = 1;
+
+ public function __sleep()
+ {
+ return 5;
+ }
+}
+
+class SerdesMissingMemberSleep
+{
+ public int $a = 1;
+
+ public function __sleep(): array
+ {
+ return ['a', 'missing'];
+ }
+}
+
+$resource = fopen(__FILE__, 'r');
+$refused = [
+ 'closure' => static fn() => 1,
+ 'resource' => $resource,
+ 'lazy' => (new ReflectionClass(SerdesLazyTarget::class))->newLazyGhost(function ($object) {
+ $object->x = 2;
+ }),
+ 'weakref' => WeakReference::create($magic),
+ 'bad-sleep' => new SerdesBadSleep(),
+];
+
+foreach ($refused as $key => $member) {
+ try {
+ var_dump($cache->store($key, with_trace(new Exception('refused'), ['args' => [$member]])));
+ } catch (TypeError $e) {
+ echo $key, ': ', $e->getMessage(), "\n";
+ }
+ var_dump($cache->has($key));
+}
+fclose($resource);
+
+$missing = with_trace(new Exception('missing'), [new SerdesMissingMemberSleep()]);
+var_dump($cache->store('missing-member', $missing));
+ok('missing member parity', @serialize($cache->fetch('missing-member')) === @serialize(native_copy($missing)));
+
+/* Real call-stack arguments of internal and user exception classes */
+$cache = UserCache\Cache::getPool('internal-wakeup-natural');
+
+class SerdesAppException extends Exception
+{
+}
+
+function serdes_throw_internal(float $ratio, object $box, array &$list, SerdesSuit $suit): never
+{
+ throw new RuntimeException('internal');
+}
+
+function serdes_throw_user(float $ratio, SerdesMagic $magic): never
+{
+ throw new SerdesAppException('user');
+}
+
+$list = [1, 2.5];
+try {
+ serdes_throw_internal(0.5, new ArrayObject(['k' => 1.25]), $list, SerdesSuit::Hearts);
+} catch (RuntimeException $e) {
+ $internal = $e;
+}
+try {
+ serdes_throw_user(-0.5, new SerdesMagic([4.5]));
+} catch (SerdesAppException $e) {
+ $user = $e;
+}
+
+ok('internal exception with args', $cache->store('internal', $internal)
+ && serialize($cache->fetch('internal')) === serialize(native_copy($internal)));
+ok('user exception with args', $cache->store('user', $user)
+ && serialize($cache->fetch('user')) === serialize(native_copy($user)));
+?>
+--EXPECTF--
+store: OK
+class: OK
+native parity: OK
+hook order matches native: OK
+previous: OK
+message and code: OK
+float bits: OK
+nan: OK
+object identity kept: OK
+magic payload: OK
+sleeper: OK
+enum: OK
+scalars: OK
+int key: OK
+nested: OK
+reference kept: OK
+reference detached from the stored source: OK
+fetches are isolated: OK
+Error root: OK
+unserialize store: OK
+unserialize fetch throws like unserialize(): OK
+unserialize fetchMultiple throws like unserialize(): OK
+unserialize entry kept: OK
+wakeup store: OK
+wakeup fetch throws like unserialize(): OK
+wakeup fetchMultiple throws like unserialize(): OK
+wakeup entry kept: OK
+closure: Closure objects cannot be stored in the user cache
+bool(false)
+resource: Resources cannot be stored in the user cache
+bool(false)
+lazy: Uninitialized lazy objects cannot be stored in the user cache
+bool(false)
+weakref: WeakReference objects cannot be stored in the user cache
+bool(false)
+
+Warning: UserCache\Cache::store(): SerdesBadSleep::__sleep() should return an array only containing the names of instance-variables to serialize in %s on line %d
+bad-sleep: SerdesBadSleep::__sleep() did not return an array of member names; the object cannot be stored in the user cache
+bool(false)
+
+Warning: UserCache\Cache::store(): "missing" returned as member variable from __sleep() but does not exist in %s on line %d
+bool(true)
+missing member parity: OK
+internal exception with args: OK
+user exception with args: OK
diff --git a/ext/user_cache/tests/user_cache_graph_orphan_reclaim.phpt b/ext/user_cache/tests/user_cache_graph_orphan_reclaim.phpt
new file mode 100644
index 000000000000..1e0c82df13b5
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_orphan_reclaim.phpt
@@ -0,0 +1,92 @@
+--TEST--
+UserCache\Cache: graph payloads released while readers are still draining are orphaned and reclaimed by the next sweep, clear() or deletePool()
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+getFreeMemory();
+}
+
+function seed(UserCache\Cache $cache, int $count): void {
+ $pad = str_repeat('o', 3000);
+ for ($i = 0; $i < $count; $i++) {
+ $cache->store("o:$i", ['i' => $i, 'pad' => $pad]);
+ }
+}
+
+/* More payloads than the orphan list holds are freed by the periodic write sweep. */
+$cache = UserCache\Cache::getPool('orphan-sweep');
+$cache->clear();
+$cache->store('tick', 0);
+for ($i = 20; $i < 40; $i++) {
+ $cache->store("o:$i", $i);
+}
+$baseline = free_memory();
+
+seed($cache, 40);
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT=1');
+for ($i = 0; $i < 20; $i++) {
+ $cache->delete("o:$i");
+}
+for ($i = 20; $i < 40; $i++) {
+ $cache->store("o:$i", $i);
+}
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT');
+
+echo "orphaned: ";
+var_dump(free_memory() < $baseline);
+
+for ($i = 1; $i <= 64; $i++) {
+ $cache->store('tick', $i);
+}
+echo "reclaimed by the sweep: ";
+var_dump(free_memory() === $baseline);
+
+/* Pool clear() and deletePool() reclaim orphans of any pool immediately once readers have drained. */
+$cache = UserCache\Cache::getPool('orphan-clear');
+$cache->clear();
+$baseline = free_memory();
+
+seed($cache, 5);
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT=1');
+var_dump($cache->clear());
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT');
+
+echo "orphaned by clear() while draining: ";
+var_dump($cache->has('o:0'), free_memory() < $baseline);
+
+var_dump(UserCache\Cache::getPool('orphan-unrelated')->clear());
+echo "reclaimed by the next clear(): ";
+var_dump(free_memory() === $baseline);
+
+seed($cache, 5);
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT=1');
+for ($i = 0; $i < 5; $i++) {
+ $cache->delete("o:$i");
+}
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT');
+
+echo "orphaned by delete() while draining: ";
+var_dump(free_memory() < $baseline);
+var_dump(UserCache\Cache::deletePool('orphan-unrelated'));
+echo "reclaimed by deletePool(): ";
+var_dump(free_memory() === $baseline);
+?>
+--EXPECT--
+orphaned: bool(true)
+reclaimed by the sweep: bool(true)
+bool(true)
+orphaned by clear() while draining: bool(false)
+bool(true)
+bool(true)
+reclaimed by the next clear(): bool(true)
+orphaned by delete() while draining: bool(true)
+bool(true)
+reclaimed by deletePool(): bool(true)
diff --git a/ext/user_cache/tests/user_cache_graph_orphan_reclaim_many.phpt b/ext/user_cache/tests/user_cache_graph_orphan_reclaim_many.phpt
new file mode 100644
index 000000000000..e09fe8a4e0b8
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_orphan_reclaim_many.phpt
@@ -0,0 +1,53 @@
+--TEST--
+UserCache\Cache: a store under memory pressure reclaims hundreds of orphaned payloads found by scanning the heap, then keeps live entries and fetches every value
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+user_cache.eviction_policy=lru
+--FILE--
+clear();
+var_dump($cache->store('live', ['kept' => true]), $cache->store('live-text', str_repeat('t', 5000)));
+
+$pad = str_repeat('o', 20000);
+for ($i = 0; $i < 200; $i++) {
+ $cache->store("o:$i", ['i' => $i, 'pad' => $pad . $i]);
+}
+$before = Cache::getStatus()->getFreeMemory();
+
+/* Payloads released while readers are still draining are orphaned instead of freed. */
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT=1');
+for ($i = 0; $i < 200; $i++) {
+ $cache->delete("o:$i");
+}
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT');
+var_dump(Cache::getStatus()->getFreeMemory() < $before + 1000000);
+
+$big = str_repeat('b', 6 * 1024 * 1024);
+var_dump($cache->store('big', $big));
+$status = Cache::getStatus();
+var_dump($status->getEvictionCount(), $status->getExpungeCount());
+var_dump($cache->fetch('live'), strlen($cache->fetch('live-text')), $cache->fetch('big') === $big, $cache->has('o:0'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(0)
+int(0)
+array(1) {
+ ["kept"]=>
+ bool(true)
+}
+int(5000)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_graph_orphan_reclaim_store_pressure.phpt b/ext/user_cache/tests/user_cache_graph_orphan_reclaim_store_pressure.phpt
new file mode 100644
index 000000000000..eafbdbe798f7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_orphan_reclaim_store_pressure.phpt
@@ -0,0 +1,44 @@
+--TEST--
+UserCache\Cache: a store under memory pressure uses the space of reclaimed orphaned payloads before evicting live entries
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+user_cache.eviction_policy=lru
+--FILE--
+clear();
+var_dump($cache->store('live', 'kept'));
+
+$pad = str_repeat('o', 150000);
+for ($i = 0; $i < 40; $i++) {
+ $cache->store("o:$i", ['i' => $i, 'pad' => $pad . $i]);
+}
+
+/* Payloads released while readers are still draining are orphaned instead of freed. */
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT=1');
+for ($i = 0; $i < 40; $i++) {
+ $cache->delete("o:$i");
+}
+putenv('USER_CACHE_DEBUG_FORCE_GRAPH_NOT_QUIESCENT');
+
+$big = str_repeat('b', 4 * 1024 * 1024);
+var_dump($cache->store('big', $big));
+$status = Cache::getStatus();
+var_dump($status->getEvictionCount(), $status->getExpungeCount());
+var_dump($cache->fetch('live'), $cache->fetch('big') === $big);
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(0)
+int(0)
+string(4) "kept"
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_graph_pin_slot_dead_owner_takeover.phpt b/ext/user_cache/tests/user_cache_graph_pin_slot_dead_owner_takeover.phpt
new file mode 100644
index 000000000000..5e03d8a13322
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_pin_slot_dead_owner_takeover.phpt
@@ -0,0 +1,71 @@
+--TEST--
+UserCache\Cache: once every graph pin and reader slot belongs to a dead process, the next reader takes one over and still gets a pinned view
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_EXIT_BEFORE_CLAIM_RELEASE=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+$value = ['list' => range(1, 16), 'text' => str_repeat('t', 200), 'nested' => ['k' => ['v' => 1.5]]];
+$cache->store('graph', $value);
+
+/* Each short-lived process claims a pin slot and a reader slot, then dies before it gives them back */
+$failures = 0;
+for ($i = 0; $i < GENERATIONS; $i++) {
+ $pid = pcntl_fork();
+ if ($pid < 0) die("fork failed\n");
+ if ($pid === 0) {
+ $held = UserCache\Cache::getPool('graph-pin-slot-takeover')->fetch('graph');
+ exit($held === $value ? 0 : 1);
+ }
+ pcntl_waitpid($pid, $status);
+ if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) {
+ $failures++;
+ }
+}
+var_dump($failures);
+
+$status = UserCache\Cache::getStatus();
+var_dump($status->getGraphPinSlotsInUse(), $status->getGraphPinnedReferences());
+
+/* This process never claimed a slot: it must reclaim one from an exited owner */
+$fetched = $cache->fetch('graph');
+var_dump($fetched === $value);
+$status = UserCache\Cache::getStatus();
+var_dump($status->getGraphPinSlotsInUse(), $status->getGraphPinnedReferences());
+
+/* The pinned view stays valid after the entry is deleted and is released with it */
+var_dump($cache->delete('graph'));
+var_dump($fetched === $value);
+unset($fetched);
+var_dump($cache->store('graph', $value), $cache->fetch('graph') === $value);
+?>
+--EXPECT--
+int(0)
+int(256)
+int(0)
+bool(true)
+int(256)
+int(1)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_graph_pin_slot_exhaustion.phpt b/ext/user_cache/tests/user_cache_graph_pin_slot_exhaustion.phpt
new file mode 100644
index 000000000000..272fd66846e9
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_pin_slot_exhaustion.phpt
@@ -0,0 +1,227 @@
+--TEST--
+UserCache\Cache: a process without a graph pin slot decodes private copies instead of pinned views, also when a child forked during such a decode fetches other keys
+--EXTENSIONS--
+pcntl
+posix
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=32M
+--FILE--
+name = $name;
+ $this->items = $items;
+ }
+}
+
+$cache = UserCache\Cache::getPool('graph-pin-slot-exhaustion');
+$cache->clear();
+
+$holder = ['held' => range(1, 8)];
+$plain = ['list' => range(1, 40), 'text' => str_repeat('p', 400), 'nested' => ['k' => ['v' => 'w']]];
+$objects = [new Node('a', ['x' => str_repeat('q', 300), 'y' => [1, 2, 3]]), new Node('b', [])];
+$shared = ['leaf'];
+for ($i = 0; $i < 40; $i++) {
+ $shared = [$shared, $shared];
+}
+$cache->store('holder', $holder);
+$cache->store('objects', $objects);
+$cache->store('shared', new Node('shared', $shared));
+$free_without_plain = UserCache\Cache::getStatus()->getFreeMemory();
+$cache->store('plain', $plain);
+
+[$ready_read, $ready_write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+
+/* Every holder pins a payload and then keeps its pin slot alive in sleep(1). */
+$children = [];
+for ($i = 0; $i < HOLDERS; $i++) {
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ fclose($ready_read);
+ $held = UserCache\Cache::getPool('graph-pin-slot-exhaustion')->fetch('holder');
+ fwrite($ready_write, $held === $holder ? 'y' : 'n');
+ fclose($ready_write);
+ pcntl_exec('/bin/sleep', ['60']);
+ exit(1);
+ }
+ $children[] = $pid;
+}
+fclose($ready_write);
+
+$ready = '';
+while (strlen($ready) < HOLDERS && !feof($ready_read)) {
+ $ready .= fread($ready_read, HOLDERS);
+}
+var_dump($ready === str_repeat('y', HOLDERS));
+
+$status = UserCache\Cache::getStatus();
+var_dump($status->getGraphPinSlotsInUse(), $status->getGraphPinnedReferences());
+
+for ($round = 0; $round < 3; $round++) {
+ $fetched = $cache->fetch('plain');
+ $nodes = $cache->fetch('objects');
+ var_dump($fetched === $plain, $nodes == $objects);
+}
+
+/* A private copy keeps shared sub-arrays shared, so checking a later fetch visits each of them once. */
+for ($round = 0; $round < 2; $round++) {
+ $leaf = $cache->fetch('shared')->items;
+ for ($i = 0; $i < 40; $i++) {
+ $leaf = $leaf[$i % 2];
+ }
+ var_dump($leaf);
+}
+
+$status = UserCache\Cache::getStatus();
+var_dump($status->getGraphPinSlotsInUse(), $status->getGraphPinnedReferences());
+
+/* This process holds no reference: deleting the entry frees its payload. */
+var_dump($cache->delete('plain'));
+var_dump(UserCache\Cache::getStatus()->getFreeMemory() === $free_without_plain);
+
+$fetched['list'][] = 'local';
+var_dump(count($fetched['list']), $fetched['text'] === $plain['text']);
+var_dump($nodes[0]->items['y'], $nodes[1]->name);
+
+foreach ($children as $pid) {
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $child_status);
+}
+sleep(1);
+
+echo "\nfork during snapshot decode:\n";
+$cache->clear();
+$reclaim = Cache::getPool('graph-pin-dead-holders');
+$half = str_repeat('r', intdiv(Cache::getStatus()->getFreeMemory(), 2) + 1000);
+$reclaim->store('first', $half);
+$reclaim->store('second', $half);
+$reclaim->clear();
+unset($half);
+var_dump(Cache::getStatus()->getGraphPinSlotsInUse());
+
+class ForkOnWakeup
+{
+ public $x = 1;
+
+ public function __wakeup(): void
+ {
+ global $cache;
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $b = $cache->fetch('B', 'DEFAULT');
+ echo 'child B: ', is_array($b) ? json_encode($b['list']) : var_export($b, true), "\n";
+ echo 'child B still cached: ', var_export($cache->has('B'), true), "\n";
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+ }
+}
+
+$cache = Cache::getPool('pin-fork-snapshot');
+$cache->clear();
+$cache->store('A', ['obj' => new ForkOnWakeup]);
+$cache->store('B', ['o' => new stdClass, 'list' => [1, 2, 3]]);
+$cache->store('holder', ['held' => range(1, 8)]);
+
+/* Children hold every graph pin slot, so this process decodes private copies. */
+[$ready_read, $ready_write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+[$go_read, $go_write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+$children = [];
+for ($i = 0; $i < 256; $i++) {
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ fclose($ready_read);
+ $held = Cache::getPool('pin-fork-snapshot')->fetch('holder');
+ fwrite($ready_write, is_array($held) ? 'y' : 'n');
+ fclose($ready_write);
+ if ($i === 0) {
+ fread($go_read, 1);
+ exit(0);
+ }
+ pcntl_exec('/bin/sleep', ['20']);
+ exit(1);
+ }
+ $children[] = $pid;
+}
+fclose($ready_write);
+$ready = '';
+while (strlen($ready) < 256 && !feof($ready_read)) {
+ $ready .= fread($ready_read, 256);
+}
+echo 'holders ready: ', var_export($ready === str_repeat('y', 256), true), "\n";
+
+/* The parent fails to claim a slot; one holder then frees its slot for the forked child. */
+$cache->fetch('holder');
+fwrite($go_write, 'x');
+pcntl_waitpid($children[0], $child_status);
+
+$a = $cache->fetch('A');
+echo 'parent A: ', get_debug_type($a['obj']), "\n";
+foreach (array_slice($children, 1) as $pid) {
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $child_status);
+}
+echo 'parent B: ', json_encode($cache->fetch('B', 'GONE')['list'] ?? 'GONE'), "\n";
+?>
+--EXPECT--
+bool(true)
+int(256)
+int(256)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+array(1) {
+ [0]=>
+ string(4) "leaf"
+}
+array(1) {
+ [0]=>
+ string(4) "leaf"
+}
+int(256)
+int(256)
+bool(true)
+bool(true)
+int(41)
+bool(true)
+array(3) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+ [2]=>
+ int(3)
+}
+string(1) "b"
+
+fork during snapshot decode:
+int(0)
+holders ready: true
+child B: [1,2,3]
+child B still cached: true
+parent A: ForkOnWakeup
+parent B: [1,2,3]
diff --git a/ext/user_cache/tests/user_cache_graph_pin_slot_reclaim_exit.phpt b/ext/user_cache/tests/user_cache_graph_pin_slot_reclaim_exit.phpt
new file mode 100644
index 000000000000..a3567293ef7a
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_pin_slot_reclaim_exit.phpt
@@ -0,0 +1,55 @@
+--TEST--
+UserCache\Cache: a process killed while it takes over a dead owner's graph pin slot leaves the slot reclaimable
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--ENV--
+USER_CACHE_DEBUG_EXIT_BEFORE_CLAIM_RELEASE=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+$cache->store('value', range(1, 8));
+
+/* Every child dies after its request released its pins and before it gives back its slot. */
+fork_each(PIN_SLOTS, function () {
+ UserCache\Cache::getPool('pin-slot-reclaim-exit')->fetch('value');
+});
+var_dump(UserCache\Cache::getStatus()->getGraphPinSlotsInUse());
+
+/* Every child dies after it took a dead owner's slot and before it recorded its own start time. */
+fork_each(PIN_SLOTS, function () {
+ putenv('USER_CACHE_DEBUG_EXIT_IN_GRAPH_PIN_SLOT_RECLAIM=1');
+ UserCache\Cache::getPool('pin-slot-reclaim-exit')->fetch('value');
+});
+
+/* The slots are still reclaimable: this fetch pins the payload instead of decoding a private copy. */
+var_dump($cache->fetch('value') === range(1, 8));
+var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences());
+?>
+--EXPECT--
+int(256)
+bool(true)
+int(1)
diff --git a/ext/user_cache/tests/user_cache_graph_pins_cross_process.phpt b/ext/user_cache/tests/user_cache_graph_pins_cross_process.phpt
new file mode 100644
index 000000000000..71caa9875bec
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_graph_pins_cross_process.phpt
@@ -0,0 +1,229 @@
+--TEST--
+UserCache\Cache: shared-graph pins held by other processes survive deletePool(), orphan deleted payloads and are reclaimed after exit or SIGKILL
+--EXTENSIONS--
+pcntl
+posix
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+clear();
+
+function free_memory(): int {
+ return UserCache\Cache::getStatus()->getFreeMemory();
+}
+
+function seed_value(int $i): array {
+ return ['i' => $i, 'pad' => str_repeat('x', 2000)];
+}
+
+function seed(UserCache\Cache $cache): void {
+ for ($i = 0; $i < KEYS; $i++) {
+ $cache->store("g:$i", seed_value($i));
+ }
+}
+
+function forget(UserCache\Cache $cache): void {
+ for ($i = 0; $i < KEYS; $i++) {
+ $cache->delete("g:$i");
+ }
+}
+
+/* Waits for another process to write $path; gives up when the child $pid exits first or the deadline passes. */
+function wait_for_file(string $path, ?int $pid = null): bool {
+ $deadline = hrtime(true) + WAIT_NS;
+ do {
+ clearstatcache();
+ if (filesize($path) !== 0) {
+ return true;
+ }
+ if ($pid !== null && pcntl_waitpid($pid, $status, WNOHANG) !== 0) {
+ return false;
+ }
+ usleep(10000);
+ } while (hrtime(true) < $deadline);
+
+ return false;
+}
+
+/* Reaps $pid, killing it once the deadline passes, and reports whether it exited with status 0. */
+function reap(int $pid): bool {
+ $deadline = hrtime(true) + WAIT_NS;
+ do {
+ $ret = pcntl_waitpid($pid, $status, WNOHANG);
+ if ($ret !== 0) {
+ return $ret === $pid && pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0;
+ }
+ usleep(10000);
+ } while (hrtime(true) < $deadline);
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $status);
+
+ return false;
+}
+
+function run_pinner(UserCache\Cache $cache, string $mode): bool {
+ $ready = tempnam(sys_get_temp_dir(), 'uc_pin');
+ $pid = pcntl_fork();
+ if ($pid < 0) die("fork failed\n");
+ if ($pid === 0) {
+ for ($i = 0; $i < KEYS; $i++) {
+ if ($cache->fetch("g:$i") !== seed_value($i)) exit(1);
+ }
+ file_put_contents($ready, 'r');
+ if ($mode === 'kill') {
+ sleep(30); /* SIGKILL window */
+ }
+ exit(0);
+ }
+ $pinned = wait_for_file($ready, $pid);
+ @unlink($ready);
+ if ($mode === 'kill' && $pinned) {
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $status);
+
+ return pcntl_wifsignaled($status) && pcntl_wtermsig($status) === SIGKILL;
+ }
+
+ return reap($pid) && $pinned;
+}
+
+$baseline = free_memory();
+$status = UserCache\Cache::getStatus();
+$owners0 = $status->getDeadPinOwnersReclaimed();
+$stripped0 = $status->getDeadPinsStripped();
+
+seed($cache);
+var_dump(run_pinner($cache, 'clean'));
+forget($cache);
+var_dump(free_memory() === $baseline);
+
+seed($cache);
+var_dump(run_pinner($cache, 'kill'));
+var_dump(run_pinner($cache, 'clean')); /* Run the request-end sweep. */
+forget($cache);
+var_dump(free_memory() === $baseline);
+
+$status = UserCache\Cache::getStatus();
+var_dump($status->getDeadPinOwnersReclaimed() - $owners0);
+var_dump($status->getDeadPinsStripped() - $stripped0 === KEYS);
+
+var_dump($status->getGraphPinnedReferences());
+
+/* A child's pin keeps a payload alive across the parent's deletePool(). */
+$cache = UserCache\Cache::getPool('del-pin');
+$original = ['list' => range(1, 64), 'label' => str_repeat('x', 100)];
+$cache->store('graph', $original);
+
+$ready = tempnam(sys_get_temp_dir(), 'uc_dp_ready');
+$deleted = tempnam(sys_get_temp_dir(), 'uc_dp_done');
+
+$pid = pcntl_fork();
+if ($pid < 0) die("fork failed\n");
+if ($pid === 0) {
+ $held = $cache->fetch('graph');
+ file_put_contents($ready, 'r');
+
+ if (!wait_for_file($deleted)) exit(2);
+
+ $intact = $held === ['list' => range(1, 64), 'label' => str_repeat('x', 100)];
+ echo "child: ", $intact ? "intact\n" : "CORRUPTED\n";
+ exit($intact ? 0 : 1);
+}
+
+if (!wait_for_file($ready, $pid)) die("child did not pin the payload\n");
+
+var_dump(UserCache\Cache::deletePool('del-pin'));
+file_put_contents($deleted, 'd');
+var_dump(reap($pid));
+
+var_dump(UserCache\Cache::hasPool('del-pin'));
+var_dump(UserCache\Cache::getPool('del-pin')->fetch('graph', 'MISS'));
+
+@unlink($ready);
+@unlink($deleted);
+
+/* Payloads deleted or replaced while another process pins them are orphaned and freed when that process exits. */
+$cache = UserCache\Cache::getPool('orphan-pin');
+$cache->clear();
+$cache->store('tick', 0);
+for ($i = 20; $i < 40; $i++) {
+ $cache->store("o:$i", $i);
+}
+$baseline = free_memory();
+
+$pad = str_repeat('o', 3000);
+for ($i = 0; $i < 40; $i++) {
+ $cache->store("o:$i", ['i' => $i, 'pad' => $pad]);
+}
+
+$ready = tempnam(sys_get_temp_dir(), 'uc_orphan_ready');
+$release = tempnam(sys_get_temp_dir(), 'uc_orphan_release');
+
+$pid = pcntl_fork();
+if ($pid < 0) die("fork failed\n");
+if ($pid === 0) {
+ $held = [];
+ for ($i = 0; $i < 40; $i++) {
+ $held[] = $cache->fetch("o:$i");
+ }
+ file_put_contents($ready, 'r');
+ if (!wait_for_file($release)) exit(2);
+
+ $intact = true;
+ foreach ($held as $i => $value) {
+ $intact = $intact && $value === ['i' => $i, 'pad' => $pad];
+ }
+ echo "child: ", $intact ? "intact\n" : "CORRUPTED\n";
+ exit($intact ? 0 : 1);
+}
+
+if (!wait_for_file($ready, $pid)) die("child did not pin the payloads\n");
+
+for ($i = 0; $i < 20; $i++) {
+ $cache->delete("o:$i");
+}
+for ($i = 20; $i < 40; $i++) {
+ $cache->store("o:$i", $i);
+}
+echo "held while pinned: ";
+var_dump(free_memory() < $baseline);
+var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences() > 0);
+
+file_put_contents($release, 'd');
+var_dump(reap($pid));
+
+echo "freed by the child's request shutdown: ";
+var_dump(free_memory() === $baseline);
+var_dump(UserCache\Cache::getStatus()->getGraphPinnedReferences());
+
+@unlink($ready);
+@unlink($release);
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+bool(true)
+int(0)
+bool(true)
+child: intact
+bool(true)
+bool(false)
+string(4) "MISS"
+held while pinned: bool(true)
+bool(true)
+child: intact
+bool(true)
+freed by the child's request shutdown: bool(true)
+int(0)
diff --git a/ext/user_cache/tests/user_cache_ini_shm_size_floor.phpt b/ext/user_cache/tests/user_cache_ini_shm_size_floor.phpt
new file mode 100644
index 000000000000..b33a044ee015
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_ini_shm_size_floor.phpt
@@ -0,0 +1,36 @@
+--TEST--
+UserCache\Cache: the minimum shm_size reported by the startup warning does not depend on shm_size and is exactly the smallest segment that initializes with room for one block
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+getAvailability()->name;';
+$run = function (int $size) use ($php, $args, $probe): string {
+ $process = proc_open([$php, ...$args, '-d', "user_cache.shm_size=$size", '-r', $probe], [1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
+ $output = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ proc_close($process);
+
+ return trim($output);
+};
+
+preg_match('/minimum cache layout \((\d+) bytes\)/', $run(16), $m);
+$minimum = (int) $m[1];
+var_dump($minimum > 16);
+
+$belowMinimum = $run($minimum - 1);
+var_dump(str_contains($belowMinimum, "is below the minimum cache layout ($minimum bytes)"), str_ends_with($belowMinimum, 'UnavailableBySharedMemoryInitializationFailed'));
+var_dump($run($minimum));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+string(9) "Available"
diff --git a/ext/user_cache/tests/user_cache_ini_startup.phpt b/ext/user_cache/tests/user_cache_ini_startup.phpt
new file mode 100644
index 000000000000..ab812e062841
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_ini_startup.phpt
@@ -0,0 +1,194 @@
+--TEST--
+UserCache\Cache: startup sizing follows entries_hint and shm_size, invalid directives warn and keep a usable cache, deleteMultiple() keeps its records while the startup warning handler drops old ones, and enable_cli=0 disables the cache in the CLI
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+user_cache.entries_hint=1000
+--FILE--
+ ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
+ $output = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ proc_close($process);
+
+ return $output;
+};
+$capacity = 'echo UserCache\\Cache::getStatus()->getEntryCapacity();';
+$probe = '$status = UserCache\\Cache::getStatus();'
+ . ' $cache = UserCache\\Cache::getPool("ini");'
+ . ' var_dump($status->getAvailability(), $status->getConfiguredMemory(), $cache->store("k", [1]), $cache->fetch("k") === [1]);';
+
+echo "sizing\n";
+
+/* entries_hint sizes the entry table: next_prime(ceil(hint / 0.75)), auto at one entry per 1KB */
+var_dump(UserCache\Cache::getStatus()->getEntryCapacity());
+echo $run('-d user_cache.shm_size=16M -d user_cache.entries_hint=0', $capacity), "\n";
+echo $run('-d user_cache.shm_size=16M -d user_cache.entries_hint=-1', $capacity), "\n";
+
+/* entries_hint at the table limit is only clamped by what shm_size can index */
+echo $run('-d user_cache.shm_size=16M -d user_cache.entries_hint=16777213', $capacity), "\n";
+
+/* A segment whose half cannot even hold the fixed layout for the hint falls back to the minimum capacity and still warns */
+echo $run('-d user_cache.shm_size=40000 -d user_cache.entries_hint=100000', $capacity), "\n";
+
+/* The free-list bins are sized to the segment: with the same entry table a 1G segment reserves a little more than 16M */
+$used = 'echo UserCache\\Cache::getStatus()->getUsedMemory();';
+$small = (int) $run('-d user_cache.shm_size=16M -d user_cache.entries_hint=1000', $used);
+$large = (int) $run('-d user_cache.shm_size=1G -d user_cache.entries_hint=1000', $used);
+var_dump($large > $small, $large - $small < 8192);
+
+/* shm_size below the minimum layout leaves the cache unavailable */
+echo $run(
+ '-d user_cache.shm_size=16',
+ '$status = UserCache\\Cache::getStatus(); var_dump($status->getConfiguredMemory(), $status->getAvailability());'
+);
+
+/* shm_size up to the addressable limit (32 GiB on 64-bit builds) is accepted; larger values are clamped with a warning */
+/* Whether the host can actually allocate that much (overcommit, tmpfs size) is not checked, so the allocation warning is silenced */
+$configured = 'var_dump(@UserCache\\Cache::getStatus()->getConfiguredMemory());';
+echo $run('-d user_cache.shm_size=8192M -d user_cache.entries_hint=1000', $configured);
+echo $run('-d user_cache.shm_size=34359738360 -d user_cache.entries_hint=1000', $configured);
+echo $run('-d user_cache.shm_size=34359738361 -d user_cache.entries_hint=1000', $configured);
+echo $run('-d user_cache.shm_size=64G -d user_cache.entries_hint=1000', $configured);
+
+echo "invalid values\n";
+
+/* A negative shm_size is rejected and the default size stays in effect */
+echo $run('-d user_cache.shm_size=-1', $probe . ' var_dump(ini_get("user_cache.shm_size"));');
+
+/* entries_hint above the table limit is clamped before sizing against shm_size */
+$out = $run('-d user_cache.shm_size=16M -d user_cache.entries_hint=99999999999', $probe . ' echo UserCache\\Cache::getStatus()->getEntryCapacity();');
+echo $out, "\n";
+preg_match('/(\d+)\s*$/', $out, $m);
+$cap = (int) $m[1];
+var_dump($cap >= 100000, $cap * (40 + 4) + 1024 * 48 <= 8 * 1024 * 1024);
+
+/* An unknown eviction_policy is rejected and the default policy stays in effect */
+echo $run('-d user_cache.eviction_policy=bogus', 'var_dump(ini_get("user_cache.eviction_policy"));');
+
+echo "startup warning handler\n";
+
+$deleteMultipleStartupWarning = <<<'CODE'
+use UserCache\Cache;
+
+$pool = Cache::getPool('delete-multiple-startup-warning');
+
+/* The first call resolves the partition and warns about the clamped capacity; the handler then crosses the record limit. */
+set_error_handler(function (int $errno, string $message) use ($pool): bool {
+ echo 'warning: ', str_contains($message, 'entries_hint') ? 'entries_hint' : $message, "\n";
+ $pool->fetch('trigger');
+
+ return true;
+});
+
+$keys = [];
+for ($i = 0; $i < 20000; $i++) {
+ $keys[] = "key$i";
+}
+var_dump($pool->deleteMultiple($keys), $pool->store('key0', 1), $pool->fetch('key0'));
+CODE;
+echo $run('-d user_cache.shm_size=1M -d user_cache.entries_hint=16777213', $deleteMultipleStartupWarning);
+
+echo "enable_cli off\n";
+
+$enableCliOff = <<<'CODE'
+$cache = UserCache\Cache::getPool('cli-off');
+$status = UserCache\Cache::getStatus();
+$poolStatus = $cache->getPoolStatus();
+var_dump($status->getAvailability(), $status->getConfiguredMemory(), $status->getSharedMemorySize(), $poolStatus->getEntryCount(), $poolStatus->getEntryKeys());
+var_dump($cache->store('key', 1), $cache->add('key', 1), $cache->storeMultiple(['key' => 1]), $cache->increment('key'), $cache->decrement('key'));
+var_dump($cache->has('key'), $cache->fetch('key', 'default'), $cache->fetchMultiple(['key'], 'default'));
+var_dump($cache->delete('key'), $cache->deleteMultiple(['key']), $cache->clear(), $cache->lock('key'), $cache->unlock('key'), $cache->remember('key', fn() => 42));
+var_dump(UserCache\Cache::deletePool('cli-off'), UserCache\Cache::hasPool('cli-off'));
+CODE;
+echo $run('-d user_cache.enable_cli=0', $enableCliOff);
+?>
+--EXPECTF--
+sizing
+int(1361)
+21851
+
+Warning: user_cache.entries_hint must be greater than or equal to 0, -1 given in Unknown on line 0
+21851
+
+Warning: user_cache.entries_hint (16777213) exceeds what user_cache.shm_size can index; clamping capacity to %d in %s on line %d
+%d
+
+Warning: user_cache.entries_hint (100000) exceeds what user_cache.shm_size can index; clamping capacity to 127 in %s on line %d
+127
+bool(true)
+bool(true)
+
+Warning: user_cache.shm_size (16) is below the minimum cache layout (%d bytes); the cache will be unavailable in Unknown on line 0
+int(16)
+enum(UserCache\CacheAvailability::UnavailableBySharedMemoryInitializationFailed)
+int(8589934592)
+int(34359738360)
+
+Warning: user_cache.shm_size is limited to 34359738360 bytes; clamping in Unknown on line 0
+int(34359738360)
+
+Warning: user_cache.shm_size is limited to 34359738360 bytes; clamping in Unknown on line 0
+int(34359738360)
+invalid values
+
+Warning: user_cache.shm_size must be greater than or equal to 0, -1 given in Unknown on line 0
+enum(UserCache\CacheAvailability::Available)
+int(16777216)
+bool(true)
+bool(true)
+string(3) "16M"
+
+Warning: user_cache.entries_hint is limited to 16777213; clamping in Unknown on line 0
+
+Warning: user_cache.entries_hint (16777213) exceeds what user_cache.shm_size can index; clamping capacity to %d in %s on line %d
+enum(UserCache\CacheAvailability::Available)
+int(16777216)
+bool(true)
+bool(true)
+%d
+bool(true)
+bool(true)
+
+Warning: user_cache.eviction_policy must be one of "lru", "clear" or "none" in Unknown on line 0
+string(3) "lru"
+startup warning handler
+warning: entries_hint
+bool(true)
+bool(true)
+int(1)
+enable_cli off
+enum(UserCache\CacheAvailability::DisabledByIni)
+int(16777216)
+int(0)
+int(0)
+array(0) {
+}
+bool(false)
+bool(false)
+bool(false)
+NULL
+NULL
+bool(false)
+string(7) "default"
+array(1) {
+ ["key"]=>
+ string(7) "default"
+}
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+int(42)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_ini_validation.phpt b/ext/user_cache/tests/user_cache_ini_validation.phpt
new file mode 100644
index 000000000000..c90cbb043d15
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_ini_validation.phpt
@@ -0,0 +1,50 @@
+--TEST--
+UserCache\Cache: a preferred_memory_model that is not available on the platform and a lockfile_path that is not absolute are rejected with a warning and keep their defaults
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.preferred_memory_model=bogus
+user_cache.lockfile_path=relative/dir
+--FILE--
+store('k', 1), UserCache\Cache::getPool('ini-validation')->fetch('k'));
+
+$run = function (array $args): string {
+ $process = proc_open([PHP_BINARY, '-n', ...$args], [1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
+ $output = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ proc_close($process);
+
+ return $output;
+};
+
+/* "cgi" is accepted as an alias of "shm", which only builds with SysV shared memory provide (never macOS) */
+$shm = $run(['-d', 'user_cache.preferred_memory_model=shm', '-r', 'echo ini_get("user_cache.preferred_memory_model");']);
+$model = $shm === 'shm' ? 'cgi' : 'mmap';
+echo $run([
+ '-d', 'user_cache.enable=1',
+ '-d', 'user_cache.enable_cli=1',
+ '-d', "user_cache.preferred_memory_model=$model",
+ '-d', 'user_cache.lockfile_path=',
+ '-r', 'var_dump(ini_get("user_cache.preferred_memory_model"), ini_get("user_cache.lockfile_path"), UserCache\Cache::getPool("x")->store("k", 1));',
+]);
+?>
+--EXPECTF--
+Warning: user_cache.lockfile_path must be an absolute path, "relative/dir" given in Unknown on line 0
+
+Warning: user_cache.preferred_memory_model "bogus" is not a memory model available on this platform in Unknown on line 0
+string(0) ""
+string(4) "/tmp"
+bool(true)
+int(1)
+
+Warning: user_cache.lockfile_path must be an absolute path, "" given in Unknown on line 0
+string(%d) "%s"
+string(4) "/tmp"
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_internal_serialization_handlers.phpt b/ext/user_cache/tests/user_cache_internal_serialization_handlers.phpt
new file mode 100644
index 000000000000..9a7cc2e831de
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_internal_serialization_handlers.phpt
@@ -0,0 +1,63 @@
+--TEST--
+UserCache\Cache: legacy serialize handlers get the native unserialize context, unrestorable internal classes with __sleep() are refused when storing, and references are restored before typed properties see them
+--EXTENSIONS--
+zend_test
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+n = 1;
+$legacy = new ZendTestLegacySerializeObject();
+$legacy->data = [$shared, $shared, 'tail'];
+var_dump($cache->store('legacy', $legacy));
+$fetched = $cache->fetch('legacy');
+var_dump(serialize($fetched) === serialize(unserialize(serialize($legacy))));
+var_dump($fetched->data[0] === $fetched->data[1]);
+
+foreach ([
+ 'direct' => new ZendTestSleepObjectWithCustomCreate(),
+ 'nested' => new ZendTestSleepObject([new ZendTestSleepObjectWithCustomCreate()]),
+] as $key => $unrestorable) {
+ try {
+ var_dump($cache->store($key, $unrestorable));
+ } catch (TypeError $e) {
+ echo $key, ': ', $e->getMessage(), "\n";
+ }
+ var_dump($cache->has($key));
+}
+
+$node = new Node();
+$reference = $node;
+$node->self = &$reference;
+$node->list = [&$reference];
+$value = new ZendTestSleepObject([&$reference, $node]);
+var_dump($cache->store('typed-reference', $value));
+$fetched = $cache->fetch('typed-reference');
+var_dump(serialize($fetched) === serialize(unserialize(serialize($value))));
+var_dump($fetched->public[1]->self === $fetched->public[1]);
+
+/* Release builds do not collect cycles at shutdown. */
+$node = $reference = $value = $fetched = null;
+gc_collect_cycles();
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+direct: ZendTestSleepObjectWithCustomCreate objects cannot be stored in the user cache
+bool(false)
+nested: ZendTestSleepObjectWithCustomCreate objects cannot be stored in the user cache
+bool(false)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_key_record_cross_process.phpt b/ext/user_cache/tests/user_cache_key_record_cross_process.phpt
new file mode 100644
index 000000000000..4861eb9f0d7e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_key_record_cross_process.phpt
@@ -0,0 +1,177 @@
+--TEST--
+UserCache\Cache: records primed by has(), delete() and counters observe other processes' writes, and a record does not keep a zero-copy string that a forked process may see freed
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+store('h', 'value');
+var_dump($cache->has('h'), $cache->has('h'));
+in_child(fn($c) => $c->delete('h'));
+var_dump($cache->has('h'), $cache->has('h'));
+in_child(fn($c) => $c->store('h', 'child'));
+var_dump($cache->has('h'), $cache->fetch('h'));
+$cache->store('ht', 'ttl', 3600);
+var_dump($cache->has('ht'));
+in_child(fn($c) => $c->delete('ht'));
+var_dump($cache->has('ht'));
+
+echo "delete():\n";
+$cache->store('d', [1, 2]);
+var_dump($cache->fetch('d'), $cache->delete('d'), $cache->fetch('d', 'gone'), $cache->has('d'));
+in_child(fn($c) => $c->store('d', 'child'));
+var_dump($cache->fetch('d'), $cache->has('d'));
+var_dump($cache->delete('never-stored'), $cache->fetch('never-stored', 'absent'));
+in_child(fn($c) => $c->store('never-stored', 'child'));
+var_dump($cache->fetch('never-stored', 'absent'));
+
+echo "deleteMultiple():\n";
+$cache->storeMultiple(['m1' => 1, 'm2' => 2]);
+var_dump($cache->fetch('m1'), $cache->deleteMultiple(['m1', 'm2']), $cache->fetch('m1', 'gone'), $cache->has('m2'));
+in_child(fn($c) => $c->store('m2', 'child'));
+var_dump($cache->fetch('m1', 'gone'), $cache->fetch('m2'), $cache->has('m1'));
+
+echo "counters:\n";
+var_dump($cache->increment('n', 3), $cache->fetch('n'), $cache->has('n'));
+in_child(fn($c) => $c->increment('n', 10));
+var_dump($cache->fetch('n'), $cache->decrement('n', 2), $cache->fetch('n'));
+in_child(fn($c) => $c->decrement('n', 100));
+var_dump($cache->fetch('n'));
+in_child(fn($c) => $c->delete('n'));
+var_dump($cache->fetch('n', 'gone'), $cache->increment('n'), $cache->fetch('n'));
+var_dump($cache->increment('nt', 5, 3600), $cache->fetch('nt'), $cache->has('nt'));
+in_child(fn($c) => $c->increment('nt', 1, 3600));
+var_dump($cache->fetch('nt'), $cache->increment('nt', 1, 3600), $cache->fetch('nt'));
+$cache->store('text', 'not a number');
+var_dump($cache->fetch('text'));
+try {
+ $cache->increment('text');
+} catch (Throwable $e) {
+ echo get_class($e), "\n";
+}
+var_dump($cache->fetch('text'));
+
+echo "rollback:\n";
+$cache->store('r', 'old');
+var_dump($cache->fetch('r'));
+var_dump($cache->storeMultiple(['r' => 'new', 'huge' => str_repeat('x', 32 * 1024 * 1024)]));
+var_dump($cache->fetch('r'));
+in_child(function ($c) {
+ if ($c->fetch('r') !== 'old') {
+ exit(1);
+ }
+ $c->store('r', 'child');
+});
+var_dump($cache->fetch('r'));
+
+echo "zero-copy string stored under another key:\n";
+$result = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+$pid = pcntl_fork();
+if ($pid === 0) {
+ fclose($result[0]);
+ $c = UserCache\Cache::getPool('zero-copy-record');
+ $c->store('arr', [str_repeat('A', 300), str_repeat('B', 300)]);
+ $arr = $c->fetch('arr');
+ $c->store('dst', $arr[0]);
+ unset($arr);
+
+ /* The grandchild waits until this process has ended its request and released its pins. */
+ $exited = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+ if (pcntl_fork() === 0) {
+ fclose($exited[0]);
+ stream_get_contents($exited[1]);
+ $c->store('arr', 'replaced');
+ for ($len = 780; $len >= 200 && UserCache\Cache::getStatus()->getWastedMemory() > 0; $len -= 4) {
+ $c->store('fill' . $len, str_repeat('z', $len));
+ }
+ fwrite($result[1], $c->fetch('dst') === str_repeat('A', 300) ? 'intact' : 'corrupted');
+ exit(0);
+ }
+ exit(0);
+}
+fclose($result[1]);
+pcntl_waitpid($pid, $status);
+var_dump(stream_get_contents($result[0]));
+?>
+--EXPECT--
+has():
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+string(5) "child"
+bool(true)
+bool(false)
+delete():
+array(2) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+}
+bool(true)
+string(4) "gone"
+bool(false)
+string(5) "child"
+bool(true)
+bool(true)
+string(6) "absent"
+string(5) "child"
+deleteMultiple():
+int(1)
+bool(true)
+string(4) "gone"
+bool(false)
+string(4) "gone"
+string(5) "child"
+bool(false)
+counters:
+int(3)
+int(3)
+bool(true)
+int(13)
+int(11)
+int(11)
+int(-89)
+string(4) "gone"
+int(1)
+int(1)
+int(5)
+int(5)
+bool(true)
+int(6)
+int(7)
+int(7)
+string(12) "not a number"
+ValueError
+string(12) "not a number"
+rollback:
+string(3) "old"
+bool(false)
+string(3) "old"
+string(5) "child"
+zero-copy string stored under another key:
+string(6) "intact"
diff --git a/ext/user_cache/tests/user_cache_key_record_trim_bailout.phpt b/ext/user_cache/tests/user_cache_key_record_trim_bailout.phpt
new file mode 100644
index 000000000000..1ac8e4908411
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_key_record_trim_bailout.phpt
@@ -0,0 +1,36 @@
+--TEST--
+UserCache\Cache: a bailout while the record limit drops a pool's inline records leaves every record owned exactly once
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+memory_limit=64M
+--FILE--
+has('first');
+$pool->remember('held', function () use ($pool, $filler) {
+ $pool->has('third');
+ $pool->has('fourth');
+
+ /* Long keys push the records over the 8 MiB limit, and the trim bails out after it reset the dropped records. */
+ putenv('USER_CACHE_DEBUG_FORCE_KEY_RECORD_TRIM_BAILOUT=1');
+ echo "filling\n";
+ for ($i = 0; $i < 200; $i++) {
+ $filler->has(sprintf('%010d', $i) . str_repeat('k', 60000));
+ }
+ echo "not trimmed\n";
+
+ return 1;
+});
+?>
+--EXPECT--
+filling
diff --git a/ext/user_cache/tests/user_cache_key_records.phpt b/ext/user_cache/tests/user_cache_key_records.phpt
new file mode 100644
index 000000000000..b2e3e5db5048
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_key_records.phpt
@@ -0,0 +1,148 @@
+--TEST--
+UserCache\Cache: per-key records cache fetched values without serving stale or shared state
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+store($this->target, str_repeat('n', 300));
+ }
+}
+
+$cache = UserCache\Cache::getPool('records');
+$array = ['list' => range(1, 8), 'text' => str_repeat('t', 300)];
+$long = str_repeat('L', 5000);
+
+/* Cached values stay private to each fetch. */
+$cache->store('array', $array);
+$cache->store('string', str_repeat('s', 300));
+$cache->store('long', $long);
+for ($i = 0; $i < 3; $i++) {
+ $value = $cache->fetch('array');
+ $value['list'][0] = -1;
+ $value['text'][0] = 'x';
+ $string = $cache->fetch('string');
+ $string[0] = 'x';
+ $big = $cache->fetch('long');
+ $big[0] = 'x';
+}
+var_dump($cache->fetch('array') === $array, $cache->fetch('string') === str_repeat('s', 300), $cache->fetch('long') === $long);
+
+/* Writes to other keys keep cached values valid; writes to the key replace them. */
+$cache->store('other', 1);
+var_dump($cache->fetch('array') === $array);
+$cache->store('array', ['replaced']);
+var_dump($cache->fetch('array'));
+
+/* Deletion, re-addition and misses. */
+$cache->fetch('gone', 'unused');
+$cache->store('gone', 'here');
+var_dump($cache->fetch('gone'), $cache->delete('gone'), $cache->fetch('gone', 'default'), $cache->has('gone'));
+$cache->store('gone', 'again');
+var_dump($cache->fetch('gone'), $cache->has('gone'));
+
+/* Scalar overwrites and counters update the record they read through. */
+$cache->store('counter', 1);
+$cache->store('counter', 2);
+var_dump($cache->fetch('counter'), $cache->increment('counter', 5), $cache->fetch('counter'));
+$cache->store('counter', 'text');
+var_dump($cache->fetch('counter'));
+
+/* More keys than a pool keeps inline, including numeric strings. */
+$values = [];
+for ($i = 0; $i < 20; $i++) {
+ $values[(string) $i] = [$i];
+}
+var_dump($cache->storeMultiple($values));
+$fetched = $cache->fetchMultiple(array_keys($values));
+var_dump($fetched === $values, $cache->fetch('7') === [7], $cache->fetch('19') === [19]);
+
+/* A __wakeup() running inside fetchMultiple() rewrites a key fetched earlier. */
+$cache->store('early', str_repeat('o', 300));
+$trigger = new OverwriteOnWakeup();
+$trigger->target = 'early';
+$cache->store('trigger', $trigger);
+$both = $cache->fetchMultiple(['early', 'trigger']);
+var_dump($both['early'] === str_repeat('o', 300), $cache->fetch('early') === str_repeat('n', 300));
+
+/* Cached values follow expiry and type changes of their entry. */
+$cache->store('ttl', $array, 1);
+$cache->fetch('ttl');
+$cache->fetch('ttl');
+usleep(1100000);
+var_dump($cache->fetch('ttl', 'expired'));
+$cache->store('ttl', $array);
+$cache->fetch('ttl');
+$cache->store('ttl', 123);
+var_dump($cache->fetch('ttl'));
+
+/* clear() and deletePool() drop the pool's cached values. */
+var_dump($cache->clear(), $cache->fetch('string', 'cleared'), $cache->fetch('counter', 'cleared'));
+$cache->store('string', 'restored');
+var_dump($cache->fetch('string'));
+var_dump(UserCache\Cache::deletePool('records'), $cache->fetch('string', 'deleted'));
+$fresh = UserCache\Cache::getPool('records');
+$fresh->store('string', 'fresh');
+var_dump($fresh->fetch('string'), $cache->fetch('string'));
+
+/* Records made before fork() revalidate in both processes. */
+$fresh->store('shared', ['parent']);
+$fresh->fetch('shared');
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $ok = $fresh->fetch('shared') === ['parent'];
+ $fresh->store('shared', ['child']);
+ exit($ok && $fresh->fetch('shared') === ['child'] ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status), $fresh->fetch('shared'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+array(1) {
+ [0]=>
+ string(8) "replaced"
+}
+string(4) "here"
+bool(true)
+string(7) "default"
+bool(false)
+string(5) "again"
+bool(true)
+int(2)
+int(7)
+int(7)
+string(4) "text"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(7) "expired"
+int(123)
+bool(true)
+string(7) "cleared"
+string(7) "cleared"
+string(8) "restored"
+bool(true)
+string(7) "deleted"
+string(5) "fresh"
+string(5) "fresh"
+int(0)
+array(1) {
+ [0]=>
+ string(5) "child"
+}
diff --git a/ext/user_cache/tests/user_cache_key_records_keys.phpt b/ext/user_cache/tests/user_cache_key_records_keys.phpt
new file mode 100644
index 000000000000..8240952388c5
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_key_records_keys.phpt
@@ -0,0 +1,148 @@
+--TEST--
+UserCache\Cache: per-key records preserve colliding, binary and long keys across pool lifecycles
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+ $key) {
+ $ok = $cache->store($key, $i) && $other->store($key, -$i - 1) && $ok;
+}
+/* Keys whose hashes share their low bits still resolve to their own records. */
+for ($round = 0; $round < 16; $round++) {
+ foreach (array_reverse($keys, true) as $i => $key) {
+ $copy = substr('!' . $key, 1);
+ $ok = $cache->fetch($copy) === $i && $other->fetch($copy) === -$i - 1 && $ok;
+ }
+}
+var_dump($ok);
+
+/* Promote the inline records to a table, grow it, and revisit earlier keys. */
+$values = [];
+for ($i = 0; $i < 4096; $i++) {
+ $values['working-set:' . $i] = $i;
+}
+var_dump($cache->storeMultiple($values));
+var_dump($cache->fetchMultiple(array_keys($values)) === $values);
+$ok = true;
+foreach (array_reverse($values, true) as $key => $value) {
+ $copy = substr('!' . $key, 1);
+ $ok = $cache->fetch($copy) === $value && $ok;
+}
+foreach ($keys as $i => $key) {
+ $ok = $cache->fetch($key) === $i && $ok;
+}
+var_dump($ok);
+
+/* Keys sharing a hash bucket still compare by content. */
+$colliding = [];
+for ($i = 0; count($colliding) < 4; $i++) {
+ $key = 'same-slot:' . $i;
+ if (lowHash($key) === 0) {
+ $colliding[$key] = $i;
+ }
+}
+var_dump($cache->storeMultiple($colliding));
+var_dump($cache->fetchMultiple(array_keys($colliding)) === $colliding);
+
+$long = str_repeat('long', 16000) . "\0tail";
+var_dump($cache->store($long, 123), $cache->fetch($long), $cache->has($long));
+var_dump($cache->delete($long), $cache->fetch($long, 'missing'));
+$longPool = UserCache\Cache::getPool(str_repeat('pool', 1024));
+var_dump($longPool->store('short', 7), $longPool->fetch('short'));
+
+/* The pool name, the delimiter and the key together are at most 65535 bytes. */
+$limit = 65535 - strlen("key-cache\0pool") - 1;
+var_dump($cache->store(str_repeat('k', $limit), 'at the limit'), $cache->fetch(str_repeat('k', $limit)));
+$tooLong = str_repeat('k', $limit + 1);
+foreach ([
+ fn () => $cache->store($tooLong, 1),
+ fn () => $cache->fetchMultiple([$tooLong]),
+ fn () => $cache->storeMultiple([$tooLong => 1]),
+] as $call) {
+ try {
+ $call();
+ } catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+ }
+}
+try {
+ UserCache\Cache::getPool(str_repeat('p', 65535 - strlen((string) PHP_INT_MIN)));
+} catch (ValueError $e) {
+ var_dump($e->getMessage() === 'UserCache\Cache::getPool(): Argument #1 ($pool) must not be longer than ' . (65534 - strlen((string) PHP_INT_MIN)) . ' bytes');
+}
+$longestPool = UserCache\Cache::getPool(str_repeat('p', 65534 - strlen((string) PHP_INT_MIN)));
+var_dump($longestPool->store(PHP_INT_MIN, 'integer key'), $longestPool->fetch(PHP_INT_MIN));
+
+var_dump(UserCache\Cache::deletePool("key-cache\0pool"));
+$new = UserCache\Cache::getPool("key-cache\0pool");
+var_dump($new !== $cache, $new->fetch($keys[0], 'missing'));
+var_dump($cache->store($keys[0], 900), $new->fetch($keys[0]));
+var_dump($other->fetch($keys[0]));
+var_dump($new->clear(), $cache->fetch($keys[0], 'missing'));
+
+foreach (['', "invalid\x1fkey"] as $invalid) {
+ try {
+ $cache->fetch($invalid);
+ } catch (ValueError $exception) {
+ echo "invalid key rejected\n";
+ }
+}
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(123)
+bool(true)
+bool(true)
+string(7) "missing"
+bool(true)
+int(7)
+bool(true)
+string(12) "at the limit"
+UserCache\Cache::store(): Argument #1 ($key) must not be longer than 65520 bytes
+UserCache\Cache::fetchMultiple(): Argument #1 ($keys) must contain only cache keys that are not longer than 65520 bytes
+UserCache\Cache::storeMultiple(): Argument #1 ($values) must be an array whose keys are not longer than 65520 bytes
+bool(true)
+bool(true)
+string(11) "integer key"
+bool(true)
+bool(true)
+string(7) "missing"
+bool(true)
+int(900)
+int(-1)
+bool(true)
+string(7) "missing"
+invalid key rejected
+invalid key rejected
diff --git a/ext/user_cache/tests/user_cache_key_records_limit.phpt b/ext/user_cache/tests/user_cache_key_records_limit.phpt
new file mode 100644
index 000000000000..eb3dc79180e5
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_key_records_limit.phpt
@@ -0,0 +1,327 @@
+--TEST--
+UserCache bounds key records per request by the entry capacity, also for pools deleted while in use, drops the older half at the limit and never drops records held by an active, suspended or fetchMultiple() call
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+has("$prefix-$i");
+ }
+}
+
+function key_records_deleted_pool(bool $measured): void
+{
+ $limit = max(16384, UserCache\Cache::getStatus()->getEntryCapacity());
+
+ $registered = UserCache\Cache::getPool('records-registered');
+ $base = memory_get_usage();
+ touch_keys($registered, 'key', 4 * $limit);
+ $bounded = memory_get_usage() - $base;
+
+ /* The object keeps working after its pool is deleted and is no longer registered. */
+ $deleted = UserCache\Cache::getPool('records-deleted');
+ var_dump($deleted->store('x', 1), UserCache\Cache::deletePool('records-deleted'));
+ $base = memory_get_usage();
+ touch_keys($deleted, 'key', 4 * $limit);
+ var_dump(!$measured || memory_get_usage() - $base < 2 * $bounded);
+ var_dump($registered->has('key-0') === false, $deleted->store('y', 2), $deleted->fetch('y'));
+
+ UserCache\Cache::deletePool('records-registered');
+}
+
+class TouchesManyKeys
+{
+ public $n = 1;
+
+ public function __wakeup(): void
+ {
+ static $nested = false;
+
+ if ($nested) {
+ return;
+ }
+
+ $nested = true;
+ $cache = UserCache\Cache::getPool('records-limit');
+
+ touch_keys($cache, 'wakeup', $GLOBALS['limit']);
+
+ $cache->fetch('outer');
+ }
+}
+
+class SuspendsOnWakeup
+{
+ public $n = 1;
+
+ public function __wakeup(): void
+ {
+ Fiber::suspend();
+ }
+}
+
+/* Without the Zend memory manager memory_get_usage() reports 0. */
+$measured = getenv('USE_ZEND_ALLOC') !== '0';
+
+echo "deleted pool:\n";
+key_records_deleted_pool($measured);
+
+echo "\nrecord limit:\n";
+$cache = UserCache\Cache::getPool('records-limit');
+/* The limit is the entry capacity, but at least 16384 records. */
+$limit = max(16384, UserCache\Cache::getStatus()->getEntryCapacity());
+
+$base = memory_get_usage();
+touch_keys($cache, 'warm', $limit - 1);
+$full = memory_get_usage() - $base;
+touch_keys($cache, 'cross', 2);
+$trimmed = memory_get_usage() - $base;
+var_dump(!$measured || ($trimmed > $full / 3 && $trimmed < $full * 2 / 3));
+touch_keys($cache, 'key', 3 * $limit);
+var_dump(memory_get_usage() - $base <= $full * 1.1);
+
+/* The limit also applies while code runs inside a fiber. */
+$fiber = new Fiber(function () use ($cache, $limit): int {
+ $base = memory_get_usage();
+ touch_keys($cache, 'fiber-key', 3 * $limit);
+ return memory_get_usage() - $base;
+});
+$fiber->start();
+var_dump($fiber->getReturn() <= $full * 1.1);
+
+/* Records of many pools are bounded together. */
+$base = memory_get_usage();
+for ($p = 0; $p < 64; $p++) {
+ touch_keys(UserCache\Cache::getPool("records-limit-$p"), 'key', intdiv(3 * $limit, 64));
+}
+var_dump(memory_get_usage() - $base <= $full * 1.1);
+
+$cache->store('outer', new TouchesManyKeys);
+var_dump($cache->fetch('outer') instanceof TouchesManyKeys);
+
+var_dump($cache->remember('remembered', function () use ($cache, $limit) {
+ touch_keys($cache, 'callback', $limit);
+ $cache->store('callback-last', 4999);
+
+ return 'value';
+}));
+var_dump($cache->fetch('remembered'), $cache->fetch('callback-last'));
+
+var_dump($cache->fetchMultiple(['outer', 'remembered', 'missing'], 'default')['remembered']);
+
+/* Restore hooks cannot suspend the fiber of a fetch; a fiber suspended inside a callback still holds the record of its call. */
+$cache->store('suspends', new SuspendsOnWakeup);
+$fiber = new Fiber(function () use ($cache): array {
+ try {
+ $woken = $cache->fetch('suspends');
+ } catch (FiberError $e) {
+ $woken = $e->getMessage();
+ }
+
+ return [
+ $woken,
+ $cache->remember('in-fiber', function () {
+ Fiber::suspend();
+
+ return 'from fiber';
+ }),
+ ];
+});
+$fiber->start();
+touch_keys($cache, 'while-suspended', $limit);
+$fiber->resume();
+[$woken, $remembered] = $fiber->getReturn();
+var_dump($woken, $remembered, $cache->fetch('in-fiber'));
+
+UserCache\Cache::deletePool('records-limit');
+for ($p = 0; $p < 64; $p++) {
+ UserCache\Cache::deletePool("records-limit-$p");
+}
+unset($cache, $fiber);
+
+function key_records_held_by_calls(): void
+{
+ $pool = Cache::getPool('key-records-held-by-calls');
+ $limit = max(16384, Cache::getStatus()->getEntryCapacity());
+ $bound = 8 << 20;
+
+ $fiber = new Fiber(fn () => $pool->remember('slow', function () {
+ Fiber::suspend();
+
+ return 'computed';
+ }));
+ $fiber->start();
+ $base = memory_get_usage();
+ for ($i = 0; $i < 3 * $limit; $i++) {
+ $pool->has("suspended-$i");
+ }
+ echo 'bounded while a call is suspended: ', var_export(memory_get_usage() - $base < $bound, true), "\n";
+ $fiber->resume();
+ var_dump($fiber->getReturn(), $pool->fetch('slow'));
+
+ $base = memory_get_usage();
+ $peak = 0;
+ $dependencies = 50;
+ for ($i = 0; $i < intdiv(3 * $limit, $dependencies + 1); $i++) {
+ $pool->remember("item-$i", function () use ($pool, $dependencies, $i) {
+ for ($j = 0; $j < $dependencies; $j++) {
+ $pool->has("dependency-$i-$j");
+ }
+
+ return $i;
+ });
+ $peak = max($peak, memory_get_usage() - $base);
+ }
+ echo 'bounded while records are created inside callbacks: ', var_export($peak < $bound, true), "\n";
+
+ Cache::deletePool('key-records-held-by-calls');
+}
+
+echo "\nheld by calls:\n";
+key_records_held_by_calls();
+
+echo "\nfetchMultiple reference keys:\n";
+
+final class Renames
+{
+ public $v = 1;
+
+ public function __wakeup(): void
+ {
+ global $key, $other, $renamed;
+
+ if ($renamed) {
+ return;
+ }
+
+ $renamed = true;
+ $key = 'renamed-by-hook';
+ $limit = max(16384, Cache::getStatus()->getEntryCapacity());
+ for ($i = 0; $i < $limit + 10; $i++) {
+ $other->has("other-$i");
+ }
+ }
+}
+
+$renamed = false;
+$pool = Cache::getPool('fetch-multiple-reference-keys');
+$other = Cache::getPool('fetch-multiple-reference-keys-other');
+$pool->store('a', new Renames);
+$pool->store('b', [new ArrayObject([1, 2]), 'x']);
+$key = 'b';
+$keys = ['a', &$key];
+$fetched = $pool->fetchMultiple($keys);
+var_dump(array_keys($fetched), $fetched['b'][0]->getArrayCopy(), $fetched['b'][1], $key);
+
+echo "\ntrim destructor throws:\n";
+
+final class TrimThrower
+{
+ public $self;
+
+ public function __destruct()
+ {
+ throw new Exception('from destructor during trim');
+ }
+}
+
+function key_records_trim_destructor_throws(): void
+{
+ $cache = Cache::getPool('trim-destructor-throws');
+ $other = Cache::getPool('trim-destructor-throws-other');
+ $limit = max(16384, Cache::getStatus()->getEntryCapacity());
+
+ /* The pinned filler exhausts the request's pin budget, so the next array is a private copy held by its record. */
+ $cache->store('filler', range(1, 300000));
+ $cache->store('copied', range(1, 300000));
+ $filler = $cache->fetch('filler');
+ $copied = $cache->fetch('copied');
+
+ $keys = [];
+ for ($i = 0; $i < $limit + 100; $i++) {
+ $keys[] = "k$i";
+ }
+ $other->fetchMultiple($keys);
+ unset($keys);
+
+ gc_collect_cycles();
+
+ $thrower = new TrimThrower();
+ $thrower->self = $thrower;
+ unset($thrower);
+
+ $roots = [];
+ $status = gc_status();
+ for ($i = $status['threshold'] - $status['roots'] - 1; $i > 0; $i--) {
+ $roots[$i] = [$i];
+ $root = $roots[$i];
+ unset($root);
+ }
+
+ try {
+ var_dump($cache->lock('trim-lock'));
+ } catch (Exception $e) {
+ echo $e->getMessage(), "\n";
+ }
+
+ var_dump($cache->unlock('trim-lock'), count($copied), count($filler));
+}
+
+key_records_trim_destructor_throws();
+?>
+--EXPECT--
+deleted pool:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(2)
+
+record limit:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(5) "value"
+string(5) "value"
+int(4999)
+string(5) "value"
+string(49) "Cannot switch fibers in current execution context"
+string(10) "from fiber"
+string(10) "from fiber"
+
+held by calls:
+bounded while a call is suspended: true
+string(8) "computed"
+string(8) "computed"
+bounded while records are created inside callbacks: true
+
+fetchMultiple reference keys:
+array(2) {
+ [0]=>
+ string(1) "a"
+ [1]=>
+ string(1) "b"
+}
+array(2) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+}
+string(1) "x"
+string(15) "renamed-by-hook"
+
+trim destructor throws:
+from destructor during trim
+bool(false)
+int(300000)
+int(300000)
diff --git a/ext/user_cache/tests/user_cache_lazy_proxy_initialized.phpt b/ext/user_cache/tests/user_cache_lazy_proxy_initialized.phpt
new file mode 100644
index 000000000000..5669c35c62d1
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lazy_proxy_initialized.phpt
@@ -0,0 +1,100 @@
+--TEST--
+UserCache\Cache: an initialized lazy proxy is stored like serialize() stores it, while uninitialized lazy objects, including one inserted by a serialization hook, are rejected without running their initializer
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+newLazyProxy(function (): Point {
+ $point = new Point;
+ $point->x = 7;
+
+ return $point;
+});
+try {
+ $cache->store('proxy', $proxy);
+} catch (TypeError $e) {
+ echo 'uninitialized proxy: ', $e->getMessage(), "\n";
+}
+
+var_dump($proxy->x);
+var_dump($cache->store('proxy', $proxy));
+var_dump(serialize($cache->fetch('proxy')) === serialize(unserialize(serialize($proxy))));
+var_dump($class->isUninitializedLazyObject($cache->fetch('proxy')), $cache->fetch('proxy')->x);
+
+$nested = ['proxy' => $proxy, 'again' => $proxy];
+var_dump($cache->store('nested', $nested));
+$fetched = $cache->fetch('nested');
+var_dump($fetched['proxy'] === $fetched['again'], $fetched['proxy']->x);
+
+class Ghost
+{
+ public $v = 0;
+}
+
+class InsertsGhost
+{
+ public $holder;
+
+ public function __serialize(): array
+ {
+ $this->holder->ghost = (new ReflectionClass(Ghost::class))->newLazyGhost(function (Ghost $ghost): void {
+ echo "initializer ran\n";
+ $ghost->v = 42;
+ });
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+function store_hook_inserts_lazy_object(): void
+{
+ $holder = new stdClass;
+ $holder->ghost = null;
+ $hook = new InsertsGhost;
+ $hook->holder = $holder;
+
+ $cache = UserCache\Cache::getPool('store-hook-inserts-lazy-object');
+ try {
+ var_dump($cache->store('k', [$holder, $hook]));
+ } catch (TypeError $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+ }
+ var_dump($cache->fetch('k', 'MISS'));
+ var_dump((new ReflectionClass(Ghost::class))->isUninitializedLazyObject($holder->ghost));
+
+ /* Release builds do not collect cycles at shutdown. */
+ $holder = $hook = $e = null;
+ gc_collect_cycles();
+}
+
+echo "\nstore hook inserts lazy object:\n";
+store_hook_inserts_lazy_object();
+?>
+--EXPECT--
+uninitialized proxy: Uninitialized lazy objects cannot be stored in the user cache
+int(7)
+bool(true)
+bool(true)
+bool(false)
+int(7)
+bool(true)
+bool(true)
+int(7)
+
+store hook inserts lazy object:
+TypeError: Uninitialized lazy objects cannot be stored in the user cache
+string(4) "MISS"
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_litespeed_boundary.phpt b/ext/user_cache/tests/user_cache_litespeed_boundary.phpt
new file mode 100644
index 000000000000..9444a8b98a9d
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_litespeed_boundary.phpt
@@ -0,0 +1,380 @@
+--TEST--
+UserCache\Cache: litespeed partitions cache data by boundary
+--CONFLICTS--
+all
+--SKIPIF--
+
+--FILE--
+ $value) {
+ $keyString = $key . "\0";
+ $valueString = $value . "\0";
+ $offsets[$key] = $baseOffset + strlen($block) + 4 + strlen($keyString);
+ $block .= pack('n', strlen($keyString));
+ $block .= pack('n', strlen($valueString));
+ $block .= $keyString;
+ $block .= $valueString;
+ }
+
+ return $block . "\0\0\0\0";
+}
+
+function user_cache_lsapi_request_packet(string $script, string $docRoot, string $serverName, string $query): string
+{
+ $offsets = [];
+ $specialBlock = "\0\0\0\0";
+ $envBase = LSAPI_REQ_HEADER_LEN + strlen($specialBlock);
+ $env = [
+ 'SCRIPT_FILENAME' => $script,
+ 'SCRIPT_NAME' => '/index.php',
+ 'QUERY_STRING' => $query,
+ 'REQUEST_METHOD' => 'GET',
+ 'SERVER_NAME' => $serverName,
+ 'DOCUMENT_ROOT' => $docRoot,
+ 'REQUEST_URI' => '/index.php' . ($query !== '' ? '?' . $query : ''),
+ 'SERVER_PROTOCOL' => 'HTTP/1.1',
+ 'REMOTE_ADDR' => '127.0.0.1',
+ ];
+ $envBlock = user_cache_lsapi_env_block($env, $envBase, $offsets);
+ $body = $specialBlock . $envBlock;
+ $body .= str_repeat("\0", (8 - ((LSAPI_REQ_HEADER_LEN + strlen($body)) % 8)) & 7);
+ $body .= str_repeat("\0", LSAPI_HTTP_HEADER_INDEX_LEN);
+ $length = LSAPI_REQ_HEADER_LEN + strlen($body);
+
+ return user_cache_lsapi_packet_header(LSAPI_BEGIN_REQUEST, $length)
+ . user_cache_lsapi_pack_int(0)
+ . user_cache_lsapi_pack_int(0)
+ . user_cache_lsapi_pack_int($offsets['SCRIPT_FILENAME'])
+ . user_cache_lsapi_pack_int($offsets['SCRIPT_NAME'])
+ . user_cache_lsapi_pack_int($offsets['QUERY_STRING'])
+ . user_cache_lsapi_pack_int($offsets['REQUEST_METHOD'])
+ . user_cache_lsapi_pack_int(0)
+ . user_cache_lsapi_pack_int(count($env))
+ . user_cache_lsapi_pack_int(0)
+ . $body;
+}
+
+function user_cache_lsapi_read_exact($fp, int $length): string
+{
+ $buffer = '';
+
+ while (strlen($buffer) < $length && !feof($fp)) {
+ $chunk = fread($fp, $length - strlen($buffer));
+ if ($chunk === false) {
+ throw new RuntimeException('Failed to read LSAPI response');
+ }
+ if ($chunk === '') {
+ if (stream_get_meta_data($fp)['timed_out']) {
+ throw new RuntimeException('Timed out reading LSAPI response');
+ }
+ usleep(10000);
+ continue;
+ }
+ $buffer .= $chunk;
+ }
+
+ if (strlen($buffer) !== $length) {
+ throw new RuntimeException('Truncated LSAPI response');
+ }
+
+ return $buffer;
+}
+
+function user_cache_lsapi_request(int $port, string $script, string $docRoot, string $serverName, string $query): string
+{
+ $fp = @stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 2);
+ if ($fp === false) {
+ throw new RuntimeException($errstr);
+ }
+ stream_set_timeout($fp, 5);
+
+ fwrite($fp, user_cache_lsapi_request_packet($script, $docRoot, $serverName, $query));
+ $body = '';
+
+ while (!feof($fp)) {
+ try {
+ $header = user_cache_lsapi_read_exact($fp, 8);
+ } catch (RuntimeException $e) {
+ if ($body !== '' && $e->getMessage() === 'Truncated LSAPI response') {
+ break;
+ }
+ throw $e;
+ }
+ if (substr($header, 0, 2) !== 'LS') {
+ throw new RuntimeException('Invalid LSAPI response header');
+ }
+
+ $type = ord($header[2]);
+ $length = user_cache_lsapi_unpack_int(substr($header, 4, 4));
+ $payload = $length > 8 ? user_cache_lsapi_read_exact($fp, $length - 8) : '';
+
+ if ($type === LSAPI_RESP_STREAM) {
+ $body .= $payload;
+ } elseif ($type === LSAPI_RESP_END || $type === LSAPI_CONN_CLOSE) {
+ break;
+ }
+ }
+
+ fclose($fp);
+
+ return trim($body);
+}
+
+function user_cache_lsapi_free_port(): int
+{
+ $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+ if ($server === false) {
+ throw new RuntimeException($errstr);
+ }
+
+ $name = stream_socket_get_name($server, false);
+ fclose($server);
+
+ return (int) substr(strrchr($name, ':'), 1);
+}
+
+function user_cache_lsapi_rm_rf(string $path): void
+{
+ if (!file_exists($path)) {
+ return;
+ }
+
+ if (!is_dir($path) || is_link($path)) {
+ unlink($path);
+ return;
+ }
+
+ foreach (scandir($path) as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ user_cache_lsapi_rm_rf($path . DIRECTORY_SEPARATOR . $entry);
+ }
+
+ rmdir($path);
+}
+
+function user_cache_lsapi_start(string $lsphp, string $iniDir, int $port): array
+{
+ $env = array_merge($_ENV, [
+ 'PHP_LSAPI_CHILDREN' => '1',
+ 'LSAPI_PPID_NO_CHECK' => '1',
+ 'LSAPI_MAX_REQS' => '100',
+ 'LSAPI_CLEAN_SHUTDOWN' => '1',
+ ]);
+ $process = proc_open(
+ [$lsphp, '-c', $iniDir, '-b', "127.0.0.1:$port"],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $pipes,
+ $iniDir,
+ $env
+ );
+ if (!is_resource($process)) {
+ throw new RuntimeException('Unable to start lsphp');
+ }
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ return [$process, $pipes];
+}
+
+function user_cache_lsapi_stop($process, array $pipes): void
+{
+ if (is_resource($process)) {
+ proc_terminate($process);
+
+ for ($i = 0; $i < 20; $i++) {
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ break;
+ }
+ usleep(100000);
+ }
+
+ $status = proc_get_status($process);
+ if ($status['running']) {
+ proc_terminate($process, 9);
+ }
+ }
+
+ foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+
+ if (is_resource($process)) {
+ proc_close($process);
+ }
+}
+
+function user_cache_lsapi_wait($process, array $pipes, int $port, string $script, string $docRoot, string $serverName): void
+{
+ for ($i = 0; $i < 50; $i++) {
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ throw new RuntimeException(stream_get_contents($pipes[2]));
+ }
+
+ try {
+ user_cache_lsapi_request($port, $script, $docRoot, $serverName, 'action=fetch');
+ return;
+ } catch (Throwable) {
+ usleep(100000);
+ }
+ }
+
+ throw new RuntimeException(stream_get_contents($pipes[2]) ?: 'lsphp did not become ready');
+}
+
+$root = sys_get_temp_dir() . '/php-user-cache-lsapi-' . getmypid();
+$alphaRoot = $root . '/alpha';
+$betaRoot = $root . '/beta';
+$otherRoot = $root . '/other';
+$processes = [];
+
+user_cache_lsapi_rm_rf($root);
+mkdir($alphaRoot, 0777, true);
+mkdir($betaRoot, 0777, true);
+mkdir($otherRoot, 0777, true);
+
+$script = <<<'PHP'
+clear();
+} elseif ($action === 'seed') {
+ $cache->store($key, $host . '-value');
+}
+
+echo $host, ':', $cache->fetch($key, 'MISS'), ':', $cache->getPoolStatus()->getPoolName(), "\n";
+PHP;
+
+file_put_contents($alphaRoot . '/index.php', $script);
+file_put_contents($betaRoot . '/index.php', $script);
+file_put_contents($otherRoot . '/index.php', $script);
+file_put_contents($root . '/php.ini', implode("\n", [
+ 'user_cache.enable=1',
+ 'user_cache.shm_size=32M',
+ 'user_cache.lockfile_path=' . $root,
+ 'opcache.file_update_protection=0',
+]));
+
+try {
+ $lsphp = user_cache_lsapi_binary();
+ $alphaPort = user_cache_lsapi_free_port();
+ $betaPort = user_cache_lsapi_free_port();
+ $alphaScript = $alphaRoot . '/index.php';
+ $betaScript = $betaRoot . '/index.php';
+ $otherScript = $otherRoot . '/index.php';
+
+ $processes[] = user_cache_lsapi_start($lsphp, $root, $alphaPort);
+ $processes[] = user_cache_lsapi_start($lsphp, $root, $betaPort);
+
+ user_cache_lsapi_wait($processes[0][0], $processes[0][1], $alphaPort, $alphaScript, $alphaRoot, 'alpha.local');
+ user_cache_lsapi_wait($processes[1][0], $processes[1][1], $betaPort, $betaScript, $betaRoot, 'beta.local');
+
+ user_cache_lsapi_request($alphaPort, $alphaScript, $alphaRoot, 'alpha.local', 'action=clear');
+ user_cache_lsapi_request($alphaPort, $otherScript, $otherRoot, 'other.local', 'action=clear');
+ user_cache_lsapi_request($betaPort, $betaScript, $betaRoot, 'beta.local', 'action=clear');
+
+ $checks = [
+ [$alphaPort, $alphaScript, $alphaRoot, 'alpha.local', 'action=seed', 'alpha.local:alpha.local-value:default'],
+ [$alphaPort, $alphaScript, $alphaRoot, 'alpha.local', 'action=fetch', 'alpha.local:alpha.local-value:default'],
+ [$alphaPort, $otherScript, $otherRoot, 'other.local', 'action=fetch', 'other.local:MISS:default'],
+ [$alphaPort, $otherScript, $otherRoot, 'other.local', 'action=seed', 'other.local:other.local-value:default'],
+ [$alphaPort, $otherScript, $otherRoot, 'other.local', 'action=fetch', 'other.local:other.local-value:default'],
+ [$alphaPort, $alphaScript, $alphaRoot, 'alpha.local', 'action=fetch', 'alpha.local:alpha.local-value:default'],
+ [$betaPort, $betaScript, $betaRoot, 'beta.local', 'action=fetch', 'beta.local:MISS:default'],
+ [$betaPort, $betaScript, $betaRoot, 'beta.local', 'action=seed', 'beta.local:beta.local-value:default'],
+ [$alphaPort, $alphaScript, $alphaRoot, 'alpha.local', 'action=fetch', 'alpha.local:alpha.local-value:default'],
+ [$betaPort, $betaScript, $betaRoot, 'beta.local', 'action=fetch', 'beta.local:beta.local-value:default'],
+ ];
+
+ foreach ($checks as [$port, $file, $docRoot, $serverName, $query, $expected]) {
+ $actual = user_cache_lsapi_request($port, $file, $docRoot, $serverName, $query);
+ if ($actual !== $expected) {
+ $stderr = stream_get_contents($port === $alphaPort ? $processes[0][1][2] : $processes[1][1][2]);
+ throw new RuntimeException("Expected $expected, got $actual" . ($stderr !== '' ? "\n$stderr" : ''));
+ }
+ }
+
+ echo "Done\n";
+} finally {
+ foreach ($processes as [$process, $pipes]) {
+ user_cache_lsapi_stop($process, $pipes);
+ }
+ FcgiTester::removeBoundarySegments($root);
+ user_cache_lsapi_rm_rf($root);
+}
+
+?>
+--EXPECT--
+Done
diff --git a/ext/user_cache/tests/user_cache_lock_full_segment_evicts.phpt b/ext/user_cache/tests/user_cache_lock_full_segment_evicts.phpt
new file mode 100644
index 000000000000..7cefa7a26250
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_full_segment_evicts.phpt
@@ -0,0 +1,130 @@
+--TEST--
+UserCache\Cache: under LRU a full segment makes room for lock() like for a store, clears the cache when only an expired lock record kept it fragmented, and counts each evicted entry once
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.eviction_policy=lru
+--FILE--
+getInt(0, 5)) {
+ case 0:
+ return str_repeat('a', $randomizer->getInt(1, 200));
+ case 1:
+ return str_repeat('b', $randomizer->getInt(300, 5000));
+ case 2:
+ return str_repeat('c', $randomizer->getInt(5000, 60000));
+ case 3:
+ return range(1, $randomizer->getInt(1, 300));
+ case 4:
+ return $randomizer->getInt(PHP_INT_MIN, PHP_INT_MAX);
+ default:
+ $value = [];
+ for ($i = $randomizer->getInt(1, 50); $i > 0; $i--) {
+ $value["k$i"] = str_repeat('x', $randomizer->getInt(0, 400));
+ }
+
+ return $value;
+ }
+}
+
+function eviction_count_matches_removed(): void
+{
+ foreach ([1, 2, 4] as $seed) {
+ $randomizer = new Randomizer(new Xoshiro256StarStar($seed));
+ $cache = Cache::getPool("eviction-count-$seed");
+ $mismatches = 0;
+
+ for ($op = 0; $op < 3000; $op++) {
+ $key = 'k' . $randomizer->getInt(0, 399);
+ $before = Cache::getStatus();
+ $existed = $cache->has($key);
+ $stored = $cache->store($key, value($randomizer));
+ $after = Cache::getStatus();
+
+ if (!$stored || $existed || $after->getExpungeCount() !== $before->getExpungeCount()) {
+ continue;
+ }
+
+ $removed = $before->getEntryCount() + 1 - $after->getEntryCount();
+ if ($after->getEvictionCount() - $before->getEvictionCount() !== $removed) {
+ $mismatches++;
+ }
+ }
+
+ echo "seed $seed: $mismatches mismatches\n";
+ $cache->clear();
+ }
+}
+
+function eviction_clear_after_expired_lock(): void
+{
+ $cache = Cache::getPool('eviction-clear-after-expired-lock');
+ $cache->clear();
+
+ var_dump($cache->store('front', str_repeat('f', 350000)));
+ var_dump($cache->lock('expired', 1));
+ var_dump($cache->store('back', str_repeat('b', 350000)));
+
+ sleep(2);
+
+ $before = Cache::getStatus();
+ var_dump($cache->store('huge', str_repeat('h', 700000)));
+ $after = Cache::getStatus();
+
+ var_dump($after->getExpungeCount() - $before->getExpungeCount());
+ var_dump(strlen($cache->fetch('huge')));
+ var_dump($cache->has('front'), $cache->has('back'));
+}
+
+echo "eviction count:\n";
+eviction_count_matches_removed();
+
+echo "\nlock evicts:\n";
+$cache = UserCache\Cache::getPool('lock-evicts');
+$cache->clear();
+
+for ($i = 0; $i < 400; $i++) {
+ $cache->store("fill-$i", str_repeat('x', 4000));
+}
+
+/* The lock key cannot fit into what is left without evicting entries. */
+$free = UserCache\Cache::getStatus()->getFreeMemory();
+var_dump($free < 8000);
+
+$key = str_repeat('k', $free + 1000);
+var_dump($cache->lock($key), $cache->unlock($key));
+var_dump($cache->remember($key, fn () => 'computed'), $cache->fetch($key));
+
+echo "\nclear after expired lock:\n";
+$cache->clear();
+eviction_clear_after_expired_lock();
+?>
+--EXPECT--
+eviction count:
+seed 1: 0 mismatches
+seed 2: 0 mismatches
+seed 4: 0 mismatches
+
+lock evicts:
+bool(true)
+bool(true)
+bool(true)
+string(8) "computed"
+string(8) "computed"
+
+clear after expired lock:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+int(700000)
+bool(false)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_lock_lease_deadlines.phpt b/ext/user_cache/tests/user_cache_lock_lease_deadlines.phpt
new file mode 100644
index 000000000000..118b9732c8bf
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_lease_deadlines.phpt
@@ -0,0 +1,185 @@
+--TEST--
+UserCache\Cache: lease renewal, unbounded locks, deadlines past request end (also after re-locking with or without a lease), takeover after expiry and what a stale holder can still do
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+lock($key) ? 0 : 1);
+ }
+ pcntl_waitpid($pid, $status);
+
+ return pcntl_wexitstatus($status) === 0;
+}
+
+$cache = UserCache\Cache::getPool('lease-deadlines');
+$cache->clear();
+
+echo "re-lock renews the lease\n";
+var_dump($cache->lock('renewed', 1));
+usleep(700000);
+$renewed = hrtime();
+var_dump($cache->lock('renewed', 1));
+usleep(700000);
+var_dump(!other_process_locks($cache, 'renewed') || seconds_since($renewed) >= 1);
+$cache->unlock('renewed');
+
+echo "a lease does not bound an unbounded lock\n";
+var_dump($cache->lock('unbounded'));
+var_dump($cache->lock('unbounded', 1));
+usleep(1200000);
+var_dump(other_process_locks($cache, 'unbounded'));
+var_dump($cache->unlock('unbounded'));
+
+echo "request end keeps the deadline set at lock()\n";
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $cache->store('left-at', hrtime());
+ $cache->lock('left', 2);
+ usleep(800000);
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+$left_at = $cache->fetch('left-at');
+var_dump(!other_process_locks($cache, 'left') || seconds_since($left_at) >= 2);
+usleep(max(0, (int) ((2.4 - seconds_since($left_at)) * 1000000)));
+var_dump(other_process_locks($cache, 'left'));
+
+echo "a lease blocks other processes until it expires, then passes to another process\n";
+$taken_at = hrtime();
+var_dump($cache->lock('taken', 1), $cache->lock('extended', 1));
+var_dump(!other_process_locks($cache, 'taken') || seconds_since($taken_at) >= 1);
+usleep(1200000);
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $cache->store('taken-ready', $cache->lock('taken') && $cache->lock('extended'));
+ while ($cache->fetch('taken-done') === null) {
+ usleep(10000);
+ }
+ $cache->store('taken-store', $cache->store('taken', 'new-holder'));
+ $cache->store('taken-unlock', $cache->unlock('taken') && $cache->unlock('extended'));
+ exit(0);
+}
+while (($taken = $cache->fetch('taken-ready')) === null) {
+ usleep(10000);
+}
+var_dump($taken);
+
+echo "unlock() after the lease passed to another process\n";
+var_dump($cache->unlock('taken'));
+
+echo "re-locking after the lease passed to another process does not extend it\n";
+var_dump($cache->lock('extended', 5));
+$cache->store('taken-done', true);
+pcntl_waitpid($pid, $status);
+
+echo "the new holder stores and unlocks\n";
+var_dump($cache->fetch('taken-store'), $cache->fetch('taken-unlock'), $cache->fetch('taken'));
+var_dump($cache->lock('taken'));
+var_dump($cache->unlock('taken'));
+
+echo "the stale holder exiting after the takeover leaves the new lock in place\n";
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $locked = $cache->lock('abandoned', 1);
+ $cache->store('abandoned-at', hrtime());
+ while ($cache->fetch('abandoned-done') === null) {
+ usleep(10000);
+ }
+ exit($locked ? 0 : 1);
+}
+while (($abandoned_at = $cache->fetch('abandoned-at')) === null) {
+ usleep(10000);
+}
+usleep(max(0, (int) ((1.2 - seconds_since($abandoned_at)) * 1000000)));
+var_dump($cache->lock('abandoned'));
+$cache->store('abandoned-done', true);
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+var_dump(other_process_locks($cache, 'abandoned'));
+var_dump($cache->unlock('abandoned'));
+var_dump(other_process_locks($cache, 'abandoned'));
+
+echo "unlock() after the lease expired\n";
+var_dump($cache->lock('expired', 1));
+usleep(1200000);
+var_dump($cache->unlock('expired'));
+var_dump($cache->lock('expired'));
+var_dump($cache->unlock('expired'));
+echo "re-locking a leased lock without a lease keeps its deadline after the holder exits\n";
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $cache->store('relocked-at', hrtime());
+ exit($cache->lock('relocked', 1) && $cache->lock('relocked') ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+$relocked_at = $cache->fetch('relocked-at');
+var_dump(!other_process_locks($cache, 'relocked') || seconds_since($relocked_at) >= 1);
+usleep(max(0, (int) ((1.4 - seconds_since($relocked_at)) * 1000000)));
+var_dump(other_process_locks($cache, 'relocked'));
+
+echo "a lease added to an unbounded lock does not keep it after the holder exits\n";
+$pid = pcntl_fork();
+if ($pid === 0) {
+ exit($cache->lock('unbounded-exit') && $cache->lock('unbounded-exit', 2) ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+var_dump(other_process_locks($cache, 'unbounded-exit'));
+?>
+--EXPECT--
+re-lock renews the lease
+bool(true)
+bool(true)
+bool(true)
+a lease does not bound an unbounded lock
+bool(true)
+bool(true)
+bool(false)
+bool(true)
+request end keeps the deadline set at lock()
+bool(true)
+bool(true)
+a lease blocks other processes until it expires, then passes to another process
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+unlock() after the lease passed to another process
+bool(false)
+re-locking after the lease passed to another process does not extend it
+bool(false)
+the new holder stores and unlocks
+bool(true)
+bool(true)
+string(10) "new-holder"
+bool(true)
+bool(true)
+the stale holder exiting after the takeover leaves the new lock in place
+bool(true)
+int(0)
+bool(false)
+bool(true)
+bool(true)
+unlock() after the lease expired
+bool(true)
+bool(false)
+bool(true)
+bool(true)
+re-locking a leased lock without a lease keeps its deadline after the holder exits
+int(0)
+bool(true)
+bool(true)
+a lease added to an unbounded lock does not keep it after the holder exits
+int(0)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_lock_lease_limits.phpt b/ext/user_cache/tests/user_cache_lock_lease_limits.phpt
new file mode 100644
index 000000000000..fc0afba54d95
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_lease_limits.phpt
@@ -0,0 +1,95 @@
+--TEST--
+Leased locks are bounded by user_cache.lock_lease_max, a per-segment count and a key byte budget
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.entries_hint=10
+user_cache.lock_lease_max=2
+--FILE--
+store('warm', 1);
+
+function inChild(callable $body): string
+{
+ $out = tempnam(sys_get_temp_dir(), 'ucache_lease');
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ file_put_contents($out, $body());
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+ $result = file_get_contents($out);
+ unlink($out);
+
+ return $result;
+}
+
+echo "leased lock count per segment\n";
+echo inChild(function () use ($pool) {
+ $acquired = 0;
+ for ($i = 0; $i < 48; $i++) {
+ if ($pool->lock('counted' . $i, 2)) {
+ $acquired++;
+ }
+ }
+ $unleased = $pool->lock('unleased', 0);
+
+ return "acquired=$acquired unleased=" . var_export($unleased, true);
+}), "\n";
+usleep(2200000);
+
+echo "lock key byte budget\n";
+echo inChild(function () use ($pool) {
+ $first = $pool->lock(str_repeat('a', 40000), 2);
+ $second = $pool->lock(str_repeat('b', 40000), 2);
+ $unleased = $pool->lock(str_repeat('c', 40000), 0);
+
+ return 'first=' . var_export($first, true) . ' second=' . var_export($second, true) . ' unleased=' . var_export($unleased, true);
+}), "\n";
+usleep(2200000);
+
+echo "lease clamped to user_cache.lock_lease_max\n";
+var_dump($pool->lock('clamped', PHP_INT_MAX));
+$start = microtime(true);
+echo inChild(function () use ($pool) {
+ for ($i = 0; $i < 120; $i++) {
+ if ($pool->lock('clamped', 0)) {
+ return 'taken over';
+ }
+ usleep(50000);
+ }
+
+ return 'still held';
+}), "\n";
+var_dump(microtime(true) - $start < 5);
+
+echo "clear() drops leased locks left without an owner\n";
+echo inChild(function () use ($pool) {
+ return 'child lock=' . var_export($pool->lock('abandoned', 2), true);
+}), "\n";
+var_dump($pool->lock('abandoned', 0));
+var_dump($pool->clear());
+var_dump($pool->lock('abandoned', 0));
+var_dump($pool->store('after', 'ok'));
+
+?>
+--EXPECT--
+leased lock count per segment
+acquired=32 unleased=true
+lock key byte budget
+first=true second=false unleased=true
+lease clamped to user_cache.lock_lease_max
+bool(true)
+taken over
+bool(true)
+clear() drops leased locks left without an owner
+child lock=true
+bool(false)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_lock_model_fcntl.phpt b/ext/user_cache/tests/user_cache_lock_model_fcntl.phpt
new file mode 100644
index 000000000000..bd6b047d12bb
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_model_fcntl.phpt
@@ -0,0 +1,57 @@
+--TEST--
+UserCache\Cache: end-to-end on the fcntl lock model used where robust process-shared mutexes are missing
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--ENV--
+USER_CACHE_DEBUG_FORCE_FCNTL_LOCK_MODEL=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+store('probe', 1);
+ob_start();
+phpinfo(INFO_MODULES);
+preg_match('/^Active lock model => (.+)$/m', ob_get_clean(), $m);
+echo 'lock model: ', trim($m[1] ?? 'unknown'), "\n";
+
+user_cache_memory_model_exercise('mmap');
+
+/* Readers and writers in other processes contend on the fcntl lock. */
+$cache = UserCache\Cache::getPool('lock-model-contention');
+$cache->store('counter', 0);
+$children = [];
+for ($worker = 0; $worker < 3; $worker++) {
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ for ($i = 0; $i < 300; $i++) {
+ if ($cache->increment('counter') === null ||
+ !$cache->store("w$worker", [$worker, $i, str_repeat('s', 300)]) ||
+ $cache->fetch("w$worker")[1] !== $i
+ ) {
+ exit(1);
+ }
+ }
+ exit(0);
+ }
+ $children[] = $pid;
+}
+$ok = true;
+foreach ($children as $pid) {
+ pcntl_waitpid($pid, $status);
+ $ok = $ok && pcntl_wexitstatus($status) === 0;
+}
+var_dump($ok, $cache->fetch('counter'));
+?>
+--EXPECTF--
+lock model: fcntl
+%A
+bool(true)
+int(900)
diff --git a/ext/user_cache/tests/user_cache_lock_oversized_key.phpt b/ext/user_cache/tests/user_cache_lock_oversized_key.phpt
new file mode 100644
index 000000000000..7499f6eda143
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_oversized_key.phpt
@@ -0,0 +1,24 @@
+--TEST--
+UserCache\Cache: a lock key that cannot fit in the segment fails without clearing the cache
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=64K
+user_cache.eviction_policy=lru
+--FILE--
+clear();
+var_dump($cache->store('kept', 'value'));
+$expunged = UserCache\Cache::getStatus()->getExpungeCount();
+
+var_dump($cache->lock(str_repeat('k', 40000)));
+var_dump($cache->remember(str_repeat('r', 40000), fn () => 'computed without a lock'));
+var_dump($cache->fetch('kept'), UserCache\Cache::getStatus()->getExpungeCount() === $expunged);
+?>
+--EXPECT--
+bool(true)
+bool(false)
+string(23) "computed without a lock"
+string(5) "value"
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_lock_scalar_writes.phpt b/ext/user_cache/tests/user_cache_lock_scalar_writes.phpt
new file mode 100644
index 000000000000..f9f24a6dd28c
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_scalar_writes.phpt
@@ -0,0 +1,113 @@
+--TEST--
+UserCache\Cache: scalar writes skip other keys' locks but still wait for a lock on their own key
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+lock($key)) exit(1);
+ fwrite($child, "locked\n");
+ if (fgets($child) !== "release\n") exit(2);
+ /* Only widens the window for a write that ignores the lock; passing runs do not depend on it. */
+ usleep(100000);
+ if (!$cache->store($key, $value)) exit(3);
+ exit($cache->unlock($key) ? 0 : 4);
+ }
+ fclose($child);
+
+ return [$pid, $parent];
+}
+
+function read_line($socket, int $pid): string|false
+{
+ $deadline = hrtime(true) + WAIT_NS;
+ do {
+ $read = [$socket];
+ $write = $except = null;
+ if (stream_select($read, $write, $except, 0, 10000) > 0) {
+ return fgets($socket);
+ }
+ if (pcntl_waitpid($pid, $status, WNOHANG) !== 0) {
+ return false;
+ }
+ } while (hrtime(true) < $deadline);
+
+ return false;
+}
+
+function reap(int $pid, $socket): bool
+{
+ $deadline = hrtime(true) + WAIT_NS;
+ fclose($socket);
+ do {
+ $ret = pcntl_waitpid($pid, $status, WNOHANG);
+ if ($ret !== 0) {
+ return $ret === $pid && pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0;
+ }
+ usleep(1000);
+ } while (hrtime(true) < $deadline);
+
+ if (function_exists('posix_kill')) {
+ posix_kill($pid, SIGKILL);
+ }
+
+ return false;
+}
+
+$cache = UserCache\Cache::getPool('scalar-locks');
+$cache->clear();
+var_dump($cache->store('counter', 0), $cache->store('other', 0));
+var_dump($cache->fetch('counter'), $cache->fetch('other'));
+
+/* The lock stays held until "release", so a write that waited for it would time out and fail */
+[$pid, $socket] = hold_lock($cache, 'counter', 100);
+var_dump(read_line($socket, $pid) === "locked\n");
+var_dump($cache->store('other', 1));
+fwrite($socket, "release\n");
+var_dump($cache->store('counter', 1));
+var_dump(reap($pid, $socket));
+/* The overwrite lands after the value the child wrote right before unlocking */
+var_dump($cache->fetch('counter'), $cache->fetch('other'));
+
+[$pid, $socket] = hold_lock($cache, 'counter', 200);
+var_dump(read_line($socket, $pid) === "locked\n");
+var_dump($cache->increment('other'));
+fwrite($socket, "release\n");
+var_dump($cache->increment('counter'));
+var_dump(reap($pid, $socket));
+var_dump($cache->fetch('counter'), $cache->fetch('other'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(0)
+int(0)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+int(1)
+bool(true)
+int(2)
+int(201)
+bool(true)
+int(201)
+int(2)
diff --git a/ext/user_cache/tests/user_cache_lock_wait_budget.phpt b/ext/user_cache/tests/user_cache_lock_wait_budget.phpt
new file mode 100644
index 000000000000..776c68546b21
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_wait_budget.phpt
@@ -0,0 +1,115 @@
+--TEST--
+UserCache\Cache: a write to a key another process has locked waits for the lock, fails when the wait runs out (including deleteMultiple(), add() and remember()), and succeeds when the holder unlocks during the wait; reads do not wait and a leased lock outlives its holder
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--ENV--
+USER_CACHE_DEBUG_SHORT_ENTRY_LOCK_WAIT=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+clear();
+$cache->store('counter', 1);
+$cache->store('value', 'old');
+
+function elapsed_ms(float $started): float
+{
+ return (hrtime(true) - $started) / 1e6;
+}
+
+[$parent, $child] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+$pid = pcntl_fork();
+if ($pid === 0) {
+ fclose($parent);
+ $holder = UserCache\Cache::getPool('lock-wait-budget');
+ $locked = $holder->lock('value', 30) && $holder->lock('counter', 30) && $holder->lock('batch', 30);
+ fwrite($child, $locked ? 'L' : 'F');
+ fread($child, 1);
+ usleep(100000);
+ $holder->unlock('value');
+ fread($child, 1);
+ exit(0);
+}
+fclose($child);
+var_dump(fread($parent, 1));
+
+$started = hrtime(true);
+var_dump($cache->store('counter', 2));
+$waited = elapsed_ms($started);
+var_dump($waited >= 250 && $waited < 5000);
+
+var_dump($cache->increment('counter'), $cache->delete('batch'), $cache->storeMultiple(['batch' => 1, 'other' => 2]));
+var_dump($cache->fetch('counter'), $cache->has('other'), $cache->fetch('value'));
+
+echo "\ndeleteMultiple(), add() and remember() when the wait runs out:\n";
+var_dump($cache->store('unlocked', 'kept'));
+$started = hrtime(true);
+var_dump($cache->deleteMultiple(['unlocked', 'batch']));
+$waited = elapsed_ms($started);
+var_dump($waited >= 250 && $waited < 5000, $cache->fetch('unlocked'));
+
+$started = hrtime(true);
+var_dump($cache->add('batch', 'added'));
+$waited = elapsed_ms($started);
+var_dump($waited >= 250 && $waited < 5000, $cache->has('batch'));
+
+$calls = 0;
+$started = hrtime(true);
+var_dump($cache->remember('batch', function (string $key) use (&$calls): string {
+ $calls++;
+
+ return "computed $key";
+}));
+$waited = elapsed_ms($started);
+var_dump($calls, $waited >= 500 && $waited < 10000, $cache->has('batch'));
+
+echo "\nthe holder unlocks during the wait:\n";
+
+fwrite($parent, 'R');
+$started = hrtime(true);
+var_dump($cache->store('value', 'new'));
+$waited = elapsed_ms($started);
+var_dump($waited >= 50 && $waited < 290);
+
+fwrite($parent, 'X');
+pcntl_waitpid($pid, $status);
+/* A lock taken with a lease keeps guarding the key after its holder exits. */
+var_dump($cache->fetch('value'), $cache->store('counter', 3), $cache->fetch('counter'));
+?>
+--EXPECT--
+string(1) "L"
+bool(false)
+bool(true)
+NULL
+bool(false)
+bool(false)
+int(1)
+bool(false)
+string(3) "old"
+
+deleteMultiple(), add() and remember() when the wait runs out:
+bool(true)
+bool(false)
+bool(true)
+string(4) "kept"
+bool(false)
+bool(true)
+bool(false)
+string(14) "computed batch"
+int(1)
+bool(true)
+bool(false)
+
+the holder unlocks during the wait:
+bool(true)
+bool(true)
+string(3) "new"
+bool(false)
+int(1)
diff --git a/ext/user_cache/tests/user_cache_lock_zombie_owner.phpt b/ext/user_cache/tests/user_cache_lock_zombie_owner.phpt
new file mode 100644
index 000000000000..06802496f622
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_lock_zombie_owner.phpt
@@ -0,0 +1,50 @@
+--TEST--
+UserCache\Cache: an entry lock held by a killed but not yet reaped process can be taken over
+--EXTENSIONS--
+pcntl
+posix
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $cache->lock('held');
+ $cache->store('ready', true);
+ sleep(30);
+ exit(0);
+}
+while ($cache->fetch('ready') === null) {
+ usleep(10000);
+}
+
+/* WNOWAIT leaves the killed owner as a zombie: kill(pid, 0) still succeeds. */
+posix_kill($pid, SIGKILL);
+$info = [];
+var_dump(pcntl_waitid(P_PID, $pid, $info, WEXITED | WNOWAIT));
+
+var_dump($cache->lock('held'));
+var_dump($cache->unlock('held'));
+
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifsignaled($status));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_locked_fetch_gc_destructors.phpt b/ext/user_cache/tests/user_cache_locked_fetch_gc_destructors.phpt
new file mode 100644
index 000000000000..61b64754da37
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_locked_fetch_gc_destructors.phpt
@@ -0,0 +1,66 @@
+--TEST--
+UserCache\Cache: a locked-path fetch decodes outside the cache lock, so destructors run by the cycle collector can write to the cache
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_FORCE_LOCKED_FETCH=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+store('from-destructor', ++$stores)) {
+ return;
+ }
+
+ echo "store from destructor failed\n";
+ }
+}
+
+$cache = UserCache\Cache::getPool('locked-fetch-gc-destructors');
+$stores = 0;
+
+$scalar = 1;
+$inner = ['scalar' => &$scalar, 'text' => 'v'];
+$value = ['a' => &$inner, 'b' => &$inner];
+var_dump($cache->store('value', $value));
+unset($value, $inner, $scalar);
+
+for ($round = 0; $round < 3; $round++) {
+ gc_collect_cycles();
+ $status = gc_status();
+ for ($i = $status['roots']; $i < $status['threshold'] - 1; $i++) {
+ $garbage = new StoringGarbage();
+ $garbage->self = $garbage;
+ unset($garbage);
+ }
+
+ $fetched = $cache->fetch('value');
+ $fetched['a']['scalar'] = 2;
+ var_dump($fetched['b']['scalar'], $fetched['a']['text']);
+ unset($fetched);
+}
+
+gc_collect_cycles();
+var_dump($stores > 0, $cache->fetch('from-destructor') === $stores);
+?>
+--EXPECT--
+bool(true)
+int(2)
+string(1) "v"
+int(2)
+string(1) "v"
+int(2)
+string(1) "v"
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_locks.phpt b/ext/user_cache/tests/user_cache_locks.phpt
new file mode 100644
index 000000000000..abd35cbcfe6b
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_locks.phpt
@@ -0,0 +1,74 @@
+--TEST--
+UserCache\Cache: entry locks are isolated by exact key and re-entrant only for their owner
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+var_dump($cache->lock('alpha'));
+var_dump($cache->store('beta', 'beta-value'));
+var_dump($cache->fetch('beta'));
+var_dump($cache->delete('beta'));
+var_dump($cache->has('beta'));
+
+var_dump($cache->storeMultiple([
+ 'gamma' => 'gamma-value',
+ 'delta' => 'delta-value',
+]));
+var_dump($cache->fetchMultiple(['gamma', 'delta']));
+var_dump($cache->deleteMultiple(['gamma', 'delta']));
+var_dump($cache->has('gamma'));
+var_dump($cache->has('delta'));
+
+var_dump($cache->lock('epsilon'));
+var_dump($cache->unlock('epsilon'));
+var_dump($cache->unlock('alpha'));
+
+var_dump($cache->lock('prefix'));
+var_dump($cache->store('prefix:child', 'child-value'));
+var_dump($cache->fetch('prefix:child'));
+var_dump($cache->unlock('prefix'));
+
+/* The owner may re-lock its key; unlocking an unheld key fails. */
+$cache = UserCache\Cache::getPool('locks-reentrancy');
+
+var_dump($cache->lock('key'));
+var_dump($cache->lock('key'));
+var_dump($cache->unlock('key'));
+var_dump($cache->unlock('key'));
+
+var_dump($cache->unlock('never-locked'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+string(10) "beta-value"
+bool(true)
+bool(false)
+bool(true)
+array(2) {
+ ["gamma"]=>
+ string(11) "gamma-value"
+ ["delta"]=>
+ string(11) "delta-value"
+}
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(11) "child-value"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_locks_cross_process.phpt b/ext/user_cache/tests/user_cache_locks_cross_process.phpt
new file mode 100644
index 000000000000..f264ff844186
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_locks_cross_process.phpt
@@ -0,0 +1,199 @@
+--TEST--
+UserCache\Cache: entry lock ownership, release at exit, pool-scoped clear refusal and a waiting delete across processes
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+$shared = range(1, 4);
+$value = [$shared, $shared, ['nested' => $shared]];
+
+var_dump($cache->store('graph', $value));
+var_dump($cache->lock('lock'));
+
+$pid = fork_child();
+if ($pid === 0) {
+ echo "child unlock\n";
+ var_dump($cache->unlock('lock'));
+ echo "child lock\n";
+ var_dump($cache->lock('lock', 1));
+ echo "child fetch\n";
+ var_dump($cache->fetch('graph') === $value);
+ exit(0);
+}
+
+pcntl_waitpid($pid, $status);
+echo "parent unlock\n";
+var_dump($cache->unlock('lock'));
+
+/* A child that only exits does not release the parent's lock at shutdown. */
+$cache = UserCache\Cache::getPool('locks-fork-shutdown');
+$cache->clear();
+
+var_dump($cache->lock('held'));
+
+$pid = fork_child();
+if ($pid === 0) {
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+
+$pid = fork_child();
+if ($pid === 0) {
+ echo "child lock\n";
+ var_dump($cache->lock('held', 1));
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+
+echo "parent unlock\n";
+var_dump($cache->unlock('held'));
+
+/* Locks still held at process exit are released. */
+$cache = UserCache\Cache::getPool('locks-exit-release');
+$cache->clear();
+
+$pid = fork_child();
+if ($pid === 0) {
+ $cache->lock('exit-plain');
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+
+echo "lock released at child exit\n";
+var_dump($cache->lock('exit-plain'));
+var_dump($cache->unlock('exit-plain'));
+
+/* clear() and deletePool() refuse while another process holds a key lock in their own pool only. */
+$cache = UserCache\Cache::getPool('locks-clear-refusal');
+$cleared = UserCache\Cache::getPool('locks-clear-other');
+$deleted = UserCache\Cache::getPool('locks-delete-other');
+$cache->clear();
+$cleared->store('value', 'cleared');
+$deleted->store('value', 'deleted');
+
+$pid = fork_child();
+if ($pid === 0) {
+ $cache->lock('held-by-child');
+ $cache->store('child-ready', true);
+ while ($cache->fetch('parent-done') === null) {
+ usleep(10000);
+ }
+ exit($cache->unlock('held-by-child') ? 0 : 1);
+}
+
+while ($cache->fetch('child-ready') === null) {
+ usleep(10000);
+}
+
+var_dump($cache->clear());
+var_dump(UserCache\Cache::deletePool('locks-clear-refusal'));
+var_dump($cache->has('child-ready'));
+
+echo "other pools\n";
+var_dump($cleared->clear());
+var_dump(UserCache\Cache::deletePool('locks-delete-other'));
+var_dump($cleared->has('value'), $deleted->has('value'));
+
+$cache->store('parent-done', true);
+pcntl_waitpid($pid, $status);
+
+echo "child unlock after the refusals\n";
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+var_dump($cache->clear());
+var_dump($cache->has('child-ready'));
+
+/* delete() of a foreign-locked key waits until the holder's last write before unlocking. */
+$cache = UserCache\Cache::getPool('locks-waiting-delete');
+$cache->store('key', 10);
+$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+$pid = fork_child();
+if ($pid === 0) {
+ fclose($sockets[0]);
+ if (!$cache->lock('key')) {
+ exit(1);
+ }
+ fwrite($sockets[1], 'L');
+ if (fread($sockets[1], 1) !== 'R') {
+ exit(2);
+ }
+ usleep(100000);
+ if (!$cache->store('key', 11)) {
+ exit(3);
+ }
+ if (!$cache->unlock('key')) {
+ exit(4);
+ }
+ fclose($sockets[1]);
+ exit(0);
+}
+fclose($sockets[1]);
+if (fread($sockets[0], 1) !== 'L') {
+ die('lock handshake failed');
+}
+var_dump($cache->lock('key'));
+fwrite($sockets[0], 'R');
+var_dump($cache->delete('key'));
+pcntl_waitpid($pid, $status);
+fclose($sockets[0]);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+var_dump($cache->fetch('key', 'missing'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+child unlock
+bool(false)
+child lock
+bool(false)
+child fetch
+bool(true)
+parent unlock
+bool(true)
+bool(true)
+child lock
+bool(false)
+parent unlock
+bool(true)
+lock released at child exit
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+other pools
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+child unlock after the refusals
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+string(7) "missing"
diff --git a/ext/user_cache/tests/user_cache_memory_model_exercise.inc b/ext/user_cache/tests/user_cache_memory_model_exercise.inc
new file mode 100644
index 000000000000..0d3f8a308149
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_memory_model_exercise.inc
@@ -0,0 +1,114 @@
+ (.+)$/m', $info, $m) ? trim($m[1]) : 'unknown';
+}
+
+class MemoryModelPlain
+{
+ public int $a = 1;
+ public ?string $b = null;
+ public array $c = [];
+}
+
+class MemoryModelMagic
+{
+ public function __construct(public array $payload) {}
+
+ public function __serialize(): array
+ {
+ return ['payload' => $this->payload];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->payload = $data['payload'];
+ }
+}
+
+function user_cache_memory_model_exercise(string $expected): void
+{
+ $cache = UserCache\Cache::getPool('memory-model');
+ $cache->clear();
+
+ var_dump($cache->store('init', true));
+ var_dump(UserCache\Cache::getStatus()->getAvailability()->name);
+ $active = user_cache_memory_model_active();
+ echo 'active model: ', $active === $expected ? 'as requested' : "MISMATCH ($active)", "\n";
+
+ var_dump($cache->store('int', 42), $cache->fetch('int'));
+ var_dump($cache->store('float', 1.5), $cache->fetch('float'));
+ var_dump($cache->store('bool', false), $cache->fetch('bool'));
+ var_dump($cache->store('null', null), $cache->fetch('null', 'miss'));
+ var_dump($cache->store('str', 'hello'), $cache->fetch('str'));
+
+ $array = ['packed' => [1, 2, 3], 'map' => ['k' => 'v'], 'nested' => [['deep' => true]], 'empty' => []];
+ var_dump($cache->store('array', $array));
+ var_dump($cache->fetch('array') === $array);
+
+ $plain = new MemoryModelPlain();
+ $plain->b = 'x';
+ $plain->c = [1];
+ var_dump($cache->store('plain', $plain));
+ $fetched = $cache->fetch('plain');
+ var_dump($fetched instanceof MemoryModelPlain, $fetched->a, $fetched->b, $fetched->c);
+
+ $magic = new MemoryModelMagic(['m' => 2]);
+ var_dump($cache->store('magic', $magic));
+ $fetchedMagic = $cache->fetch('magic');
+ var_dump($fetchedMagic instanceof MemoryModelMagic, $fetchedMagic->payload);
+
+ var_dump($cache->add('int', 99));
+ var_dump($cache->add('added', 7), $cache->fetch('added'));
+ var_dump($cache->delete('added'), $cache->has('added'));
+
+ var_dump($cache->store('counter', 10));
+ var_dump($cache->increment('counter', 5));
+ var_dump($cache->decrement('counter'));
+
+ var_dump($cache->storeMultiple(['m1' => 'a', 'm2' => 'b'], 60));
+ var_dump($cache->fetchMultiple(['m1', 'm2', 'm3'], 'dflt'));
+ var_dump($cache->deleteMultiple(['m1', 'm2']));
+
+ var_dump($cache->lock('lk'));
+ var_dump($cache->unlock('lk'));
+ $calls = 0;
+ var_dump($cache->remember('rem', function () use (&$calls) {
+ $calls++;
+ return 'computed';
+ }));
+ var_dump($cache->remember('rem', function () use (&$calls) {
+ $calls++;
+ return 'recomputed';
+ }));
+ var_dump($calls);
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $child = UserCache\Cache::getPool('memory-model');
+ $ok = $child->fetch('str') === 'hello'
+ && $child->fetch('array') === $array
+ && $child->fetch('counter') === 14;
+ echo $ok ? "child sees parent data\n" : "child MISSING parent data\n";
+ var_dump($child->store('from-child', 'child-value'));
+ var_dump($child->increment('counter', 100));
+ exit(0);
+ }
+
+ if ($pid < 0) {
+ echo "pcntl_fork() failed\n";
+ return;
+ }
+
+ pcntl_waitpid($pid, $status);
+ var_dump($cache->fetch('from-child'));
+ var_dump($cache->fetch('counter'));
+
+ var_dump($cache->clear());
+ var_dump($cache->has('str'));
+}
diff --git a/ext/user_cache/tests/user_cache_memory_model_lazy_table.phpt b/ext/user_cache/tests/user_cache_memory_model_lazy_table.phpt
new file mode 100644
index 000000000000..bf3e0fe7acc9
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_memory_model_lazy_table.phpt
@@ -0,0 +1,61 @@
+--TEST--
+UserCache\Cache: a process-private segment is formatted without touching its zero-filled entry table, so the table pages are faulted in only as entries use them
+--SKIPIF--
+ ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
+$shm = stream_get_contents($pipes[1]);
+fclose($pipes[1]);
+proc_close($process);
+if ($shm !== 'shm') die('skip the shm memory model is not built in (no SysV shared memory)');
+?>
+--FILE--
+store('k', 'v') && $cache->fetch('k') === 'v';
+$touchedKb = rss_shmem_kb() - $before;
+$status = UserCache\Cache::getStatus();
+$tableKb = intdiv($status->getEntryCapacity() * 44, 1024);
+
+echo $status->getAvailability()->name, ' ', var_export($stored, true), ' ', var_export($tableKb > 32 * 1024 && $touchedKb < intdiv($tableKb, 8), true);
+PHP;
+
+foreach (['mmap', 'posix', 'shm'] as $model) {
+ $process = proc_open(
+ [
+ PHP_BINARY, '-n',
+ '-d', 'user_cache.enable_cli=1',
+ '-d', 'user_cache.shm_size=256M',
+ '-d', 'user_cache.entries_hint=1000000',
+ '-d', 'user_cache.preferred_memory_model=' . $model,
+ '-r', $code,
+ ],
+ [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
+ $pipes
+ );
+ fclose($pipes[0]);
+ $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ echo $model, ': ', trim($out), "\n";
+}
+?>
+--EXPECT--
+mmap: Available true true
+posix: Available true true
+shm: Available true true
diff --git a/ext/user_cache/tests/user_cache_memory_model_mmap.phpt b/ext/user_cache/tests/user_cache_memory_model_mmap.phpt
new file mode 100644
index 000000000000..2810d29a7d41
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_memory_model_mmap.phpt
@@ -0,0 +1,94 @@
+--TEST--
+UserCache\Cache: end-to-end on the explicit "mmap" memory model
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+user_cache.preferred_memory_model=mmap
+--SKIPIF--
+store("k", 1);'
+ . 'ob_start(); phpinfo(INFO_MODULES);'
+ . 'preg_match("/^Active memory model => (.+)$/m", ob_get_clean(), $m);'
+ . 'echo trim($m[1] ?? "none");';
+$process = proc_open(
+ [$php, '-n', '-d', 'user_cache.enable=1', '-d', 'user_cache.enable_cli=1', '-d', 'user_cache.shm_size=16M', '-d', 'user_cache.preferred_memory_model=mmap', '-r', $probe],
+ [1 => ['pipe', 'w'], 2 => ['null']],
+ $pipes
+);
+$active = trim(stream_get_contents($pipes[1]));
+fclose($pipes[1]);
+proc_close($process);
+if ($active !== 'mmap') {
+ die("skip mmap memory model unavailable (active: $active)");
+}
+?>
+--FILE--
+
+--EXPECT--
+bool(true)
+string(9) "Available"
+active model: as requested
+bool(true)
+int(42)
+bool(true)
+float(1.5)
+bool(true)
+bool(false)
+bool(true)
+NULL
+bool(true)
+string(5) "hello"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+string(1) "x"
+array(1) {
+ [0]=>
+ int(1)
+}
+bool(true)
+bool(true)
+array(1) {
+ ["m"]=>
+ int(2)
+}
+bool(false)
+bool(true)
+int(7)
+bool(true)
+bool(false)
+bool(true)
+int(15)
+int(14)
+bool(true)
+array(3) {
+ ["m1"]=>
+ string(1) "a"
+ ["m2"]=>
+ string(1) "b"
+ ["m3"]=>
+ string(4) "dflt"
+}
+bool(true)
+bool(true)
+bool(true)
+string(8) "computed"
+string(8) "computed"
+int(1)
+child sees parent data
+bool(true)
+int(114)
+string(11) "child-value"
+int(114)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_memory_model_posix.phpt b/ext/user_cache/tests/user_cache_memory_model_posix.phpt
new file mode 100644
index 000000000000..e8fabf86d862
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_memory_model_posix.phpt
@@ -0,0 +1,94 @@
+--TEST--
+UserCache\Cache: end-to-end on the explicit "posix" memory model
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+user_cache.preferred_memory_model=posix
+--SKIPIF--
+store("k", 1);'
+ . 'ob_start(); phpinfo(INFO_MODULES);'
+ . 'preg_match("/^Active memory model => (.+)$/m", ob_get_clean(), $m);'
+ . 'echo trim($m[1] ?? "none");';
+$process = proc_open(
+ [$php, '-n', '-d', 'user_cache.enable=1', '-d', 'user_cache.enable_cli=1', '-d', 'user_cache.shm_size=16M', '-d', 'user_cache.preferred_memory_model=posix', '-r', $probe],
+ [1 => ['pipe', 'w'], 2 => ['null']],
+ $pipes
+);
+$active = trim(stream_get_contents($pipes[1]));
+fclose($pipes[1]);
+proc_close($process);
+if ($active !== 'posix') {
+ die("skip posix memory model unavailable (active: $active)");
+}
+?>
+--FILE--
+
+--EXPECT--
+bool(true)
+string(9) "Available"
+active model: as requested
+bool(true)
+int(42)
+bool(true)
+float(1.5)
+bool(true)
+bool(false)
+bool(true)
+NULL
+bool(true)
+string(5) "hello"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+string(1) "x"
+array(1) {
+ [0]=>
+ int(1)
+}
+bool(true)
+bool(true)
+array(1) {
+ ["m"]=>
+ int(2)
+}
+bool(false)
+bool(true)
+int(7)
+bool(true)
+bool(false)
+bool(true)
+int(15)
+int(14)
+bool(true)
+array(3) {
+ ["m1"]=>
+ string(1) "a"
+ ["m2"]=>
+ string(1) "b"
+ ["m3"]=>
+ string(4) "dflt"
+}
+bool(true)
+bool(true)
+bool(true)
+string(8) "computed"
+string(8) "computed"
+int(1)
+child sees parent data
+bool(true)
+int(114)
+string(11) "child-value"
+int(114)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_memory_model_shm.phpt b/ext/user_cache/tests/user_cache_memory_model_shm.phpt
new file mode 100644
index 000000000000..e664d18b812d
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_memory_model_shm.phpt
@@ -0,0 +1,94 @@
+--TEST--
+UserCache\Cache: end-to-end on the explicit "shm" memory model
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+user_cache.preferred_memory_model=shm
+--SKIPIF--
+store("k", 1);'
+ . 'ob_start(); phpinfo(INFO_MODULES);'
+ . 'preg_match("/^Active memory model => (.+)$/m", ob_get_clean(), $m);'
+ . 'echo trim($m[1] ?? "none");';
+$process = proc_open(
+ [$php, '-n', '-d', 'user_cache.enable=1', '-d', 'user_cache.enable_cli=1', '-d', 'user_cache.shm_size=16M', '-d', 'user_cache.preferred_memory_model=shm', '-r', $probe],
+ [1 => ['pipe', 'w'], 2 => ['null']],
+ $pipes
+);
+$active = trim(stream_get_contents($pipes[1]));
+fclose($pipes[1]);
+proc_close($process);
+if ($active !== 'shm') {
+ die("skip shm memory model unavailable (active: $active)");
+}
+?>
+--FILE--
+
+--EXPECT--
+bool(true)
+string(9) "Available"
+active model: as requested
+bool(true)
+int(42)
+bool(true)
+float(1.5)
+bool(true)
+bool(false)
+bool(true)
+NULL
+bool(true)
+string(5) "hello"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+string(1) "x"
+array(1) {
+ [0]=>
+ int(1)
+}
+bool(true)
+bool(true)
+array(1) {
+ ["m"]=>
+ int(2)
+}
+bool(false)
+bool(true)
+int(7)
+bool(true)
+bool(false)
+bool(true)
+int(15)
+int(14)
+bool(true)
+array(3) {
+ ["m1"]=>
+ string(1) "a"
+ ["m2"]=>
+ string(1) "b"
+ ["m3"]=>
+ string(4) "dflt"
+}
+bool(true)
+bool(true)
+bool(true)
+string(8) "computed"
+string(8) "computed"
+int(1)
+child sees parent data
+bool(true)
+int(114)
+string(11) "child-value"
+int(114)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_multiple.phpt b/ext/user_cache/tests/user_cache_multiple.phpt
new file mode 100644
index 000000000000..828724f45775
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_multiple.phpt
@@ -0,0 +1,259 @@
+--TEST--
+UserCache\Cache: fetchMultiple key handling, storeMultiple validation and rollback, and bulk operations snapshot referenced keys and values before userland runs
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+--FILE--
+storeMultiple([
+ 123 => 'int-key',
+ '456' => 'numeric-string-key',
+]));
+
+$result = $cache->fetchMultiple([123, '456', 789], 'default');
+var_dump($result);
+var_dump(array_key_exists(123, $result));
+var_dump(array_key_exists(456, $result));
+var_dump($result[123], $result[456], $result[789]);
+
+/* fetchMultiple keeps input key order when warm, cold and missing keys mix. */
+class OrderProbe
+{
+ public function __construct(public int $n) {}
+}
+
+$cache = UserCache\Cache::getPool('multiple-order');
+$cache->clear();
+
+var_dump($cache->store('cold', new OrderProbe(1)));
+var_dump($cache->store('warm', new OrderProbe(2)));
+
+$cache->fetch('warm');
+
+$result = $cache->fetchMultiple(['cold', 'warm', 'missing'], 'dflt');
+var_dump(array_keys($result));
+var_dump($result['cold']->n, $result['warm']->n, $result['missing']);
+
+/* storeMultiple validates the whole batch before writing anything. */
+$cache = UserCache\Cache::getPool('multiple-typeerror');
+
+var_dump($cache->store('existing', 'previous'));
+
+try {
+ $cache->storeMultiple([
+ 'existing' => 'replaced',
+ 'fresh' => 1,
+ 'bad' => fn() => 1,
+ ]);
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->fetch('existing'));
+var_dump($cache->has('fresh'));
+
+try {
+ $cache->storeMultiple(['bad' => fopen('php://memory', 'r')]);
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+/* storeMultiple rolls back partial writes across every pair of value kinds. */
+$cache = UserCache\Cache::getPool('multiple-rollback');
+
+$values = [null, false, true, PHP_INT_MIN, -0.0, str_repeat('s', 300),
+ str_repeat('g', 8192), ['n' => (object) ['x' => 1]]];
+$tooLarge = str_repeat('x', 4 * 1024 * 1024);
+
+for ($offset = 0; $offset < count($values); $offset++) {
+ $cache->clear();
+ $original = $replacement = [];
+ foreach ($values as $index => $value) {
+ $key = "value-$index";
+ $original[$key] = $value;
+ $replacement[$key] = $values[($index + $offset) % count($values)];
+ }
+ if (!$cache->storeMultiple($original) || !$cache->store('last', 7)) {
+ die("initial store failed: $offset\n");
+ }
+ $used = $cache->getPoolStatus()->getUsedMemory();
+ if ($cache->storeMultiple($replacement + ['fresh' => $values[$offset], 'last' => $tooLarge])) {
+ die("bulk unexpectedly succeeded: $offset\n");
+ }
+ foreach ($original as $key => $value) {
+ if (!$cache->has($key) || serialize($cache->fetch($key)) !== serialize($value)) {
+ die("rollback mismatch: $offset/$key\n");
+ }
+ }
+ if ($cache->fetch('last') !== 7 || $cache->has('fresh')) {
+ die("rollback keys mismatch: $offset\n");
+ }
+ if ($cache->getPoolStatus()->getUsedMemory() !== $used) {
+ die("rollback memory mismatch: $offset\n");
+ }
+ $replacement['fresh'] = $values[$offset];
+ if (!$cache->storeMultiple($replacement)) {
+ die("bulk reuse failed: $offset\n");
+ }
+ foreach ($replacement as $key => $value) {
+ if (!$cache->has($key) || serialize($cache->fetch($key)) !== serialize($value)) {
+ die("bulk reuse mismatch: $offset/$key\n");
+ }
+ }
+}
+echo "rollback OK\n";
+
+$fresh = UserCache\Cache::getPool('multiple-rollback-fresh');
+var_dump($fresh->storeMultiple([
+ 'fresh-a' => 'new-a',
+ 'fresh-b' => $tooLarge,
+]));
+var_dump($fresh->has('fresh-a'));
+var_dump($fresh->has('fresh-b'));
+
+echo "\nreference mutation:\n";
+
+class RewritesKeyOnDestruct
+{
+ public function __destruct()
+ {
+ global $key;
+ $key = $GLOBALS['replacement'];
+ }
+}
+
+class RewritesKeyOnWakeup
+{
+ public $n = 1;
+
+ public function __wakeup(): void
+ {
+ global $key;
+ $key = new stdClass;
+ }
+}
+
+class RewritesValueOnSerialize
+{
+ public function __serialize(): array
+ {
+ global $value;
+ $value = $GLOBALS['replacement'];
+
+ return ['x' => 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+$replacement = null;
+$cache = UserCache\Cache::getPool('reference-mutation');
+$cache->store('destructs', new RewritesKeyOnDestruct);
+$cache->store('wakes', new RewritesKeyOnWakeup);
+$cache->store('plain', 'plain value');
+$cache->store('7', 'seven');
+
+foreach ([null, [1, 2, 3], 12345] as $replacement) {
+ $key = 'plain';
+ $result = $cache->fetchMultiple(['destructs', 'destructs', &$key]);
+ var_dump(array_keys($result), $result['plain']);
+}
+
+$key = 7;
+$result = $cache->fetchMultiple(['wakes', &$key]);
+var_dump(array_keys($result), $result[7]);
+
+foreach ([12345, 'short', str_repeat('b', 5000)] as $replacement) {
+ $value = str_repeat('a', 300);
+ var_dump($cache->storeMultiple(['referenced' => &$value, 'hook' => new RewritesValueOnSerialize]));
+ var_dump(strlen($cache->fetch('referenced')), $cache->fetch('referenced')[0]);
+}
+
+$value = 'tiny';
+$replacement = 1;
+var_dump($cache->storeMultiple(['referenced' => &$value, 'hook' => new RewritesValueOnSerialize]));
+var_dump($cache->fetch('referenced'));
+?>
+--EXPECT--
+bool(true)
+array(3) {
+ [123]=>
+ string(7) "int-key"
+ [456]=>
+ string(18) "numeric-string-key"
+ [789]=>
+ string(7) "default"
+}
+bool(true)
+bool(true)
+string(7) "int-key"
+string(18) "numeric-string-key"
+string(7) "default"
+bool(true)
+bool(true)
+array(3) {
+ [0]=>
+ string(4) "cold"
+ [1]=>
+ string(4) "warm"
+ [2]=>
+ string(7) "missing"
+}
+int(1)
+int(2)
+string(4) "dflt"
+bool(true)
+Closure objects cannot be stored in the user cache
+string(8) "previous"
+bool(false)
+Resources cannot be stored in the user cache
+rollback OK
+bool(false)
+bool(false)
+bool(false)
+
+reference mutation:
+array(2) {
+ [0]=>
+ string(9) "destructs"
+ [1]=>
+ string(5) "plain"
+}
+string(11) "plain value"
+array(2) {
+ [0]=>
+ string(9) "destructs"
+ [1]=>
+ string(5) "plain"
+}
+string(11) "plain value"
+array(2) {
+ [0]=>
+ string(9) "destructs"
+ [1]=>
+ string(5) "plain"
+}
+string(11) "plain value"
+array(2) {
+ [0]=>
+ string(5) "wakes"
+ [1]=>
+ int(7)
+}
+string(5) "seven"
+bool(true)
+int(300)
+string(1) "a"
+bool(true)
+int(300)
+string(1) "a"
+bool(true)
+int(300)
+string(1) "a"
+bool(true)
+string(4) "tiny"
diff --git a/ext/user_cache/tests/user_cache_multiple_lock_takeover.phpt b/ext/user_cache/tests/user_cache_multiple_lock_takeover.phpt
new file mode 100644
index 000000000000..815775f13308
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_multiple_lock_takeover.phpt
@@ -0,0 +1,103 @@
+--TEST--
+UserCache\Cache: batches wait for foreign locks, including after a local lease expires
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+ $cache->store('guarded', 10);
+ $cache->store('other', 20);
+ if ($staleLease && !$cache->lock('guarded', 1)) {
+ die("initial lease failed\n");
+ }
+ $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+ $pid = pcntl_fork();
+ if ($pid < 0) {
+ die("fork failed\n");
+ }
+ if ($pid === 0) {
+ fclose($sockets[0]);
+ if ($staleLease) {
+ sleep(2);
+ }
+ if (!$cache->lock('guarded')) {
+ exit(1);
+ }
+ fwrite($sockets[1], 'L');
+ if (fread($sockets[1], 1) !== 'G') {
+ exit(2);
+ }
+ usleep(300000);
+ /* The unlocked first item must also remain unchanged while waiting. */
+ if ($cache->fetch('guarded') !== 10 || $cache->fetch('other') !== 20) {
+ exit(3);
+ }
+ if (!$cache->unlock('guarded')) {
+ exit(4);
+ }
+ fclose($sockets[1]);
+ exit(0);
+ }
+ fclose($sockets[1]);
+ if (fread($sockets[0], 1) !== 'L') {
+ die("lock handshake failed\n");
+ }
+ if ($staleLease) {
+ /* Neither a default nor an equal lease can reuse a stale local token. */
+ var_dump($cache->lock('guarded'));
+ var_dump($cache->lock('guarded', 1));
+ }
+ fwrite($sockets[0], 'G');
+ var_dump(match ($operation) {
+ 'storeMultiple' => $cache->storeMultiple(['other' => 21, 'guarded' => 11]),
+ 'deleteMultiple' => $cache->deleteMultiple(['other', 'guarded', 'other']),
+ });
+ pcntl_waitpid($pid, $status);
+ fclose($sockets[0]);
+ var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+ if ($operation === 'storeMultiple') {
+ var_dump($cache->fetch('guarded') === 11 && $cache->fetch('other') === 21);
+ } else {
+ var_dump(!$cache->has('guarded') && !$cache->has('other'));
+ }
+ if ($staleLease) {
+ $cache->unlock('guarded');
+ }
+ }
+}
+?>
+--EXPECT--
+foreign storeMultiple
+bool(true)
+bool(true)
+bool(true)
+foreign deleteMultiple
+bool(true)
+bool(true)
+bool(true)
+stale storeMultiple
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+stale deleteMultiple
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_object_properties.phpt b/ext/user_cache/tests/user_cache_object_properties.phpt
new file mode 100644
index 000000000000..9e24933d1234
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_object_properties.phpt
@@ -0,0 +1,207 @@
+--TEST--
+UserCache\Cache: uninitialized typed, missing-scope private and empty-named properties decode correctly, and malformed mangled property names from a state are not reported at store() and fail the restore like unserialize()
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+name = $name;
+ }
+}
+
+$object = new UninitBox();
+$object->assigned = 42;
+
+$cache->store('box', $object);
+$fetched = $cache->fetch('box');
+
+$assignedProp = new ReflectionProperty($fetched, 'assigned');
+$neverProp = new ReflectionProperty($fetched, 'neverAssigned');
+$nullableProp = new ReflectionProperty($fetched, 'nullableNeverAssigned');
+$defaultProp = new ReflectionProperty($fetched, 'withDefault');
+
+ok('assigned initialized', $assignedProp->isInitialized($fetched) && $fetched->assigned === 42);
+ok('never-assigned stays uninitialized', !$neverProp->isInitialized($fetched));
+ok('nullable-never stays uninitialized', !$nullableProp->isInitialized($fetched));
+ok('default initialized', $defaultProp->isInitialized($fetched) && $fetched->withDefault === 'default');
+
+$readError = false;
+try {
+ $fetched->neverAssigned;
+} catch (Error $e) {
+ $readError = str_contains($e->getMessage(), 'must not be accessed before initialization');
+}
+ok('uninitialized read still errors', $readError);
+
+$fetched->neverAssigned = 7;
+ok('can assign after fetch', $fetched->neverAssigned === 7);
+$typeError = false;
+try {
+ $fetched->assigned = 'not an int';
+} catch (TypeError $e) {
+ $typeError = true;
+}
+ok('type check preserved', $typeError);
+
+$ro = new UninitReadonly('n');
+$cache->store('ro', $ro);
+$fetchedRo = $cache->fetch('ro');
+$idProp = new ReflectionProperty($fetchedRo, 'id');
+ok('readonly uninitialized survives', !$idProp->isInitialized($fetchedRo) && $fetchedRo->name === 'n');
+
+$graph = ['direct' => new UninitBox(), 'list' => [new UninitBox()]];
+$graph['direct']->assigned = 1;
+$cache->store('graph', $graph);
+$fetchedGraph = $cache->fetch('graph');
+$nestedProp = new ReflectionProperty($fetchedGraph['list'][0], 'neverAssigned');
+ok('nested uninitialized survives', !$nestedProp->isInitialized($fetchedGraph['list'][0]));
+ok('serialize parity', serialize($cache->fetch('box')) === serialize($object));
+
+/* A private property of a missing declaring class round-trips like unserialize() */
+$cache = UserCache\Cache::getPool('object-props-missing-scope');
+$cache->clear();
+
+#[AllowDynamicProperties]
+class UserCacheMissingScopeBox
+{
+}
+
+$payload = 'O:24:"UserCacheMissingScopeBox":1:{s:15:"' . "\0Missing\0secret" . '";i:42;}';
+$value = unserialize($payload);
+
+var_dump($cache->store('value', $value));
+var_dump($cache->fetch('value', 'default'));
+var_dump(serialize($cache->fetch('value')) === $payload);
+
+/* An empty property name decodes on the first and the slot-cached fetch */
+$cache = UserCache\Cache::getPool('object-props-empty-name');
+$cache->clear();
+
+#[AllowDynamicProperties]
+class UserCacheEmptyPropertyNameBox
+{
+ public int $declared = 1;
+}
+
+$plain = (object) ['' => 'empty-name'];
+var_dump($cache->store('plain', $plain));
+var_dump($cache->fetch('plain', 'default'));
+var_dump($cache->fetch('plain', 'default'));
+
+$box = new UserCacheEmptyPropertyNameBox();
+$box->{''} = 'empty-name';
+var_dump($cache->store('box', $box));
+var_dump($cache->fetch('box', 'default'));
+
+class Malformed
+{
+ public $a = 1;
+
+ public function __serialize(): array
+ {
+ return ["\0x" => 1, "\0A\0" => 2, 'a' => 5];
+ }
+}
+
+function malformed_property_names(): void
+{
+ $notices = [];
+ set_error_handler(function (int $errno, string $message) use (&$notices): bool {
+ $notices[] = $message;
+
+ return true;
+ });
+
+ $cache = UserCache\Cache::getPool('malformed-property-names');
+ var_dump($cache->store('k', new Malformed));
+ echo 'store notices: ', count($notices), "\n";
+
+ $notices = [];
+ var_dump($cache->fetch('k', 'DEFAULT'));
+ echo 'fetch notices: ', implode(' | ', array_unique($notices)), "\n";
+
+ $notices = [];
+ var_dump(unserialize(serialize(new Malformed)));
+ echo 'unserialize notices: ', implode(' | ', $notices), "\n";
+
+ /* Classes without declared properties keep such names as dynamic properties, like unserialize(). */
+ $notices = [];
+ $undeclared = (object) ["\0x" => 1, 'ok' => 2];
+ var_dump($cache->store('undeclared', $undeclared));
+ var_dump($cache->fetch('undeclared') == unserialize(serialize($undeclared)), $cache->fetch('undeclared') == $undeclared);
+ echo 'undeclared notices: ', count($notices), "\n";
+
+ restore_error_handler();
+}
+
+echo "\nmalformed property names:\n";
+malformed_property_names();
+?>
+--EXPECTF--
+assigned initialized: OK
+never-assigned stays uninitialized: OK
+nullable-never stays uninitialized: OK
+default initialized: OK
+uninitialized read still errors: OK
+can assign after fetch: OK
+type check preserved: OK
+readonly uninitialized survives: OK
+nested uninitialized survives: OK
+serialize parity: OK
+bool(true)
+object(UserCacheMissingScopeBox)#%d (1) {
+ ["secret":"Missing":private]=>
+ int(42)
+}
+bool(true)
+bool(true)
+object(stdClass)#%d (1) {
+ [""]=>
+ string(10) "empty-name"
+}
+object(stdClass)#%d (1) {
+ [""]=>
+ string(10) "empty-name"
+}
+bool(true)
+object(UserCacheEmptyPropertyNameBox)#%d (2) {
+ ["declared"]=>
+ int(1)
+ [""]=>
+ string(10) "empty-name"
+}
+
+malformed property names:
+bool(true)
+store notices: 0
+string(7) "DEFAULT"
+fetch notices: Illegal member variable name
+bool(false)
+unserialize notices: Illegal member variable name | unserialize(): Error at offset %d of %d bytes
+bool(true)
+bool(true)
+bool(true)
+undeclared notices: 0
diff --git a/ext/user_cache/tests/user_cache_object_restore_native_parity.phpt b/ext/user_cache/tests/user_cache_object_restore_native_parity.phpt
new file mode 100644
index 000000000000..d8e60816d711
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_object_restore_native_parity.phpt
@@ -0,0 +1,405 @@
+--TEST--
+UserCache\Cache: restoring objects never calls write-property hooks, refuses classes that native unserialize() refuses, restores class names that now alias internal classes, properties that became static, stale enum cases and safe-direct internal properties like unserialize(), and isolates autoloaders run by a safe-direct restore
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+$name = $value . '!';
+ }
+}
+
+#[AllowDynamicProperties]
+class PropsArrayObject extends ArrayObject
+{
+}
+
+class StampedDate extends DateTimeImmutable
+{
+ public $ctime = 'default';
+ public $timezone_type = 'user';
+}
+
+class TaggedInterval extends DateInterval
+{
+ public $rel = 'default';
+}
+
+class TaggedPeriod extends DatePeriod
+{
+ public $start_ce = 'default';
+}
+
+class Outer implements Serializable
+{
+ public static ?ArrayObject $fetched = null;
+ public static ?array $autoloaded = null;
+
+ public function serialize(): string
+ {
+ return '';
+ }
+
+ public function unserialize(string $data): void
+ {
+ self::$fetched = UserCache\Cache::getPool('safe-direct-autoload-lock')->fetch('array-object');
+ }
+
+ public function __serialize(): array
+ {
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+/* Values are decoded by a fresh process so no request-local copy is reused. */
+function fetch_in_child(string $key, callable $check): void
+{
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $check(Cache::getPool('restore-parity')->fetch($key, 'default'));
+ exit(0);
+ }
+
+ pcntl_waitpid($pid, $status);
+}
+
+function outcome(Closure $restore): string
+{
+ try {
+ return get_debug_type($restore());
+ } catch (Throwable $e) {
+ return $e::class . ': ' . $e->getMessage();
+ }
+}
+
+/* Store bare enums and enums nested in exception trace arguments. */
+function user_cache_stale_enum_capture($case): RuntimeException
+{
+ return new RuntimeException('captured');
+}
+
+function restore_internal_alias(): void
+{
+ $cache = Cache::getPool('restore-internal-alias');
+ $cache->clear();
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ eval('class AliasPlain {} class AliasSleeps { public function __sleep() { return []; } } class AliasNested {} class AliasArray { public $a = 1; } class AliasIterator { public $a = 1; }');
+ $child = Cache::getPool('restore-internal-alias');
+ $values = [
+ 'plain' => new AliasPlain(),
+ 'sleeps' => ['x' => new AliasSleeps()],
+ 'nested' => ['list' => [1, 2], 'obj' => new AliasNested()],
+ 'array' => new AliasArray(),
+ 'iterator' => new AliasIterator(),
+ ];
+ foreach ($values as $key => $value) {
+ $child->store($key, $value);
+ $child->store("native-$key", serialize($value));
+ }
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ class_alias(Random\Randomizer::class, 'AliasPlain');
+ class_alias(Random\Randomizer::class, 'AliasSleeps');
+ class_alias(Random\Randomizer::class, 'AliasNested');
+ class_alias(ArrayObject::class, 'AliasArray');
+ class_alias(IteratorIterator::class, 'AliasIterator');
+
+ foreach (['plain', 'sleeps', 'nested', 'array'] as $key) {
+ $fetched = outcome(fn () => $cache->fetch($key, 'refused'));
+ $native = outcome(fn () => unserialize($cache->fetch("native-$key")));
+ echo $key, ': ', $fetched, $fetched === $native ? ' (same as unserialize())' : " (unserialize(): $native)", "\n";
+ }
+
+ var_dump($cache->fetch('iterator', 'refused'), $cache->has('iterator'));
+}
+
+function restore_static_property_rename(): void
+{
+ $cache = Cache::getPool('restore-static-property-rename');
+ $cache->clear();
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ eval('class BecameStatic { public $a = "v"; }');
+ $object = new BecameStatic();
+ file_put_contents(__DIR__ . '/user_cache_object_restore_native_parity_static_property.ser', serialize($object));
+ Cache::getPool('restore-static-property-rename')->store('k', $object);
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ eval('class BecameStatic { public static $a = "s"; }');
+
+ set_error_handler(function (int $errno, string $message): bool {
+ echo 'error: ', $message, "\n";
+
+ return true;
+ });
+
+ $fetched = $cache->fetch('k');
+ $native = unserialize(file_get_contents(__DIR__ . '/user_cache_object_restore_native_parity_static_property.ser'));
+ var_dump($fetched, $fetched == $native, BecameStatic::$a);
+
+ restore_error_handler();
+}
+
+function stale_enum_case(): void
+{
+ $cache = UserCache\Cache::getPool('stale-enum-case');
+ $cache->clear();
+
+ $names = ['missing', 'const', 'kept', 'backed'];
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ /* Enum definitions in effect when the entries are stored. */
+ enum UserCacheStaleEnumMissing { case Active; case Gone; }
+ enum UserCacheStaleEnumConst { case Active; case Renamed; }
+ enum UserCacheStaleEnumKept { case Active; }
+ enum UserCacheStaleEnumBacked: string { case Active = 'active'; }
+
+ $cases = [
+ 'missing' => UserCacheStaleEnumMissing::Gone,
+ 'const' => UserCacheStaleEnumConst::Renamed,
+ 'kept' => UserCacheStaleEnumKept::Active,
+ 'backed' => UserCacheStaleEnumBacked::Active,
+ ];
+ $ok = true;
+ foreach ($cases as $name => $case) {
+ $ok = $cache->store("$name-graph", $case) && $ok;
+ $ok = $cache->store("$name-trace", user_cache_stale_enum_capture($case)) && $ok;
+ }
+ exit($ok ? 0 : 1);
+ }
+
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+ /* Enum definitions in effect when the entries are fetched (stale). */
+ enum UserCacheStaleEnumMissing { case Active; }
+ enum UserCacheStaleEnumConst { case Active; const Renamed = 'renamed'; }
+ enum UserCacheStaleEnumKept { case Active; }
+ enum UserCacheStaleEnumBacked: string { case Active = 'active'; }
+
+ foreach ($names as $name) {
+ echo "$name-graph: ";
+ var_dump($cache->fetch("$name-graph", 'default'));
+ echo "$name-graph has: ";
+ var_dump($cache->has("$name-graph"));
+
+ echo "$name-trace: ";
+ $value = $cache->fetch("$name-trace", 'default');
+ if ($value instanceof RuntimeException) {
+ var_dump($value->getTrace()[0]['args'][0]);
+ } else {
+ var_dump($value);
+ }
+ echo "$name-trace has: ";
+ var_dump($cache->has("$name-trace"));
+ }
+}
+
+function safe_direct_internal_properties(): void
+{
+ $interval = new DateInterval('P1D');
+ json_encode($interval);
+ $interval->d = 5;
+
+ $date = new StampedDate('2020-01-02 03:04:05', new DateTimeZone('Europe/Paris'));
+ $date->ctime = 'changed';
+
+ $tagged = new TaggedInterval('P2D');
+ $tagged->rel = 'changed';
+
+ $period = new TaggedPeriod(new DateTime('2020-01-01'), new DateInterval('P1D'), 2);
+ $period->start_ce = 'changed';
+
+ $values = ['interval' => $interval, 'date' => $date, 'tagged' => $tagged, 'period' => $period];
+ $cache = Cache::getPool('safe-direct-internal-properties');
+ foreach ($values as $key => $value) {
+ $cache->store($key, $value);
+ }
+
+ /* A fresh process decodes the stored state instead of a request-local copy. */
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $cache = Cache::getPool('safe-direct-internal-properties');
+ foreach ($values as $key => $value) {
+ $fetched = $cache->fetch($key);
+ echo $key, ': ', serialize($fetched) === serialize(unserialize(serialize($value))) ? 'same as unserialize()' : 'DIFFERENT', "\n";
+ }
+ var_dump($cache->fetch('interval')->d, $cache->fetch('date')->ctime, $cache->fetch('tagged')->rel, $cache->fetch('period')->start_ce);
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+}
+
+function safe_direct_autoload_serialize_lock(): void
+{
+ $cache = UserCache\Cache::getPool('safe-direct-autoload-lock');
+ $cache->delete('array-object');
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ eval('class LazyIterator extends ArrayIterator {}');
+ $value = new ArrayObject([1, 2]);
+ $value->setIteratorClass('LazyIterator');
+ $cache->store('array-object', $value);
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ spl_autoload_register(function (string $class): void {
+ if ($class === 'LazyIterator') {
+ Outer::$autoloaded = unserialize('a:2:{i:0;O:8:"stdClass":1:{s:3:"tag";s:5:"inner";}i:1;r:2;}');
+ eval('class LazyIterator extends ArrayIterator {}');
+ }
+ });
+
+ $outer = unserialize('a:2:{i:0;O:8:"stdClass":1:{s:3:"tag";s:5:"outer";}i:1;C:5:"Outer":0:{}}');
+ var_dump($outer[0]->tag, get_class($outer[1]));
+ var_dump(Outer::$autoloaded[0]->tag, Outer::$autoloaded[1]->tag);
+ var_dump(Outer::$fetched->getIteratorClass(), Outer::$fetched->getArrayCopy());
+}
+
+$cache = Cache::getPool('restore-parity');
+$cache->clear();
+
+$box = new SettingBox();
+$box->name = 'a';
+var_dump($box->name, $cache->store('box', $box));
+fetch_in_child('box', function ($fetched) use ($box): void {
+ var_dump($fetched->name, $fetched == unserialize(serialize($box)));
+});
+
+$array = new PropsArrayObject(['k' => 'v']);
+$array->extra = 1;
+$array->setFlags(ArrayObject::ARRAY_AS_PROPS);
+var_dump($cache->store('array', $array));
+fetch_in_child('array', function ($fetched) use ($array): void {
+ var_dump($fetched->getArrayCopy(), serialize($fetched) === serialize(unserialize(serialize($array))));
+});
+
+try {
+ $cache->store('anonymous', new class('2020-01-01') extends DateTimeImmutable {});
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ eval('class RestoreParityAliased {}');
+ Cache::getPool('restore-parity')->store('aliased', new RestoreParityAliased());
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+class_alias('Closure', 'RestoreParityAliased');
+var_dump($cache->fetch('aliased', 'refused'));
+
+echo "\ninternal alias:\n";
+restore_internal_alias();
+
+echo "\nstatic property rename:\n";
+restore_static_property_rename();
+
+echo "\nstale enum case:\n";
+stale_enum_case();
+
+echo "\nsafe-direct internal properties:\n";
+safe_direct_internal_properties();
+
+echo "\nsafe-direct autoload serialize lock:\n";
+safe_direct_autoload_serialize_lock();
+?>
+--CLEAN--
+
+--EXPECTF--
+string(2) "a!"
+bool(true)
+string(2) "a!"
+bool(true)
+bool(true)
+array(1) {
+ ["k"]=>
+ string(1) "v"
+}
+bool(true)
+DateTimeImmutable@anonymous objects cannot be stored in the user cache
+string(7) "refused"
+
+internal alias:
+plain: Exception: Invalid serialization data for Random\Randomizer object (same as unserialize())
+sleeps: Exception: Invalid serialization data for Random\Randomizer object (same as unserialize())
+nested: Exception: Invalid serialization data for Random\Randomizer object (same as unserialize())
+array: UnexpectedValueException: Incomplete or ill-typed serialization data (same as unserialize())
+string(7) "refused"
+bool(false)
+
+static property rename:
+object(BecameStatic)#%d (1) {
+ ["a"]=>
+ string(1) "v"
+}
+bool(true)
+string(1) "s"
+
+stale enum case:
+bool(true)
+missing-graph: string(7) "default"
+missing-graph has: bool(false)
+missing-trace: string(7) "default"
+missing-trace has: bool(false)
+const-graph: string(7) "default"
+const-graph has: bool(false)
+const-trace: string(7) "default"
+const-trace has: bool(false)
+kept-graph: enum(UserCacheStaleEnumKept::Active)
+kept-graph has: bool(true)
+kept-trace: enum(UserCacheStaleEnumKept::Active)
+kept-trace has: bool(true)
+backed-graph: enum(UserCacheStaleEnumBacked::Active)
+backed-graph has: bool(true)
+backed-trace: enum(UserCacheStaleEnumBacked::Active)
+backed-trace has: bool(true)
+
+safe-direct internal properties:
+interval: same as unserialize()
+date: same as unserialize()
+tagged: same as unserialize()
+period: same as unserialize()
+int(5)
+string(7) "changed"
+string(7) "changed"
+string(7) "changed"
+
+safe-direct autoload serialize lock:
+string(5) "outer"
+string(5) "Outer"
+string(5) "inner"
+string(5) "inner"
+string(12) "LazyIterator"
+array(2) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+}
diff --git a/ext/user_cache/tests/user_cache_packed_graph.phpt b/ext/user_cache/tests/user_cache_packed_graph.phpt
new file mode 100644
index 000000000000..353345bf19ac
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_packed_graph.phpt
@@ -0,0 +1,130 @@
+--TEST--
+UserCache\Cache: packed graph values preserve nested arrays, aliases, append indexes and relocated pointers
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+ 7];
+$scalar = 13;
+$leaf = [str_repeat('leaf', 512), ['name' => "value\0tail", 'id' => 11]];
+$tail = [$node, 1, 2];
+unset($tail[2]);
+$holes = [$node, 1, 2];
+unset($holes[1]);
+$empty = [1];
+unset($empty[0]);
+
+$values = [null, false, true, 17, 2.5, "text\0tail", $node, $node,
+ &$scalar, &$scalar, $leaf, $leaf, $tail, $holes, $empty];
+
+check($cache->store('values', $values), 'initial store');
+$fetched = $cache->fetch('values');
+check(array_keys($fetched) === range(0, 14), 'packed keys');
+check(array_slice($fetched, 0, 6) === array_slice($values, 0, 6), 'scalar values');
+check($fetched[6] === $fetched[7] && $fetched[6]->id === 7, 'object identity');
+$fetched[8] = 29;
+check($fetched[9] === 29, 'reference identity');
+check($fetched[10] === $leaf && $fetched[11] === $leaf, 'nested arrays');
+$fetched[10][1]['id'] = 31;
+check($fetched[11] === $leaf, 'nested copy on write');
+
+foreach ([12 => [0, 1, 3], 13 => [0, 2, 3], 14 => [1]] as $index => $keys) {
+ $fetched[$index][] = 'appended';
+ check(array_keys($fetched[$index]) === $keys, "append index $index");
+}
+$fetched[] = 'last';
+check(array_key_last($fetched) === 15, 'parent append');
+
+$replacement = [str_repeat('replacement', 4096), $node, $leaf, $values];
+check($cache->store('values', $replacement), 'replacement store');
+$again = $cache->fetch('values');
+check($again[0] === $replacement[0] && $again[2] === $leaf, 'replacement values');
+check($again[3][6] === $again[3][7], 'nested replacement identity');
+check($again[3][8] === 13 && $again[3][9] === 13, 'stored reference isolated');
+check($fetched[11] === $leaf, 'old graph remains pinned');
+
+$cycle = [];
+$cycle[] = &$cycle;
+check($cache->store('cycle', $cycle), 'packed cycle store');
+$fetchedCycle = $cache->fetch('cycle');
+$fetchedCycle[0][1] = 'cycle';
+check($fetchedCycle[0][0][1] === 'cycle', 'packed self reference');
+$freshCycle = $cache->fetch('cycle');
+check(count($freshCycle[0]) === 1, 'packed cycle fetch isolated');
+
+$packedChild = [$node];
+$packedAliases = [$packedChild, $packedChild, &$packedChild, &$packedChild];
+check($cache->store('aliases', $packedAliases), 'packed aliases store');
+$fetchedAliases = $cache->fetch('aliases');
+$fetchedAliases[0][] = 'copy';
+check(count($fetchedAliases[1]) === 1 && count($fetchedAliases[2]) === 1, 'packed array copy on write');
+$fetchedAliases[2][] = 'reference';
+check($fetchedAliases[3][1] === 'reference' && count($fetchedAliases[1]) === 1, 'packed reference aliases');
+$freshAliases = $cache->fetch('aliases');
+check(array_map('count', $freshAliases) === [1, 1, 1, 1], 'packed aliases fetch isolated');
+
+/* Release builds do not collect cycles at shutdown. */
+unset($fetched, $again, $replacement, $values, $node, $tail, $holes,
+ $cycle, $fetchedCycle, $freshCycle, $packedChild, $packedAliases, $fetchedAliases, $freshAliases);
+gc_collect_cycles();
+echo "layout: OK\n";
+
+/* Scalar-only graphs with long binary keys, holes and repeated sub-arrays survive relocation */
+$cache = UserCache\Cache::getPool('packed-graph-scalar');
+$longKey = str_repeat('key', 512) . "\0suffix";
+$longValue = str_repeat('value', 512) . "\0tail";
+$leaf = [$longValue, ['id' => 17, 'name' => $longValue], true, null, 3.5, []];
+$holes = $leaf;
+unset($holes[1]);
+$mixed = [
+ -9 => 'negative',
+ '09' => 'numeric string',
+ $longKey => $leaf,
+ 'alias' => $leaf,
+ 40 => $holes,
+ 'empty' => [],
+];
+
+$payloads = ['mixed' => $mixed, 'packed' => [$leaf, $mixed, $leaf], 'holes' => $holes];
+foreach ($payloads as $label => $value) {
+ check($cache->store($label, $value), "$label initial store");
+ $fetched = $cache->fetch($label);
+ check($fetched === $value, "$label initial fetch");
+
+ $replacement = [$value, $value, str_repeat('replacement', 1024)];
+ check($cache->store($label, $replacement), "$label replacement store");
+ $replacementFetched = $cache->fetch($label);
+ check($replacementFetched === $replacement, "$label replacement fetch");
+ check($fetched === $value, "$label pinned original");
+
+ $control = $value;
+ $control[] = 'appended';
+ $fetched[] = 'appended';
+ check($fetched === $control, "$label next free index");
+ $replacementFetched[0][] = 'changed';
+ check($replacementFetched[1] === $value, "$label alias copy on write");
+ check($cache->fetch($label) === $replacement, "$label cached copy on write");
+
+ unset($fetched, $replacementFetched);
+ check($cache->store($label, $value), "$label smaller store");
+ check($cache->store($label, $value), "$label repeated store");
+ check($cache->fetch($label) === $value, "$label final fetch");
+ echo "$label: OK\n";
+}
+?>
+--EXPECT--
+layout: OK
+mixed: OK
+packed: OK
+holes: OK
diff --git a/ext/user_cache/tests/user_cache_packed_hooks.phpt b/ext/user_cache/tests/user_cache_packed_hooks.phpt
new file mode 100644
index 000000000000..dad7a5bcc470
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_packed_hooks.phpt
@@ -0,0 +1,345 @@
+--TEST--
+UserCache\Cache: packed arrays follow store and decode hook mutations and keep late hook objects
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+values[self::$position] = new PackedLateState;
+ return [];
+ }
+
+ public function __unserialize(array $state): void
+ {
+ }
+}
+
+class PackedLateState
+{
+ public function __serialize(): array
+ {
+ PackedLateTrigger::$calls[] = 'copy';
+ if (PackedLateTrigger::$replace) {
+ PackedLateTrigger::$holder->values[63] = 900;
+ }
+ return [];
+ }
+
+ public function __unserialize(array $state): void
+ {
+ }
+}
+
+$cache = UserCache\Cache::getPool('packed-hooks-late');
+foreach ([0, 32] as $position) {
+ foreach ([false, true] as $replace) {
+ $holder = new PackedLateHolder;
+ $holder->values = range(0, 63);
+ $holder->values[0] = new stdClass;
+ $holder->values[$position] = new PreviouslySizedPackedNodeWithANameLongEnoughForTheReplacementStateNode;
+ $holder->trigger = new PackedLateTrigger;
+ PackedLateTrigger::$holder = $holder;
+ PackedLateTrigger::$position = $position;
+ PackedLateTrigger::$replace = $replace;
+ PackedLateTrigger::$calls = [];
+ if (!$cache->store('value', $holder)) {
+ throw new RuntimeException('store failed');
+ }
+ $fetched = $cache->fetch('value');
+ var_dump(
+ array_is_list($fetched->values),
+ $fetched->values[$position] instanceof PackedLateState,
+ $fetched->values[63] === ($replace ? 900 : 63),
+ PackedLateTrigger::$calls === ['calc', 'copy']
+ );
+ }
+}
+PackedLateTrigger::$holder = null;
+
+/* Sibling store hook unsets and adds keys on sized and not yet sized packed arrays */
+class PackedStoreHolder
+{
+ public array $values;
+ public mixed $trigger = null;
+}
+
+class PackedStoreMutator
+{
+ public static ?PackedStoreHolder $holder = null;
+ public static string $operation;
+ public static int $calls = 0;
+
+ public function __serialize(): array
+ {
+ self::$calls++;
+ unset(self::$holder->values[1]);
+ if (self::$operation === 'mixed') {
+ self::$holder->values['changed'] = 19;
+ }
+
+ return ['changed' => 19];
+ }
+
+ public function __unserialize(array $state): void
+ {
+ }
+}
+
+$cache = UserCache\Cache::getPool('packed-hooks-store');
+foreach (['current', 'earlier'] as $position) {
+ foreach (['hole', 'mixed'] as $operation) {
+ $holder = new PackedStoreHolder;
+ $holder->values = range(0, 63);
+ if ($position === 'current') {
+ $holder->values[] = new PackedStoreMutator;
+ } else {
+ $holder->values[0] = new stdClass;
+ $holder->trigger = new PackedStoreMutator;
+ }
+ PackedStoreMutator::$holder = $holder;
+ PackedStoreMutator::$operation = $operation;
+ PackedStoreMutator::$calls = 0;
+
+ if (!$cache->store('value', $holder)) {
+ throw new RuntimeException("$position $operation store failed");
+ }
+ $fetched = $cache->fetch('value');
+ var_dump(array_keys($fetched->values) === array_keys($holder->values));
+ var_dump($fetched->values[63] === 63 && !isset($fetched->values[1]));
+ var_dump($operation !== 'mixed' || $fetched->values['changed'] === 19);
+ var_dump(PackedStoreMutator::$calls === 1);
+ echo "$position $operation\n";
+ }
+}
+
+/* Release builds do not collect cycles at shutdown. */
+PackedStoreMutator::$holder = null;
+unset($holder, $fetched);
+gc_collect_cycles();
+
+/* Decode hook mutates the parent packed array and keeps its internal cursor */
+class PackedMutationHolder
+{
+ public array $values;
+}
+
+class PackedMutationProbe
+{
+ public static array $prefixes = [];
+ public array $parent;
+ public string $operation;
+
+ public function __serialize(): array
+ {
+ return ['parent' => &$this->parent, 'operation' => $this->operation];
+ }
+
+ public function __unserialize(array $state): void
+ {
+ $this->parent =& $state['parent'];
+ $this->operation = $state['operation'];
+ self::$prefixes[] = array_keys($this->parent);
+
+ switch ($this->operation) {
+ case 'mixed':
+ $this->parent['hook'] = 'mixed';
+ break;
+ case 'holes':
+ unset($this->parent[1]);
+ break;
+ case 'append':
+ for ($i = 0; $i < 12; $i++) {
+ $this->parent[] = 100 + $i;
+ }
+ break;
+ case 'replace':
+ $this->parent = array_fill(0, 1, 111);
+ break;
+ case 'cursor':
+ next($this->parent);
+ break;
+ }
+ }
+}
+
+$cache = UserCache\Cache::getPool('packed-hooks-decode');
+foreach (['mixed', 'holes', 'append', 'replace', 'cursor'] as $operation) {
+ $values = [10, 20, 30];
+ $probe = new PackedMutationProbe;
+ $probe->parent =& $values;
+ $probe->operation = $operation;
+ $values[] = $probe;
+ $values[] = 40;
+ $values[] = 50;
+ $holder = new PackedMutationHolder;
+ $holder->values =& $values;
+ if (!$cache->store($operation, $holder)) {
+ throw new RuntimeException('store failed');
+ }
+
+ $native = unserialize(serialize($holder));
+ $restored = $cache->fetch($operation);
+ echo $operation, ':', implode(',', array_keys($restored->values)), ':',
+ key($restored->values), "\n";
+ var_dump(array_keys($restored->values) === array_keys($native->values));
+ if ($operation === 'replace') {
+ var_dump($restored->values === [111]);
+ } else {
+ $restoredProbe = $restored->values[3];
+ var_dump($restoredProbe->parent[3] === $restoredProbe
+ && $restored->values[4] === 40 && $restored->values[5] === 50);
+ }
+
+ /* Release builds do not collect cycles at shutdown. */
+ unset($restoredProbe, $restored, $native, $holder, $probe, $values);
+ gc_collect_cycles();
+}
+
+var_dump(PackedMutationProbe::$prefixes === array_fill(0, 10, [0, 1, 2, 3, 4, 5]));
+
+/* The complete graph is visible to the restore hook and survives its exception. */
+class PackedDecodePrefixProbe
+{
+ public static bool $throw = false;
+ public static array $prefixes = [];
+ public array $parent;
+
+ public function __serialize(): array
+ {
+ return ['parent' => &$this->parent];
+ }
+
+ public function __unserialize(array $state): void
+ {
+ $this->parent =& $state['parent'];
+ self::$prefixes[] = array_keys($this->parent);
+ $nested = UserCache\Cache::getPool('packed-hooks-prefix')->fetch('nested');
+ if ($nested[0] !== 7 || !($nested[1] instanceof stdClass)) {
+ throw new RuntimeException('reentrant fetch failed');
+ }
+ if (self::$throw) {
+ throw new RuntimeException('restore failed');
+ }
+ }
+}
+
+$cache = UserCache\Cache::getPool('packed-hooks-prefix');
+$cache->store('nested', [7, new stdClass]);
+$values = [1, 'two', false];
+$probe = new PackedDecodePrefixProbe;
+$probe->parent =& $values;
+$values[] = $probe;
+$values[] = 'after';
+$values[] = 6;
+var_dump($cache->store('value', $values));
+
+PackedDecodePrefixProbe::$throw = true;
+try {
+ $cache->fetch('value');
+} catch (RuntimeException $exception) {
+ echo $exception->getMessage(), "\n";
+}
+PackedDecodePrefixProbe::$throw = false;
+$result = $cache->fetch('value');
+var_dump($result[0], $result[1], $result[2], $result[4], $result[5]);
+var_dump($result[3] === $result[3]->parent[3]);
+foreach (PackedDecodePrefixProbe::$prefixes as $prefix) {
+ echo implode(',', $prefix), "\n";
+}
+$result[] = 7;
+echo array_key_last($result), "\n";
+
+$result = $cache->fetch('value');
+echo count($result), "\n";
+
+/* Release builds do not collect cycles at shutdown. */
+unset($probe, $values, $result, $exception);
+gc_collect_cycles();
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+current hole
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+current mixed
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+earlier hole
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+earlier mixed
+mixed:0,1,2,3,4,5,hook:0
+bool(true)
+bool(true)
+holes:0,2,3,4,5:0
+bool(true)
+bool(true)
+append:0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17:0
+bool(true)
+bool(true)
+replace:0:0
+bool(true)
+bool(true)
+cursor:0,1,2,3,4,5:1
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+restore failed
+int(1)
+string(3) "two"
+bool(false)
+string(5) "after"
+int(6)
+bool(true)
+0,1,2,3,4,5
+0,1,2,3,4,5
+6
+6
diff --git a/ext/user_cache/tests/user_cache_parallel_mutations.phpt b/ext/user_cache/tests/user_cache_parallel_mutations.phpt
new file mode 100644
index 000000000000..008270a852c1
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_parallel_mutations.phpt
@@ -0,0 +1,109 @@
+--TEST--
+UserCache\Cache: concurrent increments, scalar overwrites and structural writes stay coherent with scalar stripes active and respect entry locks
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+store('shared', 0);
+$cache->store('shared', 0);
+$cache->store('guarded', 0);
+for ($worker = 0; $worker < $workers; $worker++) {
+ $cache->store("own-$worker", 0);
+}
+if (!$cache->lock('guarded')) die("parent lock failed\n");
+
+$pairs = [];
+for ($worker = 0; $worker < $workers; $worker++) {
+ $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+ if ($pair === false) die("socket pair failed\n");
+ stream_set_timeout($pair[0], 15);
+ stream_set_timeout($pair[1], 15);
+ $pairs[] = $pair;
+}
+$children = [];
+for ($worker = 0; $worker < $workers; $worker++) {
+ $pid = pcntl_fork();
+ if ($pid < 0) die("fork failed\n");
+ if ($pid === 0) {
+ foreach ($pairs as $index => $pair) {
+ fclose($pair[0]);
+ if ($index !== $worker) fclose($pair[1]);
+ }
+ $peer = $pairs[$worker][1];
+ if ($cache->unlock('guarded') || $cache->lock('guarded')) exit(1);
+ fwrite($peer, "ready\n");
+ if (fgets($peer) !== "go\n") exit(2);
+ fwrite($peer, "attempting\n");
+ if ($cache->increment('guarded') === null) exit(3);
+
+ for ($i = 1; $i <= $iterations; $i++) {
+ $number = $cache->increment('shared');
+ if ($number === null || $cache->fetch('shared') < $number ||
+ !$cache->store("own-$worker", $i) || $cache->fetch("own-$worker") !== $i) {
+ exit(4);
+ }
+ if ($i % 32 === 0) {
+ $text = str_repeat(chr(65 + $worker), 300) . $i;
+ $array = [$worker, $i, ['text' => $text]];
+ if (!$cache->store("text-$worker", $text) ||
+ !$cache->store("array-$worker", $array) ||
+ $cache->fetch("text-$worker") !== $text ||
+ $cache->fetch("array-$worker") !== $array) {
+ exit(5);
+ }
+ if (!$cache->delete("array-$worker") || $cache->has("array-$worker")) {
+ exit(6);
+ }
+ }
+ }
+ fclose($peer);
+ exit(0);
+ }
+ $children[] = $pid;
+}
+foreach ($pairs as $pair) fclose($pair[1]);
+foreach ($pairs as $pair) {
+ if (fgets($pair[0]) !== "ready\n") die("ready barrier failed\n");
+}
+foreach ($pairs as $pair) fwrite($pair[0], "go\n");
+foreach ($pairs as $pair) {
+ if (fgets($pair[0]) !== "attempting\n") die("attempt barrier failed\n");
+}
+usleep(100000);
+var_dump($cache->fetch('guarded') === 0);
+var_dump($cache->unlock('guarded'));
+
+$ok = true;
+foreach ($children as $pid) {
+ pcntl_waitpid($pid, $status);
+ $ok = $ok && pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0;
+}
+foreach ($pairs as $pair) fclose($pair[0]);
+var_dump($ok);
+var_dump($cache->fetch('guarded') === $workers);
+var_dump($cache->fetch('shared') === $workers * $iterations);
+for ($worker = 0; $worker < $workers; $worker++) {
+ if ($cache->fetch("own-$worker") !== $iterations) die("final overwrite mismatch\n");
+}
+echo "all private keys consistent\n";
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+all private keys consistent
diff --git a/ext/user_cache/tests/user_cache_pcre_cache_keys.phpt b/ext/user_cache/tests/user_cache_pcre_cache_keys.phpt
new file mode 100644
index 000000000000..f9acddc698f2
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_pcre_cache_keys.phpt
@@ -0,0 +1,37 @@
+--TEST--
+UserCache\Cache: fetched strings used as regex patterns are not kept as PCRE cache keys
+--SKIPIF--
+
+--FILE--
+store('routes', ['user' => '#^/user/(\d+)$#', 'post' => '#^/post/(\w+)$#']);
+$routes = $cache->fetch('routes');
+var_dump(preg_match($routes['user'], '/user/42', $m), $m[1]);
+var_dump(preg_match($routes['post'], '/post/abc', $m), $m[1]);
+$cache->store('pattern', '#^[a-z]+$#');
+var_dump(preg_match($cache->fetch('pattern'), 'abc'));
+var_dump(preg_match('#^' . str_repeat('a', 3) . '$#', 'aaa'));
+PHP;
+
+/* The PCRE cache releases its keys at process shutdown, after the cache
+ * segment has been unmapped; the exit status is checked in a child because
+ * the crash happens after stdout is closed. */
+$process = proc_open([$php, ...$args, '-r', $code], [1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
+echo stream_get_contents($pipes[1]);
+fclose($pipes[1]);
+var_dump(proc_close($process));
+?>
+--EXPECT--
+int(1)
+string(2) "42"
+int(1)
+string(3) "abc"
+int(1)
+int(1)
+int(0)
diff --git a/ext/user_cache/tests/user_cache_pool_registry_gc.phpt b/ext/user_cache/tests/user_cache_pool_registry_gc.phpt
new file mode 100644
index 000000000000..11f8452b4951
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_pool_registry_gc.phpt
@@ -0,0 +1,110 @@
+--TEST--
+UserCache\Cache: destructors run by the cycle collector or a WeakMap while the pool registry is trimmed cannot reach a freed pool and can register the pool being created
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+memory_limit=512M
+--FILE--
+get()];
+ }
+ }
+}
+
+echo "pool free weak reference:\n";
+
+$victim = UserCache\Cache::getPool('free-weak-reference');
+$victim->clear();
+$payload = [];
+for ($i = 0; $i < 3000; $i++) {
+ $payload[] = str_repeat('x', 100) . $i;
+}
+for ($k = 0; $k < 4; $k++) {
+ $victim->store("big$k", $payload);
+}
+$held = [];
+for ($k = 0; $k < 4; $k++) {
+ $held[] = $victim->fetch("big$k");
+}
+$weak = WeakReference::create($victim);
+$victim = null;
+$keep = [];
+
+gc_collect_cycles();
+$status = gc_status();
+for ($i = $status['threshold'] - $status['roots'] - 1; $i > 0; $i--) {
+ $garbage = new ReachesDyingPool();
+ $garbage->self = $garbage;
+ unset($garbage);
+}
+
+for ($i = 0; $i < 16400; $i++) {
+ $keep[] = UserCache\Cache::getPool("pool-$i");
+}
+
+[$poolsWhenCollected, $reached] = $observed;
+echo 'collected while the registry was trimmed: ';
+var_dump($poolsWhenCollected === 16383);
+var_dump($reached);
+var_dump($weak->get());
+var_dump(count($held[2]));
+
+unset($keep, $held);
+gc_collect_cycles();
+
+echo "\npool registry trim reentry:\n";
+
+class RegistersPoolOnDestruct
+{
+ public function __destruct()
+ {
+ $GLOBALS['nested'] = Cache::getPool($GLOBALS['current']);
+ }
+}
+
+$first = WeakReference::create(Cache::getPool('first'));
+
+/* This pool is referenced only by the registry; freeing it releases its WeakMap value and runs the destructor. */
+$hooks = new WeakMap;
+$trap = Cache::getPool('trap');
+$hooks[$trap] = new RegistersPoolOnDestruct;
+$trapped = WeakReference::create($trap);
+unset($trap);
+
+for ($i = 0; $trapped->get() !== null && $i < 40000; $i++) {
+ $GLOBALS['current'] = "tenant$i";
+ $outer = Cache::getPool($GLOBALS['current']);
+}
+
+var_dump($first->get() === null, $outer === $GLOBALS['nested'], Cache::getPool($GLOBALS['current']) === $outer);
+$entries = 0;
+foreach (Cache::getPools() as $name => $pool) {
+ $entries += $name === $GLOBALS['current'] ? 1 : 0;
+}
+var_dump($entries);
+var_dump(Cache::deletePool($GLOBALS['current']), Cache::hasPool($GLOBALS['current']));
+?>
+--EXPECT--
+pool free weak reference:
+collected while the registry was trimmed: bool(true)
+NULL
+NULL
+int(3000)
+
+pool registry trim reentry:
+bool(true)
+bool(true)
+bool(true)
+int(1)
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_pool_status_index.phpt b/ext/user_cache/tests/user_cache_pool_status_index.phpt
new file mode 100644
index 000000000000..1e044cdaedfd
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_pool_status_index.phpt
@@ -0,0 +1,145 @@
+--TEST--
+UserCache pool status index handles bucket collisions, rehash and bulk rollback
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+user_cache.entries_hint=127
+--FILE--
+getPoolStatus();
+ $actual = $status->getEntryKeys();
+ $expected = array_keys($values);
+ sort($actual);
+ sort($expected);
+ if ($actual !== $expected || $status->getEntryCount() !== count($values)
+ || ($memory !== null && $status->getUsedMemory() !== $memory)
+ || $cache->fetchMultiple(array_keys($values)) !== $values) {
+ throw new Exception('inconsistent pool status');
+ }
+}
+
+// Find two distinct pools in one index bucket and a third in another bucket.
+$seen = [];
+for ($i = 0; ; $i++) {
+ $name = sprintf('index-%04d', $i);
+ $bucket = poolBucket($name);
+ if (isset($seen[$bucket])) {
+ $names = [$seen[$bucket], $name];
+ break;
+ }
+ $seen[$bucket] = $name;
+}
+foreach ($seen as $otherBucket => $name) {
+ if ($otherBucket !== $bucket) {
+ $names[] = $name;
+ break;
+ }
+}
+[$a, $b, $c] = array_map(fn(string $name) => Cache::getPool($name), $names);
+foreach ([$a, $b, $c] as $cache) {
+ $cache->clear();
+}
+
+$av = ["nul\0key" => 'a', "byte\xffkey" => 2, 'tail' => 3];
+$bv = ["nul\0key" => 'b', "byte\xffkey" => 4, 'tail' => 5];
+$cv = ['other' => 6];
+foreach (array_keys($av) as $key) {
+ $a->store($key, $av[$key]);
+ $b->store($key, $bv[$key]);
+}
+$c->storeMultiple($cv);
+$am = $a->getPoolStatus()->getUsedMemory();
+$bm = $b->getPoolStatus()->getUsedMemory();
+$cm = $c->getPoolStatus()->getUsedMemory();
+checkPool($a, $av, $am);
+checkPool($b, $bv, $bm);
+checkPool($c, $cv, $cm);
+echo "pool separation\n";
+
+$av["nul\0key"] = str_repeat('x', 8192);
+$a->store("nul\0key", $av["nul\0key"]);
+$largeMemory = $a->getPoolStatus()->getUsedMemory();
+if ($largeMemory <= $am) {
+ throw new Exception('stale allocation accounting');
+}
+checkPool($a, $av);
+checkPool($b, $bv, $bm);
+checkPool($c, $cv, $cm);
+$av["nul\0key"] = 'a';
+$a->store("nul\0key", 'a');
+// An unpinned combined allocation may be reused without shrinking its block.
+$smallMemory = $a->getPoolStatus()->getUsedMemory();
+if ($smallMemory < $am || $smallMemory > $largeMemory) {
+ throw new Exception('invalid overwrite accounting');
+}
+$am = $smallMemory;
+checkPool($a, $av, $am);
+echo "overwrite accounting\n";
+
+// The final item fails after both replacement and insertion have committed.
+$ok = $a->storeMultiple([
+ "nul\0key" => 'replacement',
+ 'fresh' => 10,
+ 'oversized' => str_repeat('x', 5 * 1024 * 1024),
+]);
+if ($ok) {
+ throw new Exception('oversized batch unexpectedly succeeded');
+}
+checkPool($a, $av, $am);
+checkPool($b, $bv, $bm);
+checkPool($c, $cv, $cm);
+echo "bulk rollback\n";
+
+// More than capacity / 4 tombstones force relocation of surviving entries.
+for ($i = 0; $i < 96; $i++) {
+ $a->store("churn:$i", $i);
+ $av["churn:$i"] = $i;
+}
+checkPool($a, $av);
+for ($i = 0; $i < 96; $i++) {
+ $a->delete("churn:$i");
+ unset($av["churn:$i"]);
+}
+if (Cache::getStatus()->getTombstoneCount() >= 96) {
+ throw new Exception('rehash did not run');
+}
+checkPool($a, $av, $am);
+checkPool($b, $bv, $bm);
+checkPool($c, $cv, $cm);
+echo "rehash\n";
+
+// Remove interleaved chain members and then the remaining colliding pool.
+$a->delete('tail');
+unset($av['tail']);
+$b->delete("nul\0key");
+unset($bv["nul\0key"]);
+checkPool($a, $av);
+checkPool($b, $bv);
+$a->clear();
+checkPool($a, [], 0);
+checkPool($b, $bv);
+Cache::deletePool($names[1]);
+checkPool($b, [], 0);
+checkPool($c, $cv, $cm);
+$a->store('new', 7);
+checkPool($a, ['new' => 7]);
+echo "delete and clear\n";
+?>
+--EXPECT--
+pool separation
+overwrite accounting
+bulk rollback
+rehash
+delete and clear
diff --git a/ext/user_cache/tests/user_cache_pool_status_snapshots.phpt b/ext/user_cache/tests/user_cache_pool_status_snapshots.phpt
new file mode 100644
index 000000000000..7037591534fc
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_pool_status_snapshots.phpt
@@ -0,0 +1,201 @@
+--TEST--
+UserCache pool status snapshots stay independent, follow entry mutations, account used memory per pool and let cycle-collector destructors use the cache while a snapshot is refreshed
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+getEntryKeys();
+ sort($keys);
+ return $keys;
+}
+
+$cache = Cache::getPool('status-snapshots');
+$other = Cache::getPool('status-snapshots-other');
+$cache->clear();
+$other->clear();
+$cache->store('a', 1);
+$first = $cache->getPoolStatus();
+$second = $cache->getPoolStatus();
+var_dump($first !== $second, keysOf($first), keysOf($second));
+$modified = $second->getEntryKeys();
+$modified[0] = 'changed';
+$modified[] = &$modified;
+var_dump(keysOf($cache->getPoolStatus()));
+/* Release builds do not collect cycles at shutdown. */
+$modified = null;
+
+$cache->store('a', str_repeat('a', 8192));
+$cache->storeMultiple(['b' => 2, 'c' => 3]);
+$updated = $cache->getPoolStatus();
+var_dump($updated->getEntryCount(), keysOf($updated));
+var_dump($updated->getUsedMemory() > $first->getUsedMemory());
+var_dump($first->getEntryCount(), keysOf($first));
+$other->store('unrelated', 42);
+var_dump($cache->getPoolStatus()->getUsedMemory() === $updated->getUsedMemory());
+
+$cache->deleteMultiple(['a', 'b']);
+var_dump(keysOf($cache->getPoolStatus()));
+$cache->store('ttl', 'expires', 1);
+$beforeExpiry = $cache->getPoolStatus();
+sleep(2);
+var_dump($cache->has('ttl'));
+// Status accounts for physically resident entries, including unreclaimed TTLs.
+var_dump(keysOf($cache->getPoolStatus()) === keysOf($beforeExpiry));
+$cache->delete('ttl');
+var_dump(keysOf($cache->getPoolStatus()));
+
+// Churn triggers tombstone removal and slot relocation.
+for ($i = 0; $i < 512; $i++) {
+ $cache->store("churn:$i", $i);
+}
+$full = $cache->getPoolStatus();
+for ($i = 0; $i < 512; $i++) {
+ $cache->delete("churn:$i");
+}
+var_dump($full->getEntryCount(), keysOf($cache->getPoolStatus()));
+$cache->clear();
+$empty = $cache->getPoolStatus();
+var_dump($empty->getEntryCount(), $empty->getEntryKeys(), $empty->getUsedMemory());
+
+Cache::deletePool('status-snapshots');
+unset($cache);
+$replacement = Cache::getPool('status-snapshots');
+$replacement->store('replacement', true);
+var_dump(keysOf($replacement->getPoolStatus()), keysOf($first));
+
+$left = Cache::getPool('status-memory-a');
+$right = Cache::getPool('status-memory-b');
+foreach ([$left, $right] as $pool) {
+ $pool->store('int', 123);
+ $pool->store('str', str_repeat('x', 100));
+}
+$before = $left->getPoolStatus()->getUsedMemory();
+var_dump($right->getPoolStatus()->getUsedMemory() === $before);
+var_dump($left->store('large', str_repeat('y', 4096)));
+var_dump($left->getPoolStatus()->getUsedMemory() > $before);
+var_dump($left->delete('large'));
+var_dump($left->getPoolStatus()->getUsedMemory() === $before);
+
+$shrink = Cache::getPool('status-shrink');
+$shrink->store('value', 1);
+$shrink->store('value', str_repeat('z', 4000));
+$large = $shrink->getPoolStatus()->getUsedMemory();
+$shrink->store('value', 'z');
+echo "in-place shrink is accounted: ";
+var_dump($shrink->getPoolStatus()->getUsedMemory() < $large - 3000);
+
+echo "\npool status destructor reentry:\n";
+
+class Reenters
+{
+ public $self;
+
+ public function __destruct()
+ {
+ global $cache;
+
+ var_dump($cache->store('from-destructor', 1));
+ }
+}
+
+unset($first, $second);
+gc_collect_cycles();
+
+$cache = UserCache\Cache::getPool('pool-status-dtor-reentry');
+$cache->clear();
+$cache->store('k1', 'v1');
+
+/* The first status object shares the snapshot's key list. */
+$first = $cache->getPoolStatus();
+$cache->store('k2', 'v2');
+
+$obj = new Reenters;
+$obj->self = $obj;
+unset($obj);
+
+/* Fill the root buffer so that dropping the old key list runs the GC. */
+$keep = [];
+$threshold = gc_status()['threshold'];
+for ($i = 0; gc_status()['roots'] < $threshold - 1; $i++) {
+ $arr = [$i];
+ $keep[] = $arr;
+ unset($arr);
+}
+
+$second = $cache->getPoolStatus();
+var_dump($first->getEntryCount(), $second->getEntryCount(), $cache->fetch('from-destructor'));
+?>
+--EXPECT--
+bool(true)
+array(1) {
+ [0]=>
+ string(1) "a"
+}
+array(1) {
+ [0]=>
+ string(1) "a"
+}
+array(1) {
+ [0]=>
+ string(1) "a"
+}
+int(3)
+array(3) {
+ [0]=>
+ string(1) "a"
+ [1]=>
+ string(1) "b"
+ [2]=>
+ string(1) "c"
+}
+bool(true)
+int(1)
+array(1) {
+ [0]=>
+ string(1) "a"
+}
+bool(true)
+array(1) {
+ [0]=>
+ string(1) "c"
+}
+bool(false)
+bool(true)
+array(1) {
+ [0]=>
+ string(1) "c"
+}
+int(513)
+array(1) {
+ [0]=>
+ string(1) "c"
+}
+int(0)
+array(0) {
+}
+int(0)
+array(1) {
+ [0]=>
+ string(11) "replacement"
+}
+array(1) {
+ [0]=>
+ string(1) "a"
+}
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+in-place shrink is accounted: bool(true)
+
+pool status destructor reentry:
+bool(true)
+int(1)
+int(2)
+int(1)
diff --git a/ext/user_cache/tests/user_cache_pools.phpt b/ext/user_cache/tests/user_cache_pools.phpt
new file mode 100644
index 000000000000..9234769409f5
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_pools.phpt
@@ -0,0 +1,238 @@
+--TEST--
+UserCache\Cache: pool registry, per-pool isolation of clear() and deletePool(), and status objects
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+store('shared-key', 'from-factory'));
+var_dump($c->fetch('shared-key', 'MISS'));
+var_dump($a->fetch('shared-key'));
+
+/* clear() only affects its own pool. */
+$a = UserCache\Cache::getPool('clear-scope-a');
+$b = UserCache\Cache::getPool('clear-scope-b');
+$a->clear();
+$b->clear();
+
+var_dump($a->store('shared-key', 'from-a'));
+var_dump($a->store('a-only', 1));
+var_dump($b->store('shared-key', 'from-b'));
+var_dump($b->store('b-only', 2));
+
+var_dump($a->clear());
+
+var_dump($a->has('shared-key'));
+var_dump($a->has('a-only'));
+var_dump($a->getPoolStatus()->getEntryCount());
+
+var_dump($b->fetch('shared-key'));
+var_dump($b->fetch('b-only'));
+var_dump($b->getPoolStatus()->getEntryCount());
+
+/* deletePool() removes a pool and its values, leaving other pools intact. */
+$a = UserCache\Cache::getPool('delete-a');
+$b = UserCache\Cache::getPool('delete-b');
+
+$a->store('k', 'value-a');
+$b->store('k', 'value-b');
+
+var_dump(UserCache\Cache::hasPool('delete-a'));
+var_dump($a->fetch('k', 'MISS'));
+
+var_dump(UserCache\Cache::deletePool('delete-a'));
+
+var_dump(UserCache\Cache::hasPool('delete-a'));
+$pools = UserCache\Cache::getPools();
+ksort($pools);
+var_dump(array_keys($pools));
+
+$a2 = UserCache\Cache::getPool('delete-a');
+var_dump($a2->fetch('k', 'MISS'));
+var_dump($a2->has('k'));
+
+var_dump($b->fetch('k', 'MISS'));
+
+var_dump(UserCache\Cache::deletePool('never-created'));
+
+/* A live Cache object stays usable after deletePool() removed its pool. */
+$a = UserCache\Cache::getPool('live-ref');
+$a->store('k', 'v');
+var_dump(UserCache\Cache::deletePool('live-ref'));
+var_dump($a->has('k'));
+var_dump($a->store('again', 1));
+var_dump($a->fetch('again', 'MISS'));
+var_dump(UserCache\Cache::hasPool('live-ref'));
+var_dump(UserCache\Cache::getPool('live-ref')->fetch('k', 'MISS'));
+
+/* Status objects expose the global and per-pool statistics surface. */
+$cache = UserCache\Cache::getPool('stats');
+$other = UserCache\Cache::getPool('stats-other');
+$cache->clear();
+$other->clear();
+
+$initial = UserCache\Cache::getStatus();
+$initialPool = $cache->getPoolStatus();
+var_dump($initial instanceof UserCache\CacheStatus);
+var_dump($initialPool instanceof UserCache\CachePoolStatus);
+var_dump($initialPool->getPoolName());
+var_dump($initial->getAvailability()->name);
+var_dump($initial->getConfiguredMemory() === 16 * 1024 * 1024);
+var_dump($initial->getSharedMemorySize() > 0);
+$baseCount = $initial->getEntryCount();
+var_dump($initialPool->getEntryCount());
+
+var_dump($cache->store('key', ['value' => 1]));
+var_dump($other->store('key', 'other'));
+
+$afterStore = UserCache\Cache::getStatus();
+$afterStorePool = $cache->getPoolStatus();
+$afterStoreOther = $other->getPoolStatus();
+var_dump($afterStore->getEntryCount() - $baseCount >= 2);
+var_dump($afterStore->getGraphPinSlotsInUse() >= 0);
+var_dump($afterStorePool->getEntryCount());
+var_dump($afterStoreOther->getEntryCount());
+var_dump($afterStore->getEntryCapacity() > 0);
+var_dump($afterStore->getUsedMemory() > 0);
+var_dump($afterStore->getFreeMemory() > 0);
+var_dump($afterStore->getWastedMemory() >= 0);
+var_dump($afterStore->getTombstoneCount() >= 0);
+var_dump($afterStorePool->getUsedMemory() > 0);
+
+var_dump($cache->clear());
+var_dump($other->clear());
+var_dump($cache->fetch('key', 'missing'));
+var_dump($other->fetch('key', 'missing'));
+
+$afterClear = UserCache\Cache::getStatus();
+$afterClearPool = $cache->getPoolStatus();
+var_dump($afterClear->getEntryCount() === $baseCount);
+var_dump($afterClearPool->getEntryCount());
+
+foreach ([$afterStore, $afterStorePool] as $object) {
+ try {
+ serialize($object);
+ } catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+try {
+ $afterStore->entryCount = 0;
+} catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+/* A numeric pool name is a numeric key of getPools(), like any PHP array key. */
+$numeric = UserCache\Cache::getPool('123');
+$pools = UserCache\Cache::getPools();
+var_dump(isset($pools['123']), $pools[123] === $numeric);
+?>
+--EXPECT--
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+array(2) {
+ [0]=>
+ string(7) "factory"
+ [1]=>
+ string(13) "factory-other"
+}
+bool(true)
+Error
+bool(true)
+string(4) "MISS"
+string(12) "from-factory"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+int(0)
+string(6) "from-b"
+int(2)
+int(2)
+bool(true)
+string(7) "value-a"
+bool(true)
+bool(false)
+array(5) {
+ [0]=>
+ string(13) "clear-scope-a"
+ [1]=>
+ string(13) "clear-scope-b"
+ [2]=>
+ string(8) "delete-b"
+ [3]=>
+ string(7) "factory"
+ [4]=>
+ string(13) "factory-other"
+}
+string(4) "MISS"
+bool(false)
+string(7) "value-b"
+bool(true)
+bool(true)
+bool(false)
+bool(true)
+int(1)
+bool(false)
+string(4) "MISS"
+bool(true)
+bool(true)
+string(5) "stats"
+string(9) "Available"
+bool(true)
+bool(true)
+int(0)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+int(1)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(7) "missing"
+string(7) "missing"
+bool(true)
+int(0)
+Exception: Serialization of 'UserCache\CacheStatus' is not allowed
+Exception: Serialization of 'UserCache\CachePoolStatus' is not allowed
+Error: Cannot create dynamic property UserCache\CacheStatus::$entryCount
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_preferred_memory_model_failure.phpt b/ext/user_cache/tests/user_cache_preferred_memory_model_failure.phpt
new file mode 100644
index 000000000000..eaaf4b4e0eb7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_preferred_memory_model_failure.phpt
@@ -0,0 +1,24 @@
+--TEST--
+UserCache\Cache: a preferred memory model that fails is reported once with the model used instead, and the cache keeps working
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_FAIL_PREFERRED_MEMORY_MODEL=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.preferred_memory_model=mmap
+--FILE--
+store('k', 1), $cache->fetch('k'));
+var_dump(UserCache\Cache::getPool('preferred-memory-model-failure-2')->store('k', 2));
+?>
+--EXPECTF--
+Warning: %s: UserCache: preferred memory model "mmap" failed, using "%s": debug fault: %s (0) in %s on line %d
+bool(true)
+int(1)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_prototype_destructors.phpt b/ext/user_cache/tests/user_cache_prototype_destructors.phpt
new file mode 100644
index 000000000000..ffebd35ef814
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_prototype_destructors.phpt
@@ -0,0 +1,294 @@
+--TEST--
+UserCache\Cache: internal prototypes do not create extra objects with destructors, including nested native state, and release rejected cyclic prototypes and cycles through SPL internal storage
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+error_reporting=E_ALL & ~E_DEPRECATED
+--FILE--
+store('value', $source)) {
+ throw new RuntimeException('store failed');
+ }
+ unset($source);
+ gc_collect_cycles();
+ $sourceEvents = PrototypeDestructorLog::$events;
+
+ PrototypeDestructorLog::$phase = 'first logical request';
+ PrototypeDestructorLog::$events = [];
+ $valuesOk = true;
+ for ($i = 0; $i < 2; $i++) {
+ $fetched = $cache->fetch('value');
+ $valuesOk = $check($fetched) && $valuesOk;
+ unset($fetched);
+ gc_collect_cycles();
+ }
+ $fetchEvents = PrototypeDestructorLog::$events;
+
+ PrototypeDestructorLog::$phase = 'next logical request';
+ PrototypeDestructorLog::$events = [];
+ $clear ? $cache->clear() : $cache->delete('value');
+ gc_collect_cycles();
+
+ echo $name, ': ';
+ var_dump($sourceEvents === ['source']
+ && $fetchEvents === ['first logical request', 'first logical request']
+ && PrototypeDestructorLog::$events === []
+ && PrototypeDestructorLog::$clones === 0
+ && $valuesOk);
+}
+
+exercise('direct', fn() => new PrototypeDestructorValue,
+ fn($v) => $v instanceof PrototypeDestructorValue && $v->value === 41, true);
+exercise('inherited', fn() => new InheritedPrototypeDestructor,
+ fn($v) => $v instanceof InheritedPrototypeDestructor && $v->value === 41, false);
+exercise('visible-alias', function () {
+ $child = new PrototypeDestructorValue;
+ return (object) ['values' => [new stdClass, $child], 'alias' => $child];
+}, fn($v) => $v->values[1] === $v->alias && $v->alias->value === 41, true);
+exercise('cyclic-array', function () {
+ $values = [];
+ $values['self'] =& $values;
+ $values['child'] = new PrototypeDestructorValue;
+ return $values;
+}, fn($v) => $v['self']['child'] === $v['child'] && $v['child']->value === 41, false);
+
+/* The destructor-bearing child is in native state, not declared properties. */
+exercise('array-object', fn() => new ArrayObject([new stdClass, new PrototypeDestructorValue]),
+ fn($v) => $v instanceof ArrayObject && $v[1]->value === 41, false);
+exercise('fixed-array', fn() => SplFixedArray::fromArray([new stdClass, new PrototypeDestructorValue]),
+ fn($v) => $v instanceof SplFixedArray && $v[1]->value === 41, true);
+exercise('stack', function () {
+ $stack = new SplStack;
+ $stack->push(new stdClass);
+ $stack->push(new PrototypeDestructorValue);
+ return $stack;
+}, fn($v) => $v instanceof SplStack && $v->top()->value === 41, false);
+exercise('object-storage', function () {
+ $storage = new SplObjectStorage;
+ $storage->attach(new PrototypeDestructorValue);
+ return $storage;
+}, function ($v) {
+ $v->rewind();
+ return $v instanceof SplObjectStorage && $v->current()->value === 41;
+}, true);
+
+/* The native container itself may inherit or declare a user destructor. */
+exercise('native-subclass', fn() => new PrototypeDestructorArrayObject([41]),
+ fn($v) => $v instanceof PrototypeDestructorArrayObject && $v[0] === 41, false);
+
+function request_local_prototype_internal_cycle(): void
+{
+ $cache = UserCache\Cache::getPool('request-local-prototype-internal-cycle');
+
+ $containers = [
+ 'SplDoublyLinkedList' => static function (stdClass $item): object {
+ $list = new SplDoublyLinkedList;
+ $list->push($item);
+
+ return $list;
+ },
+ 'ArrayObject' => static function (stdClass $item): object {
+ return new ArrayObject(['item' => $item]);
+ },
+ 'SplFixedArray' => static function (stdClass $item): object {
+ $array = new SplFixedArray(1);
+ $array[0] = $item;
+
+ return $array;
+ },
+ ];
+
+ foreach ($containers as $name => $wrap) {
+ $item = new stdClass;
+ $item->container = $wrap($item);
+ var_dump($cache->store($name, $item));
+
+ for ($round = 0; $round < 3; $round++) {
+ $fetched = $cache->fetch($name);
+ foreach ($fetched->container as $inner) {
+ echo $name, ' #', $round, ': ', $inner === $fetched ? 'cycle kept' : 'cycle lost', "\n";
+ }
+ }
+ }
+
+ /* Release builds do not collect cycles at shutdown. */
+ $item = $fetched = $inner = null;
+ gc_collect_cycles();
+}
+
+echo "\nrequest-local prototype internal cycle:\n";
+request_local_prototype_internal_cycle();
+
+class PartialPrototypeDestructor
+{
+ public static string $phase = 'source';
+
+ public function __destruct()
+ {
+ echo 'destructor: ', self::$phase, "\n";
+ }
+}
+
+class NativePartialPrototypeDestructor
+{
+ public static int $count = 0;
+
+ public function __destruct()
+ {
+ self::$count++;
+ }
+}
+
+function releaseCycle(array &$value): void
+{
+ /* The decoded root is a copy of the array referenced by its self edge.
+ * Break both edges so no ordinary user-owned cycle remains for GC. */
+ unset($value['self']['self'], $value['self']);
+}
+
+function releaseNativeCycle(object $container): void
+{
+ $value = $container[0];
+ unset($value['self']['self'], $value['self'], $container[0]);
+}
+
+function prototype_destructor_partial_cycle(): void
+{
+ echo "array root\n";
+ $cache = UserCache\Cache::getPool('partial-prototype-cycle');
+ $source = [];
+ $source['self'] =& $source;
+ $source['child'] = new PartialPrototypeDestructor;
+ $cache->store('value', $source);
+ releaseCycle($source);
+ unset($source);
+ gc_collect_cycles();
+ gc_disable();
+
+ foreach (['first fetch', 'prototype rejection', 'later fetch'] as $phase) {
+ PartialPrototypeDestructor::$phase = $phase;
+ $fetched = $cache->fetch('value');
+ releaseCycle($fetched);
+ unset($fetched);
+ echo $phase, " finished\n";
+ }
+
+ PartialPrototypeDestructor::$phase = 'next logical request';
+ $cache->clear();
+ gc_collect_cycles();
+ echo "next logical request finished\n";
+
+ echo "native container state\n";
+ foreach (['ArrayObject', 'SplFixedArray'] as $class) {
+ $cache = UserCache\Cache::getPool('native-partial-cycle-' . $class);
+ NativePartialPrototypeDestructor::$count = 0;
+ $source = [];
+ $source['self'] =& $source;
+ $source['child'] = new NativePartialPrototypeDestructor;
+ $container = $class === 'ArrayObject'
+ ? new ArrayObject([$source])
+ : SplFixedArray::fromArray([$source]);
+ $cache->store('value', $container);
+ releaseNativeCycle($container);
+ unset($source, $container);
+ gc_collect_cycles();
+
+ $valid = NativePartialPrototypeDestructor::$count === 1;
+ for ($i = 0; $i < 3; $i++) {
+ $fetched = $cache->fetch('value');
+ $value = $fetched[0];
+ $valid = $valid && $value['self']['child'] === $value['child'];
+ unset($value);
+ releaseNativeCycle($fetched);
+ unset($fetched);
+ $valid = $valid && NativePartialPrototypeDestructor::$count === $i + 2;
+ }
+
+ $cache->clear();
+ gc_collect_cycles();
+ echo $class, ': ';
+ var_dump($valid && NativePartialPrototypeDestructor::$count === 4);
+ }
+}
+
+echo "\nprototype destructor partial cycle:\n";
+prototype_destructor_partial_cycle();
+?>
+--EXPECT--
+direct: bool(true)
+inherited: bool(true)
+visible-alias: bool(true)
+cyclic-array: bool(true)
+array-object: bool(true)
+fixed-array: bool(true)
+stack: bool(true)
+object-storage: bool(true)
+native-subclass: bool(true)
+
+request-local prototype internal cycle:
+bool(true)
+SplDoublyLinkedList #0: cycle kept
+SplDoublyLinkedList #1: cycle kept
+SplDoublyLinkedList #2: cycle kept
+bool(true)
+ArrayObject #0: cycle kept
+ArrayObject #1: cycle kept
+ArrayObject #2: cycle kept
+bool(true)
+SplFixedArray #0: cycle kept
+SplFixedArray #1: cycle kept
+SplFixedArray #2: cycle kept
+
+prototype destructor partial cycle:
+array root
+destructor: source
+destructor: first fetch
+first fetch finished
+destructor: prototype rejection
+prototype rejection finished
+destructor: later fetch
+later fetch finished
+next logical request finished
+native container state
+ArrayObject: bool(true)
+SplFixedArray: bool(true)
diff --git a/ext/user_cache/tests/user_cache_reader_slot_exhaustion.phpt b/ext/user_cache/tests/user_cache_reader_slot_exhaustion.phpt
new file mode 100644
index 000000000000..fb7d7c5b8e1c
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_reader_slot_exhaustion.phpt
@@ -0,0 +1,83 @@
+--TEST--
+UserCache\Cache: a process that finds every reader slot held by a live process stops probing the slot owners for the rest of the request
+--EXTENSIONS--
+pcntl
+posix
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=32M
+--FILE--
+clear();
+
+$value = ['list' => range(1, 40), 'text' => str_repeat('p', 400)];
+$cache->store('graph', $value);
+
+[$ready_read, $ready_write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+
+/* Every holder claims a reader slot with its first fetch and then keeps it alive in sleep. */
+$children = [];
+for ($i = 0; $i < HOLDERS; $i++) {
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ fclose($ready_read);
+ $held = UserCache\Cache::getPool('reader-slot-exhaustion')->fetch('graph');
+ fwrite($ready_write, $held === $value ? 'y' : 'n');
+ fclose($ready_write);
+ pcntl_exec('/bin/sleep', ['60']);
+ exit(1);
+ }
+ $children[] = $pid;
+}
+fclose($ready_write);
+
+$ready = '';
+while (strlen($ready) < HOLDERS && !feof($ready_read)) {
+ $ready .= fread($ready_read, HOLDERS);
+}
+var_dump($ready === str_repeat('y', HOLDERS));
+
+/* The first fetch probes the owners once; the following fetches reuse that answer. */
+var_dump($cache->fetch('graph') === $value);
+
+$calls = read_calls();
+$same = true;
+for ($i = 0; $i < 20; $i++) {
+ $same = $same && $cache->fetch('graph') === $value;
+}
+$probes = read_calls() - $calls;
+var_dump($same, $probes < 40);
+
+foreach ($children as $pid) {
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $child_status);
+}
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_recovery_dead_owner_probe.phpt b/ext/user_cache/tests/user_cache_recovery_dead_owner_probe.phpt
new file mode 100644
index 000000000000..9d3ce68ab64b
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_dead_owner_probe.phpt
@@ -0,0 +1,92 @@
+--TEST--
+UserCache\Cache: locks and graph pins of owners that exited are reclaimed by probes made within the same second, and a live owner's lock keeps refusing a clear
+--EXTENSIONS--
+pcntl
+posix
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+$cache->store('graph', range(1, 8));
+
+/* Several children are killed holding the same lock and a pin in quick succession; each takeover re-probes the owner. */
+$taken = 0;
+for ($round = 0; $round < 5; $round++) {
+ $pid = fork_child();
+ if ($pid === 0) {
+ $child = UserCache\Cache::getPool('dead-owner-probe');
+ $child->lock('held');
+ $child->fetch('graph');
+ posix_kill(posix_getpid(), SIGKILL);
+ }
+ pcntl_waitpid($pid, $status);
+ if ($cache->lock('held')) {
+ $taken++;
+ $cache->unlock('held');
+ }
+}
+var_dump($taken);
+
+/* A live child's lock keeps refusing clear() across repeated probes in the same second. */
+[$parent, $child] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+$pid = fork_child();
+if ($pid === 0) {
+ fclose($parent);
+ $holder = UserCache\Cache::getPool('dead-owner-probe');
+ fwrite($child, $holder->lock('live') ? 'L' : 'F');
+ fread($child, 1);
+ exit($holder->unlock('live') ? 0 : 1);
+}
+fclose($child);
+var_dump(fread($parent, 1));
+$refused = 0;
+for ($i = 0; $i < 3; $i++) {
+ if (!$cache->clear()) {
+ $refused++;
+ }
+}
+var_dump($refused, $cache->has('graph'));
+fwrite($parent, 'R');
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+/* The dead readers' pins never block the payload and the lock is free again. */
+var_dump($cache->fetch('graph') === range(1, 8));
+var_dump($cache->lock('held'), $cache->unlock('held'), $cache->lock('live'), $cache->unlock('live'));
+var_dump($cache->clear(), $cache->has('graph'));
+var_dump(UserCache\Cache::getStatus()->getAvailability() === UserCache\CacheAvailability::Available);
+?>
+--EXPECT--
+int(5)
+string(1) "L"
+int(3)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_recovery_dead_reader_pins.phpt b/ext/user_cache/tests/user_cache_recovery_dead_reader_pins.phpt
new file mode 100644
index 000000000000..250d028fe54b
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_dead_reader_pins.phpt
@@ -0,0 +1,51 @@
+--TEST--
+UserCache\Cache: recovery after a writer died inside a write section releases the graph pins of the dead process
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+store("k$i", range(0, 50 + $i));
+}
+
+$reclaimed = Cache::getStatus()->getDeadPinOwnersReclaimed();
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $held = [];
+ for ($i = 0; $i < 10; $i++) {
+ $held[] = $cache->fetch("k$i");
+ }
+ putenv('USER_CACHE_DEBUG_EXIT_IN_WRITE_SECTION=1');
+ $cache->store('child', 'never published');
+ exit(1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+var_dump($cache->store('after', 'ok'));
+
+$status = Cache::getStatus();
+var_dump($status->getGraphPinnedReferences());
+var_dump($status->getGraphPinSlotsInUse());
+var_dump($status->getDeadPinOwnersReclaimed() - $reclaimed);
+var_dump($cache->fetch('after'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(0)
+int(0)
+int(1)
+string(2) "ok"
diff --git a/ext/user_cache/tests/user_cache_recovery_lock_eviction_exit.phpt b/ext/user_cache/tests/user_cache_recovery_lock_eviction_exit.phpt
new file mode 100644
index 000000000000..2158c74af744
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_lock_eviction_exit.phpt
@@ -0,0 +1,45 @@
+--TEST--
+UserCache\Cache: recovery after lock() died while evicting for its key runs only once
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.eviction_policy=lru
+--FILE--
+clear();
+for ($i = 0; $i < 400; $i++) {
+ $cache->store("fill-$i", str_repeat('x', 4000));
+}
+$key = str_repeat('k', UserCache\Cache::getStatus()->getFreeMemory() + 1000);
+
+/* The lock-table section turned into a write section when its process died. */
+$pid = pcntl_fork();
+if ($pid === 0) {
+ putenv('USER_CACHE_DEBUG_EXIT_IN_ENTRY_LOCK_EVICTION=1');
+ $cache->lock($key);
+ exit(1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+var_dump($cache->store('b', 'first after recovery'));
+var_dump($cache->store('c', 'second'));
+var_dump($cache->fetch('b'), $cache->fetch('c'));
+var_dump($cache->lock($key), $cache->unlock($key));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+string(20) "first after recovery"
+string(6) "second"
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_recovery_lock_rehash_exit.phpt b/ext/user_cache/tests/user_cache_recovery_lock_rehash_exit.phpt
new file mode 100644
index 000000000000..56d77fcdae18
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_lock_rehash_exit.phpt
@@ -0,0 +1,95 @@
+--TEST--
+UserCache\Cache: a process dying while it compacts the lock table loses no live lock, leaves no duplicate and is recovered by the next locked read
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.entries_hint=127
+--FILE--
+lock($key) ? 1 : 0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ return pcntl_wexitstatus($status) === 1;
+}
+
+$cache = UserCache\Cache::getPool('recovery-lock-rehash');
+$cache->clear();
+$cache->store('value', 'written before the crash');
+
+/* 'held' sits one slot past its home; the home slot becomes a tombstone. */
+$first = key_at('first-', 40);
+$held = key_at('held-', 40);
+var_dump($cache->lock($first), $cache->lock($held), $cache->unlock($first));
+
+/* Tombstones on 31 other homes reach the compaction threshold of 128 / 4. */
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $slot = 60;
+ for ($n = 0; $n < 31; $n++, $slot++) {
+ $key = key_at('churn-', $slot);
+ if (!$cache->lock($key) || !$cache->unlock($key)) {
+ exit(2);
+ }
+ }
+ /* The next lookup compacts the table and moves 'held' home, then dies. */
+ putenv('USER_CACHE_DEBUG_EXIT_IN_ENTRY_LOCK_REHASH=1');
+ $cache->lock(key_at('churn-', $slot));
+ exit(1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+/* A read under the global lock must recover the half-compacted table before it reopens lock-free scalar writes. */
+$pid = pcntl_fork();
+if ($pid === 0) {
+ putenv('USER_CACHE_DEBUG_FORCE_LOCKED_FETCH=1');
+ echo 'locked read: ', UserCache\Cache::getPool('recovery-lock-rehash')->fetch('value', 'recovered first'), "\n";
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+
+var_dump(child_locks($cache, $held));
+var_dump($cache->unlock($held));
+var_dump(child_locks($cache, $held));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+locked read: recovered first
+bool(false)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_recovery_lock_table_stripe_write.phpt b/ext/user_cache/tests/user_cache_recovery_lock_table_stripe_write.phpt
new file mode 100644
index 000000000000..d868ca95b333
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_lock_table_stripe_write.phpt
@@ -0,0 +1,107 @@
+--TEST--
+UserCache\Cache: a lock-free scalar write waits for recovery of a half-compacted lock table instead of passing a live entry lock
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.entries_hint=127
+--FILE--
+clear();
+
+$held = key_at('held-', 40);
+
+/* The second store of the same scalar key enables lock-free scalar writes. */
+var_dump($cache->store($held, 1), $cache->store($held, 1));
+
+/* 'moved' and 'held' each sit one slot past a tombstoned home slot. */
+$left = key_at('left-', 20);
+$moved = key_at('moved-', 20);
+$first = key_at('first-', 40);
+var_dump($cache->lock($left), $cache->lock($moved), $cache->unlock($left));
+var_dump($cache->lock($first), $cache->lock($held), $cache->unlock($first));
+
+/* Compaction empties both homes, moves 'moved' and dies before 'held': an empty slot now hides 'held'. */
+run_child(function () use ($cache) {
+ for ($n = 0, $slot = 60; $n < 30; $n++, $slot++) {
+ $key = key_at('churn-', $slot);
+ $cache->lock($key);
+ $cache->unlock($key);
+ }
+ putenv('USER_CACHE_DEBUG_EXIT_IN_ENTRY_LOCK_REHASH=1');
+ $cache->lock(key_at('churn-', $slot));
+});
+
+/* A locked read notices the dead compaction, releases the global lock and dies before it recovers. */
+run_child(function () {
+ UserCache\Cache::getPool('recovery-lock-stripe')->fetch('any');
+}, ['FORCE_LOCKED_FETCH', 'EXIT_BEFORE_READ_LOCK_RECOVERY']);
+
+[$result_read, $result_write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+$writer = pcntl_fork();
+if ($writer === 0) {
+ fclose($result_read);
+ fwrite($result_write, var_export(UserCache\Cache::getPool('recovery-lock-stripe')->increment($held), true));
+ exit(0);
+}
+fclose($result_write);
+
+usleep(300000);
+var_dump($cache->store($held, 100), $cache->unlock($held));
+pcntl_waitpid($writer, $status);
+echo 'increment after the owner released: ', stream_get_contents($result_read), "\n";
+var_dump($cache->fetch($held));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+increment after the owner released: 101
+int(101)
diff --git a/ext/user_cache/tests/user_cache_recovery_pin_blocked.phpt b/ext/user_cache/tests/user_cache_recovery_pin_blocked.phpt
new file mode 100644
index 000000000000..d8321127a377
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_pin_blocked.phpt
@@ -0,0 +1,71 @@
+--TEST--
+UserCache\Cache: while a live process pins a shared graph, recovery after a writer died inside a write section waits: stores fail, reads return the default and getStatus() still answers; once the pin is released the next operation recovers and empties the segment
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+clear();
+$cache->store('graph', ['a' => 1, 'b' => [1, 2, 3]]);
+$cache->store('scalar', 42);
+
+function report(UserCache\Cache $cache, string $phase): void
+{
+ $operations = [
+ 'store' => $cache->store("after-$phase", 1),
+ 'scalar' => $cache->fetch('scalar', 'MISS'),
+ 'graph' => $cache->fetch('graph', 'MISS'),
+ 'has' => $cache->has('scalar'),
+ 'lock' => $cache->lock("lock-$phase"),
+ ];
+ $status = UserCache\Cache::getStatus();
+ echo $phase, ': ', json_encode($operations + [
+ 'availability' => $status->getAvailability()->name,
+ 'entries' => $status->getEntryCount(),
+ 'pins' => $status->getGraphPinnedReferences(),
+ ]), "\n";
+}
+
+[$parent, $child] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+$holder = pcntl_fork();
+if ($holder === 0) {
+ fclose($parent);
+ $held = UserCache\Cache::getPool('recovery-pin-blocked')->fetch('graph');
+ fwrite($child, $held['a'] === 1 ? 'P' : 'F');
+ fread($child, 1);
+ exit(0);
+}
+fclose($child);
+var_dump(fread($parent, 1));
+
+$crasher = pcntl_fork();
+if ($crasher === 0) {
+ putenv('USER_CACHE_DEBUG_EXIT_IN_WRITE_SECTION=1');
+ UserCache\Cache::getPool('recovery-pin-blocked')->store('never', 'published');
+ exit(1);
+}
+pcntl_waitpid($crasher, $status);
+var_dump(pcntl_wexitstatus($status));
+
+report($cache, 'blocked');
+
+fwrite($parent, 'R');
+pcntl_waitpid($holder, $status);
+
+report($cache, 'recovered');
+var_dump($cache->has('never'), $cache->fetch('after-recovered'));
+?>
+--EXPECT--
+string(1) "P"
+int(0)
+blocked: {"store":false,"scalar":"MISS","graph":"MISS","has":false,"lock":false,"availability":"Available","entries":0,"pins":0}
+recovered: {"store":true,"scalar":"MISS","graph":"MISS","has":false,"lock":true,"availability":"Available","entries":1,"pins":0}
+bool(false)
+int(1)
diff --git a/ext/user_cache/tests/user_cache_recovery_writer_exit.phpt b/ext/user_cache/tests/user_cache_recovery_writer_exit.phpt
new file mode 100644
index 000000000000..576828314a5d
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_recovery_writer_exit.phpt
@@ -0,0 +1,60 @@
+--TEST--
+UserCache\Cache: a writer that dies inside a write section is recovered by the next writer, keeping live entry locks
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+clear();
+for ($i = 0; $i < 20; $i++) {
+ $cache->store("k$i", str_repeat('v', 500) . $i);
+}
+var_dump($cache->lock('held', 60));
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ putenv('USER_CACHE_DEBUG_EXIT_IN_WRITE_SECTION=1');
+ $cache->store('child', 'never published');
+ exit(1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+
+/* The next writer wipes the entries the dead writer may have torn. */
+var_dump($cache->store('after', 'ok'));
+var_dump(Cache::getStatus()->getAvailability() === UserCache\CacheAvailability::Available);
+var_dump($cache->fetch('k0'), $cache->fetch('child'), $cache->fetch('after'), Cache::getStatus()->getEntryCount());
+
+/* A lock held by a live process survives the wipe. */
+$pid = pcntl_fork();
+if ($pid === 0) {
+ exit($cache->lock('held') ? 1 : 0);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status) === 0);
+var_dump($cache->unlock('held'));
+var_dump($cache->store('k0', 'again'), $cache->fetch('k0'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+NULL
+NULL
+string(2) "ok"
+int(1)
+bool(true)
+bool(true)
+bool(true)
+string(5) "again"
diff --git a/ext/user_cache/tests/user_cache_references.phpt b/ext/user_cache/tests/user_cache_references.phpt
new file mode 100644
index 000000000000..ceb56dedd857
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_references.phpt
@@ -0,0 +1,409 @@
+--TEST--
+UserCache\Cache: references and cycles keep their aliases across arrays, properties and containers
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+error_reporting=E_ALL & ~E_DEPRECATED
+--FILE--
+name . ':' . $this->revision . ':' . $this->peer?->name;
+ }
+}
+
+class UCReferencedPayload
+{
+ public function __construct(
+ public string $label,
+ public int $revision,
+ ) {
+ }
+}
+
+class UCReferenceAssignmentPayload
+{
+ public UCReferencedPayload $alias;
+
+ public function __construct(
+ public string $name,
+ public UCReferencedPayload $child,
+ ) {
+ $this->alias = $child;
+ }
+}
+
+function uc_build_cycle_payload(string $rootName, string $peerName, int $revision): UCCyclePayload
+{
+ $root = new UCCyclePayload($rootName, $revision);
+ $peer = new UCCyclePayload($peerName, $revision + 1);
+ $root->peer = $peer;
+ $peer->peer = $root;
+
+ return $root;
+}
+
+$a = ['value' => 1, 'tag' => 't'];
+$a['alias'] = &$a['value'];
+var_dump($cache->store('k_scalar', $a));
+
+$g = $cache->fetch('k_scalar');
+$g['value'] = 99;
+echo "scalar alias follows write: ";
+var_dump($g['alias'] === 99);
+echo "scalar other fetch is independent: ";
+var_dump($cache->fetch('k_scalar')['value'] === 1);
+
+$x = 10;
+$b = ['r' => &$x, 'tag' => 'u'];
+var_dump($cache->store('k_single', $b));
+echo "single value: ";
+var_dump($cache->fetch('k_single')['r'] === 10);
+
+$o = new UCReferenceHolder();
+$o->n = 7;
+$c = [];
+$c['p'] = &$o;
+$c['q'] = &$o;
+var_dump($cache->store('k_objref', $c));
+
+$gc = $cache->fetch('k_objref');
+echo "objref identity: ";
+var_dump($gc['p'] === $gc['q']);
+$gc['p']->n = 123;
+echo "objref shared mutation: ";
+var_dump($gc['q']->n === 123);
+
+$pair = new UCReferencePair();
+$v = 1;
+$pair->a = &$v;
+$pair->b = &$v;
+var_dump($cache->store('k_proptref', $pair));
+echo "proptref round-trips to object: ";
+var_dump($cache->fetch('k_proptref') instanceof UCReferencePair);
+
+$gp = $cache->fetch('k_proptref');
+$gp->a = 55;
+echo "proptref alias preserved: ";
+var_dump($gp->b === 55);
+
+$typed = new UCTypedReferencePair();
+$typedValue = 11;
+$typed->a = &$typedValue;
+$typed->b = &$typedValue;
+var_dump($cache->store('k_typed_proptref', $typed));
+$gt = $cache->fetch('k_typed_proptref');
+$gt->a = 77;
+echo "typed proptref alias preserved: ";
+var_dump($gt->b === 77);
+try {
+ $gt->a = 'not an int';
+} catch (TypeError $e) {
+ echo "typed proptref type check: TypeError\n";
+}
+
+$dynamic = new stdClass();
+$dynamicValue = 1;
+$dynamic->a = &$dynamicValue;
+$dynamic->b = &$dynamicValue;
+var_dump($cache->store('dpr_scalar', $dynamic));
+$gd = $cache->fetch('dpr_scalar');
+$gd->a = 99;
+echo "dynamic scalar alias: ";
+var_dump($gd->b === 99);
+echo "dynamic independent fetch: ";
+var_dump($cache->fetch('dpr_scalar')->a === 1);
+
+$shared = (object) ['n' => 0];
+$dynamicObject = new stdClass();
+$dynamicObject->x = &$shared;
+$dynamicObject->y = &$shared;
+var_dump($cache->store('dpr_obj', $dynamicObject));
+$gdo = $cache->fetch('dpr_obj');
+echo "dynamic object identity: ";
+var_dump($gdo->x === $gdo->y);
+$gdo->x->n = 7;
+echo "dynamic object shared mutation: ";
+var_dump($gdo->y->n === 7);
+
+$self = ['n' => 5, 'list' => [1, 2, 3]];
+$self['self'] = &$self;
+var_dump($cache->store('k_self', $self));
+$gs = $cache->fetch('k_self');
+echo "self cycle resolves: ";
+var_dump($gs['self'] === $gs);
+echo "value through cycle: ", $gs['self']['n'], " ", $gs['self']['list'][1], "\n";
+
+$p = ['name' => 'p'];
+$q = ['name' => 'q'];
+$p['other'] = &$q;
+$q['other'] = &$p;
+var_dump($cache->store('k_mutual', $p));
+$gm = $cache->fetch('k_mutual');
+echo "mutual cycle resolves: ";
+var_dump($gm['other']['other'] === $gm);
+echo "names: ", $gm['name'], " ", $gm['other']['name'], "\n";
+
+$root = ['a' => ['b' => ['c' => 7]]];
+$root['a']['b']['back'] = &$root;
+var_dump($cache->store('k_deep', $root));
+$deep = $cache->fetch('k_deep');
+echo "deep cycle resolves: ";
+var_dump($deep['a']['b']['back'] === $deep);
+echo "deep value: ", $deep['a']['b']['back']['a']['b']['c'], "\n";
+
+$g1 = $cache->fetch('k_self');
+$g2 = $cache->fetch('k_self');
+$g1['n'] = 99;
+echo "fetches independent: ";
+var_dump($g2['n'] === 5);
+
+$serializedCycle = uc_build_cycle_payload('serialized-root', 'serialized-peer', 1);
+var_dump($cache->store('serialized_cycle_object', $serializedCycle));
+$fetchedSerializedCycle = $cache->fetch('serialized_cycle_object');
+echo "serialized cycle access: ", $fetchedSerializedCycle->describe(), "\n";
+echo "serialized cycle back edge: ";
+var_dump($fetchedSerializedCycle->peer->peer === $fetchedSerializedCycle);
+
+$referenceAssignment = new UCReferenceAssignmentPayload(
+ 'reference-root',
+ new UCReferencedPayload('reference-child', 1),
+);
+var_dump($cache->store('reference_assignment_object', $referenceAssignment));
+$fetchedReferenceAssignment = $cache->fetch('reference_assignment_object');
+echo "reference assignment access: ";
+var_dump($fetchedReferenceAssignment->name . ':' . $fetchedReferenceAssignment->child->label . ':' . $fetchedReferenceAssignment->child->revision);
+echo "reference assignment identity: ";
+var_dump($fetchedReferenceAssignment->child === $fetchedReferenceAssignment->alias);
+$fetchedReferenceAssignment->child->revision = 9;
+echo "reference assignment shared mutation: ";
+var_dump($fetchedReferenceAssignment->alias->revision === 9);
+
+/* Release builds do not collect cycles at shutdown. */
+unset(
+ $a, $g, $x, $b, $o, $c, $gc, $pair, $v, $gp,
+ $typed, $typedValue, $gt, $dynamic, $dynamicValue, $gd,
+ $shared, $dynamicObject, $gdo, $self, $gs, $p, $q, $gm,
+ $root, $deep, $g1, $g2, $serializedCycle, $fetchedSerializedCycle,
+ $referenceAssignment, $fetchedReferenceAssignment
+);
+gc_collect_cycles();
+
+/* One reference spanning array and object containers stays a single alias */
+$cache = UserCache\Cache::getPool('references-cross-container');
+
+class RefHolder
+{
+ public $slot;
+}
+
+class RefPair
+{
+ public $left;
+ public $right;
+}
+
+class ArrayPropHolder
+{
+ public array $data = [];
+}
+
+$value = 5;
+$holder = new RefHolder();
+$holder->slot = &$value;
+$graph = ['inArray' => &$value, 'holder' => $holder];
+
+$cache->store('scalar', $graph);
+$fetched = $cache->fetch('scalar');
+ok('initial value', $fetched['inArray'] === 5 && $fetched['holder']->slot === 5);
+$fetched['inArray'] = 42;
+ok('array write reaches property', $fetched['holder']->slot === 42);
+$fetched['holder']->slot = 99;
+ok('property write reaches array', $fetched['inArray'] === 99);
+
+$other = $cache->fetch('scalar');
+ok('other fetch independent', $other['inArray'] === 5);
+
+$shared = 1;
+$pair = new RefPair();
+$pair->left = &$shared;
+$pair->right = &$shared;
+$wide = ['cell' => &$shared, 'pair' => $pair];
+$cache->store('wide', $wide);
+$fetchedWide = $cache->fetch('wide');
+$fetchedWide['cell'] = 7;
+ok('wide alias left', $fetchedWide['pair']->left === 7);
+ok('wide alias right', $fetchedWide['pair']->right === 7);
+
+$object = new stdClass();
+$object->n = 0;
+$objRef = $object;
+$objHolder = new RefHolder();
+$objHolder->slot = &$objRef;
+$objGraph = ['ref' => &$objRef, 'holder' => $objHolder];
+$cache->store('object', $objGraph);
+$fetchedObj = $cache->fetch('object');
+ok('object alias identity', $fetchedObj['ref'] === $fetchedObj['holder']->slot);
+$fetchedObj['ref']->n = 5;
+ok('object shared mutation', $fetchedObj['holder']->slot->n === 5);
+
+$inner = 3;
+$aph = new ArrayPropHolder();
+$aph->data['a'] = &$inner;
+$aph->data['b'] = &$inner;
+$cache->store('array-prop', $aph);
+$fetchedAph = $cache->fetch('array-prop');
+$fetchedAph->data['a'] = 88;
+ok('reference inside object-array property', $fetchedAph->data['b'] === 88);
+
+/* A property still holding its declared default is encoded when it is a reference */
+$cache = UserCache\Cache::getPool('references-default-alias');
+
+class UserCacheDefaultAlias
+{
+ public int $left = 1;
+ public int $right = 1;
+}
+
+$object = new UserCacheDefaultAlias();
+$object->left =& $object->right;
+
+var_dump($cache->store('object', $object));
+
+$fetched = $cache->fetch('object');
+$fetched->left = 99;
+var_dump($fetched->left, $fetched->right);
+
+/* A plain object cycle fetched twice stays a collectable copy with its back edge intact */
+$cache = UserCache\Cache::getPool('references-plain-cycle');
+
+class UserCachePlainCycleNode
+{
+ public ?UserCachePlainCycleNode $parent = null;
+ public array $children = [];
+
+ public function __construct(public string $name)
+ {
+ }
+}
+
+$root = new UserCachePlainCycleNode('root');
+$leaf = new UserCachePlainCycleNode('leaf');
+$leaf->parent = $root;
+$root->children[] = $leaf;
+
+var_dump($cache->store('graph', $root));
+
+$graph = $cache->fetch('graph');
+echo "decoded names: ", $graph->name, " ", $graph->children[0]->name, "\n";
+echo "back edge identity: ";
+var_dump($graph->children[0]->parent === $graph);
+echo "decoded graph is a copy: ";
+var_dump($graph !== $root);
+echo "leaf children: ";
+var_dump($graph->children[0]->children);
+
+$again = $cache->fetch('graph');
+echo "fetches independent: ";
+var_dump($again !== $graph);
+echo "fetched back edge identity: ";
+var_dump($again->children[0]->parent === $again);
+
+/* Release builds do not collect cycles at shutdown. */
+unset($root, $leaf, $graph, $again);
+gc_collect_cycles();
+?>
+--EXPECT--
+bool(true)
+scalar alias follows write: bool(true)
+scalar other fetch is independent: bool(true)
+bool(true)
+single value: bool(true)
+bool(true)
+objref identity: bool(true)
+objref shared mutation: bool(true)
+bool(true)
+proptref round-trips to object: bool(true)
+proptref alias preserved: bool(true)
+bool(true)
+typed proptref alias preserved: bool(true)
+typed proptref type check: TypeError
+bool(true)
+dynamic scalar alias: bool(true)
+dynamic independent fetch: bool(true)
+bool(true)
+dynamic object identity: bool(true)
+dynamic object shared mutation: bool(true)
+bool(true)
+self cycle resolves: bool(true)
+value through cycle: 5 2
+bool(true)
+mutual cycle resolves: bool(true)
+names: p q
+bool(true)
+deep cycle resolves: bool(true)
+deep value: 7
+fetches independent: bool(true)
+bool(true)
+serialized cycle access: serialized-root:1:serialized-peer
+serialized cycle back edge: bool(true)
+bool(true)
+reference assignment access: string(32) "reference-root:reference-child:1"
+reference assignment identity: bool(true)
+reference assignment shared mutation: bool(true)
+initial value: OK
+array write reaches property: OK
+property write reaches array: OK
+other fetch independent: OK
+wide alias left: OK
+wide alias right: OK
+object alias identity: OK
+object shared mutation: OK
+reference inside object-array property: OK
+bool(true)
+int(99)
+int(99)
+bool(true)
+decoded names: root leaf
+back edge identity: bool(true)
+decoded graph is a copy: bool(true)
+leaf children: array(0) {
+}
+fetches independent: bool(true)
+fetched back edge identity: bool(true)
diff --git a/ext/user_cache/tests/user_cache_rehash_alloc_failure_backoff.phpt b/ext/user_cache/tests/user_cache_rehash_alloc_failure_backoff.phpt
new file mode 100644
index 000000000000..5bd23b2920af
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_rehash_alloc_failure_backoff.phpt
@@ -0,0 +1,43 @@
+--TEST--
+UserCache\Cache: after the entry table rehash fails to allocate its snapshot, the following writes skip the rehash for a while instead of retrying on every write, and a later write rehashes
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.entries_hint=127
+--FILE--
+clear();
+
+putenv('USER_CACHE_DEBUG_FAIL_REHASH_ALLOC=1');
+for ($i = 0; $i < 100; $i++) {
+ $cache->store("k$i", $i);
+}
+for ($i = 0; $i < 60; $i++) {
+ $cache->delete("k$i");
+}
+var_dump(UserCache\Cache::getStatus()->getTombstoneCount());
+putenv('USER_CACHE_DEBUG_FAIL_REHASH_ALLOC');
+
+/* Overwrites with strings take the write lock and keep the tombstones, so only a rehash can clear them. */
+for ($i = 0; $i < 10; $i++) {
+ $cache->store('k99', "v$i");
+}
+var_dump(UserCache\Cache::getStatus()->getTombstoneCount());
+
+for ($i = 0; $i < 100; $i++) {
+ $cache->store('k99', "v$i");
+}
+var_dump(UserCache\Cache::getStatus()->getTombstoneCount(), $cache->fetch('k99'), $cache->fetch('k98'), $cache->has('k0'));
+?>
+--EXPECT--
+int(60)
+int(60)
+int(0)
+string(3) "v99"
+int(98)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_remember.phpt b/ext/user_cache/tests/user_cache_remember.phpt
new file mode 100644
index 000000000000..c79bc1fe97a6
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_remember.phpt
@@ -0,0 +1,132 @@
+--TEST--
+UserCache\Cache: remember() failure paths, lock ownership and by-reference callback results
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+remember('boom', function () {
+ throw new RuntimeException('from callback');
+ });
+} catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->has('boom'));
+var_dump($cache->lock('boom'));
+var_dump($cache->unlock('boom'));
+
+foreach ([
+ 'res' => fn() => fopen('php://memory', 'r'),
+ 'closure' => fn() => fn() => 1,
+ 'nested-res' => fn() => ['inner' => fopen('php://memory', 'r')],
+] as $key => $callback) {
+ try {
+ $cache->remember($key, $callback);
+ } catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+ }
+ var_dump($cache->has($key));
+}
+
+/* remember() keeps a lock the caller already holds and takes none of its own otherwise. */
+$cache = UserCache\Cache::getPool('remember-preheld');
+$cache->clear();
+
+var_dump($cache->lock('key'));
+var_dump($cache->remember('key', fn () => 'value'));
+var_dump($cache->fetch('key'));
+var_dump($cache->unlock('key'));
+
+var_dump($cache->lock('throwing'));
+try {
+ $cache->remember('throwing', function () {
+ throw new RuntimeException('boom');
+ });
+} catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->unlock('throwing'));
+
+var_dump($cache->lock('unstorable'));
+try {
+ $cache->remember('unstorable', fn () => fn () => 1);
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+var_dump($cache->unlock('unstorable'));
+
+var_dump($cache->remember('own', fn () => 'own-value'));
+var_dump($cache->unlock('own'));
+
+/* A by-reference callback return is unwrapped into a detached plain value. */
+$cache = UserCache\Cache::getPool('remember-by-ref');
+$cache->clear();
+
+$g = 42;
+$byRef = function &(string $key) use (&$g) {
+ return $g;
+};
+
+$computed = [$cache->remember('by-ref', $byRef)];
+var_dump($computed);
+var_dump($cache->fetch('by-ref'));
+
+$cached = [$cache->remember('by-ref', $byRef)];
+var_dump($cached);
+
+$g = 99;
+var_dump($cache->fetch('by-ref'));
+
+$calls = 0;
+$plain = static function (string $key) use (&$calls): string {
+ $calls++;
+
+ return 'computed:' . $key;
+};
+var_dump($cache->remember('plain', $plain));
+var_dump($cache->remember('plain', $plain));
+var_dump($cache->fetch('plain'));
+var_dump($calls);
+?>
+--EXPECT--
+from callback
+bool(false)
+bool(true)
+bool(true)
+Resources cannot be stored in the user cache
+bool(false)
+Closure objects cannot be stored in the user cache
+bool(false)
+Resources cannot be stored in the user cache
+bool(false)
+bool(true)
+string(5) "value"
+string(5) "value"
+bool(true)
+bool(true)
+boom
+bool(true)
+bool(true)
+Closure objects cannot be stored in the user cache
+bool(true)
+string(9) "own-value"
+bool(false)
+array(1) {
+ [0]=>
+ int(42)
+}
+int(42)
+array(1) {
+ [0]=>
+ int(42)
+}
+int(42)
+string(14) "computed:plain"
+string(14) "computed:plain"
+string(14) "computed:plain"
+int(1)
diff --git a/ext/user_cache/tests/user_cache_remember_concurrent.phpt b/ext/user_cache/tests/user_cache_remember_concurrent.phpt
new file mode 100644
index 000000000000..c70505a493e4
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_remember_concurrent.phpt
@@ -0,0 +1,48 @@
+--TEST--
+UserCache\Cache: concurrent remember waits for the first callback and reuses its value
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+store('calls', 0);
+$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+$pid = pcntl_fork();
+if ($pid < 0) die('fork failed');
+if ($pid === 0) {
+ fclose($sockets[0]);
+ $value = $cache->remember('value', function () use ($cache, $sockets) {
+ $cache->increment('calls');
+ fwrite($sockets[1], "ready\n");
+ if (fgets($sockets[1]) !== "fetch\n") exit(1);
+ usleep(300000);
+ return 'first';
+ });
+ exit($value === 'first' ? 0 : 2);
+}
+fclose($sockets[1]);
+if (fgets($sockets[0]) !== "ready\n") die('handshake failed');
+fwrite($sockets[0], "fetch\n");
+var_dump($cache->remember('value', function () use ($cache) {
+ $cache->increment('calls');
+ return 'second';
+}));
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+var_dump($cache->fetch('calls'), $cache->fetch('value'));
+fclose($sockets[0]);
+?>
+--EXPECT--
+string(5) "first"
+bool(true)
+int(1)
+string(5) "first"
diff --git a/ext/user_cache/tests/user_cache_remember_lock_inside_callback.phpt b/ext/user_cache/tests/user_cache_remember_lock_inside_callback.phpt
new file mode 100644
index 000000000000..baa0cf2ff140
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_remember_lock_inside_callback.phpt
@@ -0,0 +1,34 @@
+--TEST--
+UserCache\Cache: a lock() of the key inside the remember() callback stays held after remember() returns
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+clear();
+
+var_dump($cache->remember('k', static function () use ($cache): int {
+ var_dump($cache->lock('k', 30));
+
+ return 1;
+}));
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ exit(Cache::getPool('remember-lock-inside-callback')->lock('k') ? 1 : 0);
+}
+pcntl_waitpid($pid, $status);
+echo 'another process took the lock: ', var_export(pcntl_wexitstatus($status) === 1, true), "\n";
+var_dump($cache->unlock('k'), $cache->unlock('k'));
+?>
+--EXPECT--
+bool(true)
+int(1)
+another process took the lock: false
+bool(true)
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_remember_lock_release.phpt b/ext/user_cache/tests/user_cache_remember_lock_release.phpt
new file mode 100644
index 000000000000..376e279378fa
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_remember_lock_release.phpt
@@ -0,0 +1,93 @@
+--TEST--
+UserCache\Cache: remember() releases its entry lock when the post-lock fetch throws and when the callback bails out
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+
+$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+$pid = pcntl_fork();
+if ($pid < 0) die('fork failed');
+if ($pid === 0) {
+ fclose($sockets[0]);
+ if (!$cache->lock('published-while-waiting')) exit(1);
+ fwrite($sockets[1], "locked\n");
+ if (fgets($sockets[1]) !== "waiting\n") exit(2);
+ usleep(300000);
+ if (!$cache->store('published-while-waiting', new RememberThrowingWakeup())) exit(3);
+ exit($cache->unlock('published-while-waiting') ? 0 : 4);
+}
+fclose($sockets[1]);
+if (fgets($sockets[0]) !== "locked\n") die('handshake failed');
+fwrite($sockets[0], "waiting\n");
+
+$calls = 0;
+try {
+ $cache->remember('published-while-waiting', function () use (&$calls) {
+ $calls++;
+
+ return 'computed';
+ });
+ echo "no exception\n";
+} catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+}
+pcntl_waitpid($pid, $status);
+fclose($sockets[0]);
+var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+var_dump($calls);
+var_dump($cache->unlock('published-while-waiting'));
+var_dump($cache->has('published-while-waiting'));
+
+/* A fatal error inside the callback releases the lock before shutdown functions run */
+$cache = UserCache\Cache::getPool('remember-bailout');
+$cache->clear();
+
+register_shutdown_function(function () use ($cache) {
+ echo "shutdown\n";
+ var_dump($cache->unlock('fatal'));
+ var_dump($cache->has('fatal'));
+ var_dump($cache->lock('fatal'), $cache->unlock('fatal'));
+});
+
+$cache->remember('fatal', function () {
+ eval('function remember_lock_release_dup() {} function remember_lock_release_dup() {}');
+
+ return 'never stored';
+});
+echo "not reached\n";
+?>
+--EXPECTF--
+__wakeup failed
+bool(true)
+int(0)
+bool(false)
+bool(true)
+
+Fatal error: Cannot redeclare function remember_lock_release_dup() (previously declared in %s) in %s on line %d
+%Ashutdown
+bool(false)
+bool(false)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_request_local_slot_trim.phpt b/ext/user_cache/tests/user_cache_request_local_slot_trim.phpt
new file mode 100644
index 000000000000..d1436f11a7fe
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_request_local_slot_trim.phpt
@@ -0,0 +1,84 @@
+--TEST--
+UserCache\Cache: when the request-local slots are full, the oldest half is dropped so newly read graphs still get a prototype
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=128M
+memory_limit=64M
+--FILE--
+id = $id;
+ for ($i = 0; $i < $children; $i++) {
+ $child = new Node;
+ $child->id = $i;
+ $node->children[] = $child;
+ }
+
+ return $node;
+}
+
+/* The second fetch of a key builds its prototype, which stays in request memory. */
+function prototype_retained(Cache $cache, string $key): bool
+{
+ $cache->fetch($key);
+ $before = memory_get_usage();
+ $cache->fetch($key);
+
+ return memory_get_usage() > $before;
+}
+
+$cache = Cache::getPool('request-local-slot-trim');
+$cache->clear();
+
+echo "slot count:\n";
+for ($i = 0; $i < 4200; $i++) {
+ $cache->store("marked$i", graph($i, 1));
+}
+$cache->store('late', graph(-1, 200));
+for ($i = 0; $i < 4200; $i++) {
+ $cache->fetch("marked$i");
+}
+var_dump(prototype_retained($cache, 'late'));
+
+echo "slot budget:\n";
+for ($i = 0; $i < 40; $i++) {
+ $cache->store("big$i", graph($i, 3000));
+ $cache->fetch("big$i");
+ $cache->fetch("big$i");
+}
+$cache->store('after-budget', graph(-2, 3000));
+var_dump(prototype_retained($cache, 'after-budget'));
+
+echo "value larger than the budget:\n";
+$huge = new Node;
+$huge->id = str_repeat('h', 9 << 20);
+$cache->store('huge', $huge);
+unset($huge);
+$before = memory_get_usage();
+for ($i = 0; $i < 3; $i++) {
+ $cache->fetch('huge');
+}
+var_dump(memory_get_usage() >= $before);
+?>
+--EXPECT--
+slot count:
+bool(true)
+slot budget:
+bool(true)
+value larger than the budget:
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_request_local_slots.phpt b/ext/user_cache/tests/user_cache_request_local_slots.phpt
new file mode 100644
index 000000000000..24f20a8b55de
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_request_local_slots.phpt
@@ -0,0 +1,608 @@
+--TEST--
+UserCache\Cache: request-local slots stay serialize-parity-safe across fetches, overwrites, storage transitions, restore failures, deprecations, enums, native state and native __wakeup() classes
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+--FILE--
+clear();
+
+ $shared = new stdClass;
+ $shared->n = 1;
+ $value = [
+ 'date' => new DateTimeImmutable('2020-01-01 00:00:00 UTC'),
+ 'storage' => new ArrayObject([$shared, 'nested' => [1, $shared]]),
+ 'shared' => $shared,
+ ];
+ var_dump($cache->store('graph', $value));
+ unset($value, $shared);
+
+ /* The second fetch builds the prototype and later fetches copy it; the ArrayObject copies its storage array. */
+ for ($i = 0; $i < 4; $i++) {
+ $fetched = $cache->fetch('graph');
+ $storage = $fetched['storage'];
+ var_dump(count($storage), $storage[0] === $fetched['shared'], $storage['nested'][1] === $fetched['shared']);
+ unset($fetched, $storage);
+ }
+
+ $before = memory_get_usage();
+ for ($i = 0; $i < 100; $i++) {
+ $cache->fetch('graph');
+ }
+ gc_collect_cycles();
+ var_dump(getenv('USE_ZEND_ALLOC') === '0' || memory_get_usage() <= $before);
+}
+
+echo "clone ArrayObject:\n";
+request_local_clone_array_object();
+
+class Plain
+{
+ public $a = 1;
+}
+
+function request_local_prototype_dynamic_property_deprecation(): void
+{
+ $notices = [];
+ set_error_handler(function (int $errno, string $message) use (&$notices): bool {
+ $notices[] = $message;
+
+ return true;
+ });
+
+ $object = new Plain;
+ $object->dynamic = 'x';
+ $cache = UserCache\Cache::getPool('prototype-dynamic-property');
+ $value = [$object, new DateTimeImmutable('2020-01-01')];
+ var_dump($cache->store('k', $value));
+ $notices = [];
+
+ for ($i = 0; $i < 4; $i++) {
+ $notices = [];
+ $fetched = $cache->fetch('k');
+ echo "fetch $i: ", implode(' | ', $notices), ' ', $fetched[0]->dynamic, "\n";
+ }
+ $notices = [];
+ unserialize(serialize($value));
+ echo 'unserialize: ', implode(' | ', $notices), "\n";
+
+ restore_error_handler();
+}
+
+echo "\ndynamic property deprecation:\n";
+request_local_prototype_dynamic_property_deprecation();
+
+enum Suit: string
+{
+ case Hearts = 'h';
+}
+
+function request_local_prototype_enum(): void
+{
+ $cache = UserCache\Cache::getPool('prototype-enum');
+ $value = ['suit' => Suit::Hearts];
+ for ($i = 0; $i < 200; $i++) {
+ $value["o$i"] = new stdClass;
+ }
+ $cache->store('k', $value);
+ unset($value);
+
+ /* The first read marks a key, the second keeps a prototype of it. */
+ $cache->fetch('k');
+ $before = memory_get_usage();
+ $cache->fetch('k');
+ var_dump(getenv('USE_ZEND_ALLOC') === '0' || memory_get_usage() - $before > 16 * 1024);
+
+ for ($i = 0; $i < 3; $i++) {
+ $v = $cache->fetch('k');
+ var_dump($v['suit'] === Suit::Hearts, $v['o0'] instanceof stdClass);
+ }
+}
+
+echo "\nenum:\n";
+request_local_prototype_enum();
+
+class WakeupDate extends DateTimeImmutable
+{
+ public static int $woken = 0;
+
+ public function __wakeup(): void
+ {
+ self::$woken++;
+ }
+}
+
+function request_local_prototype_native_wakeup(): void
+{
+ $factories = [
+ 'DateTimeImmutable' => fn (int $i) => new DateTimeImmutable('@' . (1700000000 + $i)),
+ 'DateTime' => fn (int $i) => new DateTime('@' . (1700000000 + $i)),
+ 'DateTimeZone' => fn (int $i) => new DateTimeZone('Asia/Tokyo'),
+ 'DateInterval' => fn (int $i) => new DateInterval('PT' . ($i + 1) . 'S'),
+ 'DatePeriod' => fn (int $i) => new DatePeriod(new DateTimeImmutable('@' . $i), new DateInterval('P1D'), 3),
+ 'WakeupDate' => fn (int $i) => new WakeupDate('@' . $i),
+ ];
+
+ $cache = Cache::getPool('prototype-native-wakeup');
+ foreach ($factories as $class => $factory) {
+ $value = [];
+ for ($i = 0; $i < 200; $i++) {
+ $value[] = ['object' => $factory($i), 'i' => $i];
+ }
+ $cache->store($class, $value);
+
+ /* The first read marks a key, the second keeps a prototype of it. */
+ $cache->fetch($class);
+ $before = memory_get_usage();
+ $cache->fetch($class);
+ $retained = memory_get_usage() - $before;
+
+ $a = $cache->fetch($class);
+ $b = $cache->fetch($class);
+ echo $class, ': ';
+ var_dump(getenv('USE_ZEND_ALLOC') === '0' || $retained > 32 * 1024, serialize($a) === serialize($value), $a[0]['object'] !== $b[0]['object']);
+ }
+ var_dump(WakeupDate::$woken);
+}
+
+echo "\nnative __wakeup():\n";
+request_local_prototype_native_wakeup();
+
+echo "\nserialize parity:\n";
+
+/* Restore hooks run on every fetch and structural cloning never runs __clone(). */
+class StrictParityMagicDate extends DateTime
+{
+ public static int $unserializeCalls = 0;
+ public static int $cloneCalls = 0;
+
+ public function __serialize(): array
+ {
+ return parent::__serialize();
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCalls++;
+ parent::__unserialize($data);
+ }
+
+ public function __clone(): void
+ {
+ self::$cloneCalls++;
+ }
+}
+
+class StrictParityPlainClone
+{
+ public static int $cloneCalls = 0;
+
+ public function __construct(public int $value = 0, public array $tags = [])
+ {
+ }
+
+ public function __clone(): void
+ {
+ self::$cloneCalls++;
+ }
+}
+
+class StrictParitySleeper
+{
+ public static int $wakeupCalls = 0;
+
+ public int $value = 0;
+
+ public function __construct(int $value = 0)
+ {
+ $this->value = $value;
+ }
+
+ public function __sleep(): array
+ {
+ return ['value'];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ }
+}
+
+class StrictParityWakeupOnly
+{
+ public static int $wakeupCalls = 0;
+
+ public int $value = 0;
+
+ public function __construct(int $value = 0)
+ {
+ $this->value = $value;
+ }
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ }
+}
+
+$cache = UserCache\Cache::getPool('slot-strict-parity');
+$cache->clear();
+StrictParityMagicDate::$unserializeCalls = 0;
+StrictParityMagicDate::$cloneCalls = 0;
+StrictParityPlainClone::$cloneCalls = 0;
+StrictParitySleeper::$wakeupCalls = 0;
+StrictParityWakeupOnly::$wakeupCalls = 0;
+
+var_dump($cache->store('magic', new StrictParityMagicDate('2026-07-08 01:02:03.456789', new DateTimeZone('UTC'))));
+$magic = null;
+for ($i = 0; $i < 5; $i++) {
+ $magic = $cache->fetch('magic');
+}
+echo "magic __unserialize calls: ", StrictParityMagicDate::$unserializeCalls, "\n";
+echo "magic __clone calls: ", StrictParityMagicDate::$cloneCalls, "\n";
+echo "magic round-trips: ";
+var_dump($magic instanceof StrictParityMagicDate &&
+ $magic->format('Y-m-d H:i:s.u') === '2026-07-08 01:02:03.456789');
+
+var_dump($cache->store('plain', new StrictParityPlainClone(42, ['a', 'b'])));
+$plainOk = true;
+for ($i = 0; $i < 5; $i++) {
+ $plain = $cache->fetch('plain');
+ $plainOk = $plainOk && $plain->value === 42 && $plain->tags === ['a', 'b'];
+}
+echo "plain __clone calls: ", StrictParityPlainClone::$cloneCalls, "\n";
+echo "plain round-trips: ";
+var_dump($plainOk);
+
+var_dump($cache->store('sleeper', new StrictParitySleeper(7)));
+$sleeperOk = true;
+for ($i = 0; $i < 5; $i++) {
+ $sleeper = $cache->fetch('sleeper');
+ $sleeperOk = $sleeperOk && $sleeper->value === 7;
+}
+echo "sleeper __wakeup calls: ", StrictParitySleeper::$wakeupCalls, "\n";
+echo "sleeper round-trips: ";
+var_dump($sleeperOk);
+
+var_dump($cache->store('wakeup-only', new StrictParityWakeupOnly(9)));
+$wakeupOnlyOk = true;
+for ($i = 0; $i < 5; $i++) {
+ $wakeupOnly = $cache->fetch('wakeup-only');
+ $wakeupOnlyOk = $wakeupOnlyOk && $wakeupOnly->value === 9;
+}
+echo "wakeup-only __wakeup calls: ", StrictParityWakeupOnly::$wakeupCalls, "\n";
+echo "wakeup-only round-trips: ";
+var_dump($wakeupOnlyOk);
+
+/* Store hooks run exactly once while restore hooks run on every fetch. */
+class SlotMagicPair
+{
+ public static int $serializeCalls = 0;
+ public static int $unserializeCalls = 0;
+
+ public function __construct(public int $value = 0)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ self::$serializeCalls++;
+ return ['value' => $this->value];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCalls++;
+ $this->value = $data['value'];
+ }
+}
+
+class SlotSleepWakeup
+{
+ public static int $sleepCalls = 0;
+ public static int $wakeupCalls = 0;
+
+ public function __construct(public int $value = 0)
+ {
+ }
+
+ public function __sleep(): array
+ {
+ self::$sleepCalls++;
+ return ['value'];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ }
+}
+
+$cache = UserCache\Cache::getPool('slot-magic');
+$cache->clear();
+SlotMagicPair::$serializeCalls = 0;
+SlotMagicPair::$unserializeCalls = 0;
+SlotSleepWakeup::$sleepCalls = 0;
+SlotSleepWakeup::$wakeupCalls = 0;
+
+var_dump($cache->store('pair', new SlotMagicPair(11)));
+$first = $cache->fetch('pair');
+$second = $cache->fetch('pair');
+$third = $cache->fetch('pair');
+$fourth = $cache->fetch('pair');
+echo "pair serialize calls: ", SlotMagicPair::$serializeCalls, "\n";
+echo "pair unserialize calls: ", SlotMagicPair::$unserializeCalls, "\n";
+echo "pair fetches independent: ";
+var_dump($third !== $fourth && $third->value === 11 && $fourth->value === 11);
+$third->value = 99;
+echo "pair mutation isolated: ";
+var_dump($cache->fetch('pair')->value === 11);
+
+var_dump($cache->store('sleeper', new SlotSleepWakeup(33)));
+$cache->fetch('sleeper');
+$cache->fetch('sleeper');
+$clone1 = $cache->fetch('sleeper');
+$clone2 = $cache->fetch('sleeper');
+echo "sleep calls: ", SlotSleepWakeup::$sleepCalls, "\n";
+echo "wakeup calls: ", SlotSleepWakeup::$wakeupCalls, "\n";
+echo "sleeper fetches independent: ";
+var_dump($clone1 !== $clone2 && $clone1->value === 33 && $clone2->value === 33);
+
+/* Same-request store() and delete() invalidate the promoted slot. */
+class SlotInvalidateValue
+{
+ public static int $unserializeCalls = 0;
+
+ public function __construct(public int $value = 0)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['value' => $this->value];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCalls++;
+ $this->value = $data['value'];
+ }
+}
+
+$cache = UserCache\Cache::getPool('slot-invalidate');
+$cache->clear();
+SlotInvalidateValue::$unserializeCalls = 0;
+
+var_dump($cache->store('key', new SlotInvalidateValue(1)));
+$cache->fetch('key');
+$cache->fetch('key');
+$promoted = $cache->fetch('key');
+echo "calls after promotion: ", SlotInvalidateValue::$unserializeCalls, "\n";
+echo "promoted value: ", $promoted->value, "\n";
+
+var_dump($cache->store('key', new SlotInvalidateValue(2)));
+$fresh = $cache->fetch('key');
+echo "value after overwrite: ", $fresh->value, "\n";
+echo "calls after overwrite fetch: ", SlotInvalidateValue::$unserializeCalls, "\n";
+
+var_dump($cache->delete('key'));
+var_dump($cache->fetch('key'));
+var_dump($cache->store('key', new SlotInvalidateValue(3)));
+echo "value after delete and re-store: ", $cache->fetch('key')->value, "\n";
+
+/* One key alternates between string and graph values, keeping aliases. */
+class SlotStorageNode
+{
+ public array $tags = ['original'];
+}
+
+$cache = UserCache\Cache::getPool('slot-storage');
+$cache->clear();
+$string = str_repeat('s', 300);
+var_dump($cache->store('key', $string));
+$copy = $cache->fetch('key');
+$copy[0] = 'x';
+var_dump($cache->fetch('key') === $string);
+
+$node = new SlotStorageNode();
+$shared = ['value' => 1];
+$graph = ['first' => $node, 'second' => $node, 'left' => &$shared, 'right' => &$shared];
+var_dump($cache->store('key', $graph));
+$held = [];
+for ($i = 0; $i < 3; $i++) {
+ $held[] = $cache->fetch('key');
+}
+var_dump($held[0]['first'] === $held[0]['second']);
+var_dump($held[1]['first'] === $held[1]['second']);
+var_dump($held[2]['first'] === $held[2]['second']);
+$held[0]['first']->tags[] = 'changed';
+$held[0]['left']['value'] = 9;
+var_dump($held[0]['right']['value'] === 9);
+var_dump($held[1]['first']->tags === ['original'], $held[2]['left']['value'] === 1);
+
+var_dump($cache->store('key', $string));
+var_dump($cache->fetch('key') === $string);
+
+var_dump($cache->store('key', $graph));
+for ($i = 0; $i < 3; $i++) {
+ $fetched = $cache->fetch('key');
+}
+var_dump($fetched['first']->tags === ['original']);
+var_dump($fetched['first'] === $fetched['second']);
+var_dump($cache->delete('key'), $cache->fetch('key', 'missing'));
+
+/* Release builds do not collect cycles at shutdown. */
+unset($fetched, $held, $graph, $node, $shared);
+gc_collect_cycles();
+
+/* Throwing restore hooks propagate from fetch(), remember() and fetchMultiple() and keep the entry. */
+class RestoreWakeupThrows
+{
+ public static bool $failNext = false;
+ public int $value = 0;
+
+ public function __wakeup(): void
+ {
+ if (self::$failNext) {
+ self::$failNext = false;
+ throw new RuntimeException('wakeup failure');
+ }
+ }
+}
+
+$cache = UserCache\Cache::getPool('fetch-restore-exception');
+$cache->clear();
+RestoreWakeupThrows::$failNext = false;
+
+var_dump($cache->store('k', new RestoreWakeupThrows()));
+
+RestoreWakeupThrows::$failNext = true;
+try {
+ $cache->fetch('k', 'DEFAULT');
+ echo "no exception\n";
+} catch (RuntimeException $e) {
+ echo "fetch caught: ", $e->getMessage(), "\n";
+}
+var_dump($cache->has('k'));
+
+RestoreWakeupThrows::$failNext = true;
+try {
+ $cache->remember('k', fn (): string => 'computed');
+ echo "no exception\n";
+} catch (RuntimeException $e) {
+ echo "remember caught: ", $e->getMessage(), "\n";
+}
+var_dump($cache->has('k'));
+
+RestoreWakeupThrows::$failNext = true;
+try {
+ $cache->fetchMultiple(['k'], 'DEFAULT');
+ echo "no exception\n";
+} catch (RuntimeException $e) {
+ echo "fetchMultiple caught: ", $e->getMessage(), "\n";
+}
+var_dump($cache->has('k'));
+
+$restored = $cache->fetch('k', 'DEFAULT');
+var_dump($restored instanceof RestoreWakeupThrows);
+?>
+--EXPECT--
+clone ArrayObject:
+bool(true)
+int(2)
+bool(true)
+bool(true)
+int(2)
+bool(true)
+bool(true)
+int(2)
+bool(true)
+bool(true)
+int(2)
+bool(true)
+bool(true)
+bool(true)
+
+dynamic property deprecation:
+bool(true)
+fetch 0: Creation of dynamic property Plain::$dynamic is deprecated x
+fetch 1: Creation of dynamic property Plain::$dynamic is deprecated x
+fetch 2: Creation of dynamic property Plain::$dynamic is deprecated x
+fetch 3: Creation of dynamic property Plain::$dynamic is deprecated x
+unserialize: Creation of dynamic property Plain::$dynamic is deprecated
+
+enum:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+native __wakeup():
+DateTimeImmutable: bool(true)
+bool(true)
+bool(true)
+DateTime: bool(true)
+bool(true)
+bool(true)
+DateTimeZone: bool(true)
+bool(true)
+bool(true)
+DateInterval: bool(true)
+bool(true)
+bool(true)
+DatePeriod: bool(true)
+bool(true)
+bool(true)
+WakeupDate: bool(true)
+bool(true)
+bool(true)
+int(0)
+
+serialize parity:
+bool(true)
+magic __unserialize calls: 5
+magic __clone calls: 0
+magic round-trips: bool(true)
+bool(true)
+plain __clone calls: 0
+plain round-trips: bool(true)
+bool(true)
+sleeper __wakeup calls: 5
+sleeper round-trips: bool(true)
+bool(true)
+wakeup-only __wakeup calls: 5
+wakeup-only round-trips: bool(true)
+bool(true)
+pair serialize calls: 1
+pair unserialize calls: 4
+pair fetches independent: bool(true)
+pair mutation isolated: bool(true)
+bool(true)
+sleep calls: 1
+wakeup calls: 4
+sleeper fetches independent: bool(true)
+bool(true)
+calls after promotion: 3
+promoted value: 1
+bool(true)
+value after overwrite: 2
+calls after overwrite fetch: 4
+bool(true)
+NULL
+bool(true)
+value after delete and re-store: 3
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(7) "missing"
+bool(true)
+fetch caught: wakeup failure
+bool(true)
+remember caught: wakeup failure
+bool(true)
+fetchMultiple caught: wakeup failure
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_request_local_slots_fork.phpt b/ext/user_cache/tests/user_cache_request_local_slots_fork.phpt
new file mode 100644
index 000000000000..5f239ef22df7
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_request_local_slots_fork.phpt
@@ -0,0 +1,193 @@
+--TEST--
+UserCache\Cache: request-local slots built from another process's stores promote lazily and survive re-entrant eviction, and a forked child pins inherited prototypes independently
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+--FILE--
+clear();
+ foreach (['one', 'two', 'cold'] as $key) {
+ $cache->store($key, (object) ['values' => range(1, 64)]);
+ if ($key !== 'cold') {
+ $cache->fetch($key);
+ $cache->fetch($key);
+ }
+ }
+ echo 'parent pins: ', UserCache\Cache::getStatus()->getGraphPinnedReferences(), "\n";
+
+ $pid = pcntl_fork();
+ if ($pid < 0) {
+ die("fork failed\n");
+ }
+ if ($pid === 0) {
+ /* pcntl_fork() hands the parent's pins to the child, so the inherited
+ * prototypes are already held and only the cold key adds a pin. */
+ foreach (['cold', 'one', 'two'] as $key) {
+ $fetched = $cache->fetch($key);
+ echo $key, ' pins: ', UserCache\Cache::getStatus()->getGraphPinnedReferences(), "\n";
+ var_dump($fetched->values === range(1, 64));
+ }
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0);
+ echo 'remaining parent pins: ', UserCache\Cache::getStatus()->getGraphPinnedReferences(), "\n";
+}
+
+echo "prototype fork pins:\n";
+prototype_fork_pins();
+
+echo "\nrequest-local slots:\n";
+/* A graph stored by a child is promoted only after the first fetch and stays isolated per fetch. */
+class UserCacheDeferredPromotionDate extends DateTime
+{
+ private int $cachedSelfId;
+
+ public function __construct(string $time, DateTimeZone $timezone)
+ {
+ parent::__construct($time, $timezone);
+ $this->cachedSelfId = spl_object_id($this);
+ }
+
+ public function cachedSelfId(): int
+ {
+ return $this->cachedSelfId;
+ }
+}
+
+$cache = UserCache\Cache::getPool('deferred-promotion');
+$cache->clear();
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $date = new UserCacheDeferredPromotionDate('2026-07-03 12:34:56.123456', new DateTimeZone('Asia/Tokyo'));
+ $payload = [
+ 'first' => $date,
+ 'second' => $date,
+ 'nested' => ['label' => 'original'],
+ 'storedId' => $date->cachedSelfId(),
+ ];
+
+ var_dump($cache->store('payload', $payload));
+ exit(0);
+}
+
+if ($pid < 0) {
+ echo "pcntl_fork() failed\n";
+ exit(1);
+}
+
+pcntl_waitpid($pid, $status);
+
+$first = $cache->fetch('payload');
+$first['first']->modify('+1 day');
+$first['nested']['label'] = 'mutated-first';
+
+$second = $cache->fetch('payload');
+$secondDateUnchanged = $second['first']->format('Y-m-d H:i:s.u e') === '2026-07-03 12:34:56.123456 Asia/Tokyo';
+$secondNestedUnchanged = $second['nested']['label'] === 'original';
+$second['first']->modify('+2 days');
+$second['nested']['label'] = 'mutated-second';
+
+$third = $cache->fetch('payload');
+
+echo "status: ", pcntl_wexitstatus($status), "\n";
+echo "first alias: ";
+var_dump($first['first'] === $first['second']);
+echo "second alias: ";
+var_dump($second['first'] === $second['second']);
+echo "third alias: ";
+var_dump($third['first'] === $third['second']);
+echo "fetches independent: ";
+var_dump($first['first'] !== $second['first'] && $second['first'] !== $third['first']);
+echo "second unchanged by first: ";
+var_dump($secondDateUnchanged);
+var_dump($secondNestedUnchanged);
+echo "third unchanged by second: ";
+var_dump($third['first']->format('Y-m-d H:i:s.u e') === '2026-07-03 12:34:56.123456 Asia/Tokyo');
+var_dump($third['nested']['label'] === 'original');
+echo "object ids preserved as stored: ";
+var_dump($first['first']->cachedSelfId() === $first['storedId']);
+var_dump($second['first']->cachedSelfId() === $second['storedId']);
+var_dump($third['first']->cachedSelfId() === $third['storedId']);
+
+/* Fetched objects retain ordinary destructors that may re-enter the API across processes. */
+class ReentrantDtor
+{
+ public int $n = 0;
+
+ public function __destruct()
+ {
+ $cache = UserCache\Cache::getPool('slot-dtor');
+ $cache->store('dtor-side', 'ran');
+ $cache->fetch('dtor-side');
+ }
+}
+
+$cache = UserCache\Cache::getPool('slot-dtor');
+$cache->clear();
+
+$obj = new ReentrantDtor();
+$obj->n = 1;
+var_dump($cache->store('obj', $obj));
+unset($obj);
+
+$first = $cache->fetch('obj');
+var_dump($first->n);
+unset($first);
+
+$pid = pcntl_fork();
+if ($pid === 0) {
+ $child = new ReentrantDtor();
+ $child->n = 2;
+ UserCache\Cache::getPool('slot-dtor')->store('obj', $child);
+ exit(0);
+}
+
+if ($pid > 0) {
+ pcntl_waitpid($pid, $status);
+
+ $second = $cache->fetch('obj');
+ var_dump($second->n);
+ var_dump($cache->fetch('dtor-side'));
+} else {
+ echo "pcntl_fork() failed\n";
+}
+?>
+--EXPECT--
+prototype fork pins:
+parent pins: 2
+cold pins: 5
+bool(true)
+one pins: 5
+bool(true)
+two pins: 5
+bool(true)
+bool(true)
+remaining parent pins: 2
+
+request-local slots:
+bool(true)
+status: 0
+first alias: bool(true)
+second alias: bool(true)
+third alias: bool(true)
+fetches independent: bool(true)
+second unchanged by first: bool(true)
+bool(true)
+third unchanged by second: bool(true)
+bool(true)
+object ids preserved as stored: bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+int(2)
+string(3) "ran"
diff --git a/ext/user_cache/tests/user_cache_scalar_write_stripes.phpt b/ext/user_cache/tests/user_cache_scalar_write_stripes.phpt
new file mode 100644
index 000000000000..865a52a24065
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_scalar_write_stripes.phpt
@@ -0,0 +1,44 @@
+--TEST--
+UserCache scalar overwrite stripes preserve types, overflow, TTL and fallback operations
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+function check(bool $condition): void {
+ if (!$condition) throw new RuntimeException('unexpected result');
+}
+$cache->store('value', 0);
+$cache->store('value', 1); // The first eligible overwrite enables striped writes.
+foreach ([null, false, true, 17, -42, 1.25, PHP_INT_MAX, PHP_INT_MIN] as $value) {
+ check($cache->store('value', $value));
+ check($cache->fetch('value') === $value);
+ check($cache->fetchMultiple(['value'])['value'] === $value);
+}
+$cache->store('value', PHP_INT_MAX);
+try { $cache->increment('value'); } catch (ArithmeticError $e) { echo "overflow\n"; }
+check($cache->fetch('value') === PHP_INT_MAX);
+$cache->store('value', false);
+try { $cache->decrement('value'); } catch (ValueError $e) { echo "type\n"; }
+check($cache->fetch('value') === false);
+$cache->store('value', 40);
+check($cache->increment('value', 3) === 43);
+check($cache->decrement('value', 2) === 41);
+check($cache->store('value', 9, 3600)); // TTL uses the ordinary writer.
+check($cache->increment('value') === 10);
+check($cache->store('value', ['nested' => [1, 2]]));
+check($cache->fetch('value') === ['nested' => [1, 2]]);
+check($cache->store('value', 10));
+check($cache->storeMultiple(['value' => 11, 'other' => 12]));
+check($cache->fetchMultiple(['value', 'other']) === ['value' => 11, 'other' => 12]);
+check($cache->delete('value'));
+check($cache->increment('value', 5) === 5);
+echo "OK\n";
+?>
+--EXPECT--
+overflow
+type
+OK
diff --git a/ext/user_cache/tests/user_cache_segment_4g_memory.phpt b/ext/user_cache/tests/user_cache_segment_4g_memory.phpt
new file mode 100644
index 000000000000..0c5b9dd37120
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_segment_4g_memory.phpt
@@ -0,0 +1,165 @@
+--TEST--
+UserCache\Cache: more than 4 GiB of real data stays intact, reuses freed space and evicts in a segment larger than 4 GiB
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+ '/sys/fs/cgroup/memory.current',
+ '/sys/fs/cgroup/memory/memory.limit_in_bytes' => '/sys/fs/cgroup/memory/memory.usage_in_bytes',
+ ];
+ foreach ($cgroups as $limit_file => $usage_file) {
+ $limit = trim((string) @file_get_contents($limit_file));
+ $usage = trim((string) @file_get_contents($usage_file));
+ if (preg_match('/^\d+$/', $limit) && preg_match('/^\d+$/', $usage)) {
+ $available = min($available, (int) $limit - (int) $usage);
+ }
+ }
+
+ return $available;
+}
+
+$available = available_memory();
+if ($available === null) die('skip cannot determine the available memory');
+if ($available < 6 << 30) die('skip needs 6 GiB of available memory');
+
+if (!function_exists('proc_open')) die('skip proc_open() not available');
+$php = getenv('TEST_PHP_EXECUTABLE') ?: PHP_BINARY;
+$probe = 'UserCache\Cache::getPool("probe")->store("k", 1);'
+ . 'echo UserCache\Cache::getStatus()->getAvailability()->name;';
+$process = proc_open(
+ [$php, '-n', '-d', 'user_cache.enable=1', '-d', 'user_cache.enable_cli=1', '-d', 'user_cache.shm_size=4608M', '-d', 'user_cache.entries_hint=4096', '-r', $probe],
+ [1 => ['pipe', 'w'], 2 => ['null']],
+ $pipes
+);
+$availability = trim(stream_get_contents($pipes[1]));
+fclose($pipes[1]);
+proc_close($process);
+if ($availability !== 'Available') die("skip a 4608M segment is unavailable ($availability)");
+?>
+--CONFLICTS--
+all
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4608M
+user_cache.entries_hint=4096
+--FILE--
+getUsedMemory();
+}
+
+function value(int $i, int $size = CHUNK): string
+{
+ return str_pad(sprintf('%08d:', $i), $size, chr(65 + $i % 26));
+}
+
+/* Fetches pin zero-copy payloads until the request ends, so they run in a child to keep the parent's accounting exact. */
+function in_child(string $label, callable $check): void
+{
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ echo $label, ': ', $check(Cache::getPool('memory-4g')) ? 'yes' : 'no', "\n";
+ exit(0);
+ }
+
+ pcntl_waitpid($pid, $exit);
+}
+
+$cache = Cache::getPool('memory-4g');
+var_dump($cache->clear());
+$empty = used();
+
+$stored = 0;
+for ($i = 0; $i < COUNT; $i++) {
+ $stored += $cache->store("v:$i", value($i)) ? 1 : 0;
+}
+var_dump($stored);
+echo "more than 4 GiB in use: ";
+var_dump(used() > 4 << 30);
+var_dump(Cache::getStatus()->getEvictionCount(), Cache::getStatus()->getStoreFailureCount());
+in_child('every value intact', function (Cache $child): bool {
+ for ($i = 0; $i < COUNT; $i++) {
+ if ($child->fetch("v:$i") !== value($i)) {
+ return false;
+ }
+ }
+
+ return true;
+});
+
+for ($i = 0; $i < COUNT; $i += 2) {
+ $cache->delete("v:$i");
+}
+$stored = 0;
+for ($i = 0; $i < COUNT; $i += 2) {
+ $stored += $cache->store("w:$i", value($i, 3 << 20)) ? 1 : 0;
+}
+echo "freed blocks reused without eviction: ";
+var_dump($stored === COUNT / 2 && Cache::getStatus()->getEvictionCount() === 0);
+in_child('kept and refilled values intact', function (Cache $child): bool {
+ for ($i = 0; $i < COUNT; $i++) {
+ $ok = $i % 2
+ ? $child->fetch("v:$i") === value($i)
+ : $child->fetch("w:$i") === value($i, 3 << 20) && !$child->has("v:$i");
+ if (!$ok) {
+ return false;
+ }
+ }
+
+ return true;
+});
+
+for ($i = 0; $i < 150; $i++) {
+ $cache->store("e:$i", value(COUNT + $i));
+}
+echo "evicted: ";
+var_dump(Cache::getStatus()->getEvictionCount() > 0, Cache::getStatus()->getStoreFailureCount());
+in_child('newest values intact', function (Cache $child): bool {
+ for ($i = 140; $i < 150; $i++) {
+ if ($child->fetch("e:$i") !== value(COUNT + $i)) {
+ return false;
+ }
+ }
+
+ return true;
+});
+
+var_dump($cache->clear());
+echo "memory returned after clear: ";
+var_dump(used() === $empty);
+?>
+--EXPECT--
+bool(true)
+int(1088)
+more than 4 GiB in use: bool(true)
+int(0)
+int(0)
+every value intact: yes
+freed blocks reused without eviction: bool(true)
+kept and refilled values intact: yes
+evicted: bool(true)
+int(0)
+newest values intact: yes
+bool(true)
+memory returned after clear: bool(true)
diff --git a/ext/user_cache/tests/user_cache_segment_above_4g.phpt b/ext/user_cache/tests/user_cache_segment_above_4g.phpt
new file mode 100644
index 000000000000..58822634991e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_segment_above_4g.phpt
@@ -0,0 +1,231 @@
+--TEST--
+UserCache\Cache: a segment larger than 4 GiB keeps entries, locks and freed memory intact across and above the 4 GiB offset
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+store("k", 1);'
+ . 'echo UserCache\Cache::getStatus()->getAvailability()->name;';
+$process = proc_open(
+ [$php, '-n', '-d', 'user_cache.enable=1', '-d', 'user_cache.enable_cli=1', '-d', 'user_cache.shm_size=4160M', '-d', 'user_cache.entries_hint=4096', '-r', $probe],
+ [1 => ['pipe', 'w'], 2 => ['null']],
+ $pipes
+);
+$availability = trim(stream_get_contents($pipes[1]));
+fclose($pipes[1]);
+proc_close($process);
+if ($availability !== 'Available') {
+ die("skip a 4160M segment is unavailable ($availability)");
+}
+?>
+--ENV--
+USER_CACHE_DEBUG_RESERVE_DATA_BELOW_4G=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4160M
+user_cache.entries_hint=4096
+--FILE--
+getUsedMemory();
+}
+
+/* Fetches pin zero-copy payloads until the request ends, so they run in a child to keep the parent's accounting exact. */
+function in_child(string $label, callable $check): void
+{
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ echo $label, ': ', $check(Cache::getPool('above-4g')) ? 'yes' : 'no', "\n";
+ exit(0);
+ }
+
+ pcntl_waitpid($pid, $exit);
+}
+
+function values_intact(Cache $cache, array $expected): bool
+{
+ foreach ($expected as $key => $value) {
+ if ($cache->fetch($key) != $value) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+$cache = Cache::getPool('above-4g');
+var_dump($cache->clear());
+var_dump(Cache::getStatus()->getAvailability()->name);
+var_dump(Cache::getStatus()->getConfiguredMemory());
+$empty = used();
+echo "data tail starts below 4 GiB: ";
+var_dump($empty > (4 << 30) - (1 << 20) && $empty < 4 << 30);
+
+$expected = [];
+for ($i = 0; $i < 400; $i++) {
+ $expected[str_repeat('k', 1 + $i % 23) . ":$i"] = str_repeat(chr(65 + $i % 26), 1 + ($i * 37) % 701);
+}
+for ($i = 0; $i < 60; $i++) {
+ $expected["graph:$i"] = ['i' => $i, 'list' => range(0, $i), 'obj' => (object) ['s' => str_repeat('x', $i)]];
+}
+foreach ($expected as $key => $value) {
+ $cache->store($key, $value);
+}
+$bulk = [];
+for ($i = 0; $i < 20; $i++) {
+ $bulk["bulk:$i"] = str_repeat('b', 3 + $i);
+}
+var_dump($cache->storeMultiple($bulk));
+$expected += $bulk;
+
+$cache->store('mixed', 'first string');
+$cache->store('mixed', 42);
+$cache->store('mixed', 'second string');
+$cache->store('counter', 10);
+var_dump($cache->increment('counter', 5));
+var_dump($cache->storeMultiple(['bulk:0' => 'overwritten', 'fresh' => 7]));
+$expected = ['mixed' => 'second string', 'counter' => 15, 'bulk:0' => 'overwritten', 'fresh' => 7] + $expected;
+
+echo "data crosses 4 GiB: ";
+var_dump(used() > 4 << 30);
+in_child('values intact', fn(Cache $child) => values_intact($child, $expected));
+in_child('bulk fetch intact', fn(Cache $child) => $child->fetchMultiple(['bulk:0', 'bulk:19', 'missing'], 0) === ['bulk:0' => 'overwritten', 'bulk:19' => str_repeat('b', 22), 'missing' => 0]);
+$pool = $cache->getPoolStatus();
+echo "pool keys: ";
+var_dump($pool->getEntryCount() === count($expected), count(array_diff(array_keys($expected), $pool->getEntryKeys())));
+
+$before = used();
+for ($i = 0; $i < 300; $i++) {
+ $cache->store("tmp:$i", str_repeat('t', 1 + ($i * 131) % 5003));
+}
+for ($i = 0; $i < 300; $i += 3) {
+ $cache->store("tmp:$i", $i % 2 ? str_repeat('s', 7) : ['shrunk' => $i]);
+}
+for ($i = 1; $i < 300; $i += 3) {
+ $cache->store("tmp:$i", str_repeat('g', 9000 + $i));
+}
+for ($i = 0; $i < 300; $i++) {
+ $cache->delete("tmp:$i");
+}
+echo "memory returned after overwrite and delete: ";
+var_dump(used() === $before);
+
+for ($i = 0; $i < 64; $i++) {
+ if (!$cache->lock(str_repeat('L', 1 + $i) . ":$i", 60)) {
+ echo "lock $i failed\n";
+ }
+}
+echo "lock keys occupy memory: ";
+var_dump(used() > $before);
+in_child('locks held', function (Cache $child): bool {
+ for ($i = 0; $i < 64; $i++) {
+ if ($child->unlock(str_repeat('L', 1 + $i) . ":$i")) {
+ return false;
+ }
+ }
+
+ return true;
+});
+for ($i = 0; $i < 64; $i++) {
+ if (!$cache->unlock(str_repeat('L', 1 + $i) . ":$i")) {
+ echo "unlock $i failed\n";
+ }
+}
+echo "memory returned after unlock: ";
+var_dump(used() === $before);
+in_child('values intact', fn(Cache $child) => values_intact($child, $expected));
+
+$chunk = str_repeat('e', 1 << 20);
+for ($i = 0; $i < 100; $i++) {
+ $cache->store("evict:$i", $chunk . $i);
+}
+echo "evicted: ";
+var_dump(Cache::getStatus()->getEvictionCount() > 0);
+in_child('newest value intact', fn(Cache $child) => $child->fetch('evict:99') === $chunk . 99);
+
+var_dump($cache->clear());
+echo "memory returned after clear: ";
+var_dump(used() === $empty);
+
+$held = [];
+for ($i = 0; $i < 8; $i++) {
+ $held[] = str_repeat('H', 20 + $i) . ":$i";
+}
+foreach ($held as $key) {
+ $cache->lock($key, 60);
+}
+$cache->store('before-recovery', 'lost');
+$pid = pcntl_fork();
+if ($pid === 0) {
+ putenv('USER_CACHE_DEBUG_EXIT_IN_WRITE_SECTION=1');
+ Cache::getPool('above-4g')->store('never', 'published');
+ exit(1);
+}
+pcntl_waitpid($pid, $exit);
+var_dump($cache->store('after-recovery', 'kept'));
+echo "recovery reset the entries: ";
+var_dump($cache->has('before-recovery'), $cache->has('after-recovery'));
+in_child('locks survive recovery', function (Cache $child) use ($held): bool {
+ foreach ($held as $key) {
+ if ($child->unlock($key)) {
+ return false;
+ }
+ }
+
+ return true;
+});
+foreach ($held as $key) {
+ if (!$cache->unlock($key)) {
+ echo "unlock $key failed\n";
+ }
+}
+echo "data tail restarts below 4 GiB: ";
+var_dump(used() > (4 << 30) - (1 << 20) && used() < 4 << 30);
+
+$big = ['list' => range(1, 2000), 'text' => str_repeat('p', 50000)];
+$cache->store('pinned', $big);
+$pinned = $cache->fetch('pinned');
+var_dump($cache->delete('pinned'));
+for ($i = 0; $i < 40; $i++) {
+ $cache->store("reuse:$i", str_repeat('r', 4000 + $i));
+}
+echo "pinned value survives delete and reuse: ";
+var_dump($pinned === $big);
+?>
+--EXPECT--
+bool(true)
+string(9) "Available"
+int(4362076160)
+data tail starts below 4 GiB: bool(true)
+bool(true)
+int(15)
+bool(true)
+data crosses 4 GiB: bool(true)
+values intact: yes
+bulk fetch intact: yes
+pool keys: bool(true)
+int(0)
+memory returned after overwrite and delete: bool(true)
+lock keys occupy memory: bool(true)
+locks held: yes
+memory returned after unlock: bool(true)
+values intact: yes
+evicted: bool(true)
+newest value intact: yes
+bool(true)
+memory returned after clear: bool(true)
+bool(true)
+recovery reset the entries: bool(false)
+bool(true)
+locks survive recovery: yes
+data tail restarts below 4 GiB: bool(true)
+bool(true)
+pinned value survives delete and reuse: bool(true)
diff --git a/ext/user_cache/tests/user_cache_serializable.phpt b/ext/user_cache/tests/user_cache_serializable.phpt
new file mode 100644
index 000000000000..2e24381f5dfe
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_serializable.phpt
@@ -0,0 +1,580 @@
+--TEST--
+UserCache\Cache: legacy Serializable keeps its handler contract, native precedence over magic methods, unserialize() failure semantics and its own native serialize() context across nesting, Fibers and fatal errors
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+error_reporting=E_ALL & ~E_DEPRECATED
+--FILE--
+value;
+ }
+
+ public function unserialize(string $data): void
+ {
+ self::$unserializeCalls++;
+ $this->value = (int) $data;
+ }
+}
+
+LegacyScalar::$unserializeCalls = 0;
+$scalar = new LegacyScalar(42);
+$cache->store('scalar', $scalar);
+$fetched = $cache->fetch('scalar');
+ok('scalar instanceof', $fetched instanceof LegacyScalar);
+ok('scalar value', $fetched->value === 42);
+ok('scalar unserialize ran once per fetch', LegacyScalar::$unserializeCalls === 1);
+ok('scalar parity', serialize($fetched) === serialize($scalar));
+
+class Partial implements Serializable
+{
+ public int $kept = 0;
+ public int $notRestored = 0;
+
+ public function serialize(): string
+ {
+ return (string) $this->kept;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->kept = (int) $data;
+ }
+}
+
+$partial = new Partial();
+$partial->kept = 5;
+$partial->notRestored = 99;
+$cache->store('partial', $partial);
+$fetchedPartial = $cache->fetch('partial');
+$nativePartial = unserialize(serialize($partial));
+ok('partial matches native', $fetchedPartial->kept === $nativePartial->kept
+ && $fetchedPartial->notRestored === $nativePartial->notRestored);
+
+class LegacyBag implements Serializable
+{
+ public array $items = [];
+
+ public function serialize(): string
+ {
+ return serialize($this->items);
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->items = unserialize($data);
+ }
+}
+
+$bag = new LegacyBag();
+$bag->items = ['a' => 1, 'b' => [2, 3], 'when' => new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))];
+$cache->store('bag', $bag);
+$fetched = $cache->fetch('bag');
+ok('bag instanceof', $fetched instanceof LegacyBag);
+ok('bag array', $fetched->items['a'] === 1 && $fetched->items['b'] === [2, 3]);
+ok('bag nested object', $fetched->items['when'] instanceof DateTimeImmutable
+ && $fetched->items['when']->format('Y-m-d') === '2026-01-01');
+ok('bag parity', serialize($fetched) === serialize($bag));
+
+$graph = ['first' => new LegacyScalar(7), 'list' => [new LegacyScalar(8), new LegacyScalar(9)]];
+$cache->store('graph', $graph);
+$fetched = $cache->fetch('graph');
+ok('graph values', $fetched['first']->value === 7
+ && $fetched['list'][0]->value === 8
+ && $fetched['list'][1]->value === 9);
+ok('graph parity', serialize($fetched) === serialize($graph));
+
+/* Serializable outranks __sleep/__wakeup/__unserialize; __serialize/__unserialize outrank Serializable */
+$cache = UserCache\Cache::getPool('serializable-precedence');
+
+class PrecedenceSerVsSleep implements Serializable
+{
+ public int $kept = 0;
+ public string $via = 'none';
+
+ public function serialize(): string
+ {
+ return (string) $this->kept;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->kept = (int) $data;
+ $this->via = 'iface';
+ }
+
+ public function __sleep(): array
+ {
+ return ['kept'];
+ }
+
+ public function __wakeup(): void
+ {
+ $this->via = 'sleep';
+ }
+}
+
+class PrecedenceSerVsWakeup implements Serializable
+{
+ public int $kept = 0;
+ public string $via = 'none';
+
+ public function serialize(): string
+ {
+ return (string) $this->kept;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->kept = (int) $data;
+ $this->via = 'iface';
+ }
+
+ public function __wakeup(): void
+ {
+ $this->via = 'wakeup';
+ }
+}
+
+class PrecedenceSerVsMagicUnserialize implements Serializable
+{
+ public int $kept = 0;
+ public string $via = 'none';
+
+ public function serialize(): string
+ {
+ return (string) $this->kept;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->kept = (int) $data;
+ $this->via = 'iface';
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->via = 'magic';
+ }
+}
+
+function check_precedence(UserCache\Cache $cache, string $label, object $value): void
+{
+ $native = unserialize(serialize($value));
+ $cache->store($label, $value);
+ $fetched = $cache->fetch($label);
+
+ ok($label . ' instanceof', $fetched instanceof $value);
+ ok($label . ' interface won (native parity)',
+ $fetched->via === $native->via && $fetched->via === 'iface');
+ ok($label . ' value', $fetched->kept === $native->kept);
+}
+
+$a = new PrecedenceSerVsSleep();
+$a->kept = 5;
+check_precedence($cache, 'PrecedenceSerVsSleep', $a);
+
+$b = new PrecedenceSerVsWakeup();
+$b->kept = 6;
+check_precedence($cache, 'PrecedenceSerVsWakeup', $b);
+
+$c = new PrecedenceSerVsMagicUnserialize();
+$c->kept = 7;
+check_precedence($cache, 'PrecedenceSerVsMagicUnserialize', $c);
+
+class PrecedenceMagicOverIface implements Serializable
+{
+ public int $kept = 0;
+ public string $via = 'none';
+
+ public function serialize(): string
+ {
+ return 'IFACE';
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->via = 'iface';
+ }
+
+ public function __serialize(): array
+ {
+ return ['kept' => $this->kept];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->kept = $data['kept'];
+ $this->via = 'magic';
+ }
+}
+
+$d = new PrecedenceMagicOverIface();
+$d->kept = 8;
+$native = unserialize(serialize($d));
+$cache->store('magic-over-iface', $d);
+$fetched = $cache->fetch('magic-over-iface');
+ok('magic outranks interface', $fetched->via === $native->via && $fetched->via === 'magic');
+ok('magic value', $fetched->kept === $native->kept && $fetched->kept === 8);
+
+class SuspendsInCacheSerialize implements Serializable
+{
+ public function serialize()
+ {
+ Fiber::suspend('cache');
+
+ return 'a';
+ }
+
+ public function unserialize($data) {}
+}
+
+class SuspendsInNativeSerialize implements Serializable
+{
+ public function serialize()
+ {
+ Fiber::suspend('native');
+
+ return 'b';
+ }
+
+ public function unserialize($data) {}
+}
+
+function serdes_fiber_switch(): void
+{
+ $cache = UserCache\Cache::getPool('serdes-fiber-switch');
+ $native = new Fiber(fn () => serialize([new SuspendsInNativeSerialize()]));
+ $store = new Fiber(function () use ($cache) {
+ try {
+ return $cache->store('key', [new SuspendsInCacheSerialize()]);
+ } catch (FiberError $e) {
+ return $e::class . ': ' . $e->getMessage();
+ }
+ });
+
+ var_dump($native->start(), $store->start());
+ $native->resume();
+ var_dump($native->getReturn(), $store->getReturn());
+
+ $shared = new stdClass();
+ echo serialize([$shared, $shared, 1, 'x']), "\n";
+ var_dump(unserialize(serialize([$shared, $shared]))[1] instanceof stdClass, $cache->has('key'));
+}
+
+echo "\nserdes fiber switch:\n";
+serdes_fiber_switch();
+
+class Throws implements Serializable
+{
+ public function serialize()
+ {
+ throw new DomainException('boom');
+ }
+
+ public function unserialize($data)
+ {
+ }
+}
+
+function serializable_exception(): void
+{
+ $cache = UserCache\Cache::getPool('serializable-exception');
+ $attempts = [
+ 'store' => fn () => $cache->store('k', new Throws),
+ 'nested' => fn () => $cache->store('k', ['inner' => new Throws]),
+ 'storeMultiple' => fn () => $cache->storeMultiple(['k' => new Throws]),
+ 'remember' => fn () => $cache->remember('r', fn () => new Throws),
+ 'native' => fn () => serialize(new Throws),
+ ];
+ foreach ($attempts as $name => $attempt) {
+ try {
+ $attempt();
+ } catch (Throwable $e) {
+ echo $name, ': ', get_class($e), ' ', $e->getMessage(), ' previous=', var_export($e->getPrevious(), true), "\n";
+ }
+ }
+ var_dump($cache->has('k'), $cache->has('r'));
+}
+
+echo "\nserializable exception:\n";
+serializable_exception();
+
+class Thrower implements Serializable {
+ public function serialize() { return 'x'; }
+ public function unserialize($data) { throw new RuntimeException('cannot restore'); }
+}
+class Victim {
+ public $inner;
+ public function __construct() { $this->inner = new Thrower(); }
+ public function __wakeup() { echo "wakeup ran\n"; }
+ public function __destruct() { echo "destructor ran\n"; }
+}
+
+function restore_failure_skips_destructor(): void
+{
+ $cache = Cache::getPool('restore-failure-skips-destructor');
+ var_dump($cache->store('victim', new Victim()));
+ echo "fetching\n";
+ try {
+ var_dump($cache->fetch('victim', 'default'));
+ } catch (Throwable $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+ }
+ echo "native\n";
+ try { unserialize(serialize(new Victim())); } catch (Throwable $e) { echo get_class($e), ': ', $e->getMessage(), "\n"; }
+ echo "done\n";
+}
+
+echo "\nrestore failure skips destructor:\n";
+restore_failure_skips_destructor();
+
+class Inner implements Serializable {
+ public $v;
+ public function serialize() { $o = new stdClass; return serialize([$o, $o]); }
+ public function unserialize($data) { $this->v = unserialize($data); }
+}
+class FetchingOuter implements Serializable {
+ public static $fetched;
+ public function serialize() { return ''; }
+ public function unserialize($data) { self::$fetched = UserCache\Cache::getPool('serializable-inside-native-serialize')->fetch('inner'); }
+}
+class StoringOuter implements Serializable {
+ public function serialize() { UserCache\Cache::getPool('serializable-inside-native-serialize')->store('stored-inside', new Inner); return ''; }
+ public function unserialize($data) {}
+}
+
+function serializable_inside_native_serialize(): void
+{
+ $cache = UserCache\Cache::getPool('serializable-inside-native-serialize');
+ $cache->store('inner', new Inner);
+ $outer = unserialize('a:2:{i:0;O:8:"stdClass":0:{}i:1;C:13:"FetchingOuter":0:{}}');
+ $v = FetchingOuter::$fetched->v;
+ var_dump($v[0] === $v[1], $v[0] !== $outer[0]);
+ $o = new stdClass;
+ $s = serialize([new StoringOuter, $o, $o]);
+ var_dump($s);
+ $u = unserialize($s);
+ var_dump($u[1] === $u[2]);
+ $w = $cache->fetch('stored-inside')->v;
+ var_dump($w[0] === $w[1]);
+}
+
+echo "\nserializable inside native serialize:\n";
+serializable_inside_native_serialize();
+
+/* fetch() inside an outer serialize(): the restored object's unserialize() serializes its own value. */
+class SerializesOnRestore implements Serializable
+{
+ public function serialize(): string
+ {
+ return 'x';
+ }
+
+ public function unserialize(string $data): void
+ {
+ serialize([new stdClass, new stdClass]);
+ }
+}
+
+class FetchesWhileSerialized
+{
+ public $shared;
+
+ public function __construct()
+ {
+ $this->shared = new stdClass;
+ }
+
+ public function __serialize(): array
+ {
+ Cache::getPool('native-context')->fetch('restore');
+
+ return ['a' => $this->shared, 'filler' => new ArrayObject, 'b' => $this->shared];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->shared = $data;
+ }
+}
+
+/* store() inside an outer unserialize(): the stored object's serialize() unserializes its own value. */
+class UnserializesOnStore implements Serializable
+{
+ public static array $nested = [];
+
+ public function serialize(): string
+ {
+ self::$nested[] = unserialize('a:2:{i:0;O:8:"stdClass":0:{}i:1;r:2;}');
+
+ return 'x';
+ }
+
+ public function unserialize(string $data): void
+ {
+ }
+}
+
+class StoresWhileUnserialized
+{
+ public function __serialize(): array
+ {
+ return ['a' => new stdClass];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ Cache::getPool('native-context')->store('stored' . count(UnserializesOnStore::$nested), new UnserializesOnStore);
+ }
+}
+
+function serializable_native_context_isolation(): void
+{
+ $cache = Cache::getPool('native-context');
+ $cache->clear();
+ var_dump($cache->store('restore', new SerializesOnRestore));
+
+ $serialized = serialize(new FetchesWhileSerialized);
+ $restored = unserialize($serialized);
+ var_dump($restored->shared['a'] === $restored->shared['b'], get_debug_type($restored->shared['b']));
+
+ unserialize(serialize([new StoresWhileUnserialized, new StoresWhileUnserialized]));
+ foreach (UnserializesOnStore::$nested as $nested) {
+ var_dump(get_debug_type($nested[1]), $nested[0] === $nested[1]);
+ }
+}
+
+echo "\nserializable native context isolation:\n";
+serializable_native_context_isolation();
+
+class FatalInCacheSerialize implements Serializable
+{
+ public function __construct(public stdClass $shared) {}
+
+ public function serialize()
+ {
+ serialize([$this->shared]);
+ eval('class SerdesSerializeBailoutRedeclared {} class SerdesSerializeBailoutRedeclared {}');
+
+ return 'unreachable';
+ }
+
+ public function unserialize($data) {}
+}
+
+function serdes_serialize_bailout(): void
+{
+ $shared = new stdClass();
+
+ register_shutdown_function(function () use ($shared) {
+ echo "shutdown\n";
+
+ $fiber = new Fiber(fn () => Fiber::suspend('suspended'));
+ var_dump($fiber->start());
+
+ echo serialize([$shared, $shared]), "\n";
+ });
+
+ UserCache\Cache::getPool('serdes-serialize-bailout')->store('key', new FatalInCacheSerialize($shared));
+ echo "unreachable\n";
+}
+
+echo "\nserdes serialize bailout:\n";
+serdes_serialize_bailout();
+?>
+--EXPECTF--
+scalar instanceof: OK
+scalar value: OK
+scalar unserialize ran once per fetch: OK
+scalar parity: OK
+partial matches native: OK
+bag instanceof: OK
+bag array: OK
+bag nested object: OK
+bag parity: OK
+graph values: OK
+graph parity: OK
+PrecedenceSerVsSleep instanceof: OK
+PrecedenceSerVsSleep interface won (native parity): OK
+PrecedenceSerVsSleep value: OK
+PrecedenceSerVsWakeup instanceof: OK
+PrecedenceSerVsWakeup interface won (native parity): OK
+PrecedenceSerVsWakeup value: OK
+PrecedenceSerVsMagicUnserialize instanceof: OK
+PrecedenceSerVsMagicUnserialize interface won (native parity): OK
+PrecedenceSerVsMagicUnserialize value: OK
+magic outranks interface: OK
+magic value: OK
+
+serdes fiber switch:
+string(6) "native"
+NULL
+string(48) "a:1:{i:0;C:25:"SuspendsInNativeSerialize":1:{b}}"
+string(61) "FiberError: Cannot switch fibers in current execution context"
+a:4:{i:0;O:8:"stdClass":0:{}i:1;r:2;i:2;i:1;i:3;s:1:"x";}
+bool(true)
+bool(false)
+
+serializable exception:
+store: DomainException boom previous=NULL
+nested: DomainException boom previous=NULL
+storeMultiple: DomainException boom previous=NULL
+remember: DomainException boom previous=NULL
+native: DomainException boom previous=NULL
+bool(false)
+bool(false)
+
+restore failure skips destructor:
+destructor ran
+bool(true)
+fetching
+RuntimeException: cannot restore
+native
+destructor ran
+RuntimeException: cannot restore
+done
+
+serializable inside native serialize:
+bool(true)
+bool(true)
+string(65) "a:3:{i:0;C:12:"StoringOuter":0:{}i:1;O:8:"stdClass":0:{}i:2;r:3;}"
+bool(true)
+bool(true)
+
+serializable native context isolation:
+bool(true)
+bool(true)
+string(8) "stdClass"
+string(8) "stdClass"
+bool(true)
+string(8) "stdClass"
+bool(true)
+
+serdes serialize bailout:
+
+Fatal error: Cannot redeclare class SerdesSerializeBailoutRedeclared (previously declared in %s) in %s on line 1
+shutdown
+string(9) "suspended"
+a:2:{i:0;O:8:"stdClass":0:{}i:1;r:2;}
diff --git a/ext/user_cache/tests/user_cache_serialize_hooks.phpt b/ext/user_cache/tests/user_cache_serialize_hooks.phpt
new file mode 100644
index 000000000000..77824b87a292
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_serialize_hooks.phpt
@@ -0,0 +1,587 @@
+--TEST--
+UserCache\Cache: __serialize() and __unserialize() hooks follow the native serialization contract, including numeric property keys and the native unserialize() state after a fatal error
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+error_reporting=E_ALL & ~E_DEPRECATED
+--FILE--
+formatter = fn (string $v): string => $this->prefix . ':' . $v;
+ }
+
+ public function __serialize(): array
+ {
+ return ['prefix' => $this->prefix];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->prefix = $data['prefix'];
+ $this->formatter = fn (string $v): string => $this->prefix . ':' . $v;
+ }
+
+ public function format(string $v): string
+ {
+ return ($this->formatter)($v);
+ }
+}
+
+$holder = new ContractClosureHolder('log');
+var_dump($cache->store('closure-holder', $holder));
+var_dump($cache->fetch('closure-holder')->format('entry'));
+
+class ContractIndexedCollection
+{
+ /** @var array */
+ private array $items;
+ /** @var array */
+ private array $indexByName = [];
+
+ public function __construct(array $items)
+ {
+ $this->items = $items;
+ $this->rebuildIndex();
+ }
+
+ public function __serialize(): array
+ {
+ return ['items' => $this->items];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->items = $data['items'];
+ $this->rebuildIndex();
+ }
+
+ private function rebuildIndex(): void
+ {
+ $this->indexByName = [];
+ foreach ($this->items as $position => $item) {
+ $this->indexByName[$item['name']] = $position;
+ }
+ }
+
+ public function findByName(string $name): ?array
+ {
+ $position = $this->indexByName[$name] ?? null;
+
+ return $position === null ? null : $this->items[$position];
+ }
+}
+
+$collection = new ContractIndexedCollection([
+ ['id' => 10, 'name' => 'alpha'],
+ ['id' => 20, 'name' => 'beta'],
+]);
+var_dump($cache->store('indexed', $collection));
+var_dump($cache->fetch('indexed')->findByName('beta')['id']);
+
+class ContractPackedMatrix
+{
+ /** @var array> */
+ private array $rows;
+
+ public function __construct(array $rows)
+ {
+ $this->rows = $rows;
+ }
+
+ public function __serialize(): array
+ {
+ return ['packed' => implode(';', array_map(static fn (array $r): string => implode(',', $r), $this->rows))];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->rows = array_map(
+ static fn (string $r): array => array_map('intval', explode(',', $r)),
+ explode(';', $data['packed'])
+ );
+ }
+
+ public function cell(int $row, int $column): int
+ {
+ return $this->rows[$row][$column];
+ }
+}
+
+var_dump($cache->store('matrix', new ContractPackedMatrix([[1, 2], [3, 4]])));
+var_dump($cache->fetch('matrix')->cell(1, 0));
+
+class ContractParent
+{
+ private array $secret = [];
+
+ public function __serialize(): array
+ {
+ return ['secret' => $this->secret];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->secret = $data['secret'];
+ }
+
+ public function addSecret(string $v): void
+ {
+ $this->secret[] = $v;
+ }
+
+ public function secretCount(): int
+ {
+ return count($this->secret);
+ }
+}
+
+class ContractChild extends ContractParent
+{
+ public string $label = 'child';
+}
+
+$child = new ContractChild();
+$child->addSecret('a');
+$child->addSecret('b');
+var_dump($cache->store('child', $child));
+$fetchedChild = $cache->fetch('child');
+var_dump($fetchedChild instanceof ContractChild, $fetchedChild->secretCount(), $fetchedChild->label);
+
+class ContractReadonly
+{
+ public function __construct(public readonly string $token)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['token' => $this->token];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->token = $data['token'];
+ }
+}
+
+var_dump($cache->store('ro', new ContractReadonly('tok-1')));
+var_dump($cache->fetch('ro')->token);
+
+class ContractSelf
+{
+ public ?ContractSelf $self = null;
+
+ public function __construct(public int $v = 1)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['self' => $this, 'v' => $this->v];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->self = $data['self'];
+ $this->v = $data['v'];
+ }
+}
+
+var_dump($cache->store('self', new ContractSelf()));
+$fetchedSelf = $cache->fetch('self');
+var_dump($fetchedSelf->self === $fetchedSelf, $fetchedSelf->v);
+
+$nested = [
+ 'holders' => [new ContractClosureHolder('a'), new ContractClosureHolder('b')],
+ 'plain' => ['x' => 1],
+];
+var_dump($cache->store('nested', $nested));
+$fetchedNested = $cache->fetch('nested');
+var_dump($fetchedNested['holders'][1]->format('n'), $fetchedNested['plain']['x']);
+
+class ContractBadReturn
+{
+ public function __serialize()
+ {
+ return 'not-an-array';
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+try {
+ $cache->store('bad', new ContractBadReturn());
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+/* Release builds do not collect cycles at shutdown. */
+unset($holder, $collection, $child, $fetchedChild, $fetchedSelf, $nested, $fetchedNested);
+gc_collect_cycles();
+
+/* __serialize() without __unserialize() restores by property assignment */
+$cache = UserCache\Cache::getPool('serialize-hooks-serialize-only');
+
+class SerializeOnly
+{
+ public int $kept = 0;
+ public string $dropped = 'default';
+
+ public function __construct(int $kept)
+ {
+ $this->kept = $kept;
+ $this->dropped = 'set';
+ }
+
+ public function __serialize(): array
+ {
+ return ['kept' => $this->kept];
+ }
+}
+
+$value = new SerializeOnly(5);
+$cache->store('basic', $value);
+$fetched = $cache->fetch('basic');
+ok('basic instanceof', $fetched instanceof SerializeOnly);
+ok('basic kept restored', $fetched->kept === 5);
+ok('basic omitted reverts to default', $fetched->dropped === 'default');
+ok('basic parity', serialize($fetched) === serialize($value));
+
+class SerializeOnlyWakeful
+{
+ public static int $wakeupCalls = 0;
+ public int $v = 0;
+
+ public function __construct(int $v)
+ {
+ $this->v = $v;
+ }
+
+ public function __serialize(): array
+ {
+ return ['v' => $this->v];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ }
+}
+
+SerializeOnlyWakeful::$wakeupCalls = 0;
+$cache->store('wakeful', new SerializeOnlyWakeful(9));
+$first = $cache->fetch('wakeful');
+$second = $cache->fetch('wakeful');
+ok('wakeful value', $first->v === 9 && $second->v === 9);
+ok('wakeful __wakeup per fetch', SerializeOnlyWakeful::$wakeupCalls === 2);
+ok('wakeful distinct instances', $first !== $second);
+
+class SerializeOnlyRich
+{
+ public int $num;
+ private string $secret;
+ public ?object $child;
+
+ public function __construct()
+ {
+ $this->num = 1;
+ $this->secret = 'hidden';
+ $this->child = null;
+ }
+
+ public function __serialize(): array
+ {
+ return [
+ 'num' => 42,
+ "\0" . self::class . "\0secret" => 'restored',
+ 'child' => (object) ['deep' => [1, 2, 3]],
+ ];
+ }
+
+ public function reveal(): string
+ {
+ return $this->secret;
+ }
+}
+
+$rich = new SerializeOnlyRich();
+$cache->store('rich', $rich);
+$fetchedRich = $cache->fetch('rich');
+ok('rich typed', $fetchedRich->num === 42);
+ok('rich private', $fetchedRich->reveal() === 'restored');
+ok('rich nested object', $fetchedRich->child instanceof stdClass && $fetchedRich->child->deep === [1, 2, 3]);
+ok('rich parity', serialize($fetchedRich) === serialize($rich));
+
+$graph = ['a' => new SerializeOnly(1), 'list' => [new SerializeOnly(2), new SerializeOnly(3)]];
+$cache->store('graph', $graph);
+$fetchedGraph = $cache->fetch('graph');
+ok('graph values', $fetchedGraph['a']->kept === 1
+ && $fetchedGraph['list'][0]->kept === 2
+ && $fetchedGraph['list'][1]->kept === 3);
+ok('graph parity', serialize($fetchedGraph) === serialize($graph));
+
+#[\AllowDynamicProperties]
+class SerializeOnlyIntKeys
+{
+ public int $declared = 1;
+
+ public function __serialize(): array
+ {
+ return ['declared' => 7, 42 => 'answer', 'label' => 'x'];
+ }
+}
+
+$intKeys = new SerializeOnlyIntKeys();
+$cache->store('intkeys', $intKeys);
+$fetchedIntKeys = $cache->fetch('intkeys');
+ok('integer key parity', serialize($fetchedIntKeys) === serialize(unserialize(serialize($intKeys))));
+ok('integer key property', $fetchedIntKeys->declared === 7 && $fetchedIntKeys->{42} === 'answer');
+
+class SerializeOnlyResource
+{
+ public $handle;
+
+ public function __serialize(): array
+ {
+ return ['handle' => $this->handle];
+ }
+}
+
+$withResource = new SerializeOnlyResource();
+$withResource->handle = fopen(__FILE__, 'r');
+try {
+ $cache->store('resource', $withResource);
+ ok('resource rejected', false);
+} catch (TypeError $e) {
+ ok('resource rejected', true);
+}
+fclose($withResource->handle);
+
+/* __unserialize() without __serialize() receives the property table */
+$cache = UserCache\Cache::getPool('serialize-hooks-unserialize-only');
+
+class UnserializeOnlyContract
+{
+ public int $value = 7;
+ public int $derived = 0;
+ public static int $calls = 0;
+
+ public function __unserialize(array $data): void
+ {
+ self::$calls++;
+ $this->value = $data['value'];
+ $this->derived = $this->value * 3;
+ }
+}
+
+class SleepAndUnserializeContract
+{
+ public int $keep = 5;
+ public int $drop = 99;
+ public bool $wakeup = false;
+ public array $keys = [];
+ public static array $events = [];
+
+ public function __sleep(): array
+ {
+ self::$events[] = 'sleep';
+
+ return ['keep'];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$events[] = 'wakeup';
+ $this->wakeup = true;
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$events[] = 'unserialize';
+ $this->keys = array_keys($data);
+ $this->keep = $data['keep'];
+ $this->drop = -1;
+ }
+}
+
+UnserializeOnlyContract::$calls = 0;
+SleepAndUnserializeContract::$events = [];
+
+var_dump($cache->store('one', new UnserializeOnlyContract()));
+$one = $cache->fetch('one');
+var_dump($one->value, $one->derived, UnserializeOnlyContract::$calls);
+
+var_dump($cache->store('two', new SleepAndUnserializeContract()));
+$two = $cache->fetch('two');
+var_dump($two->keep, $two->drop, $two->wakeup, $two->keys, SleepAndUnserializeContract::$events);
+
+#[AllowDynamicProperties]
+class Plain
+{
+ public $got;
+
+ public function __unserialize(array $data): void
+ {
+ $this->got = [gettype(array_key_first($data)), isset($data[7]), $data[7] ?? null];
+ }
+}
+
+#[AllowDynamicProperties]
+class Sleeps
+{
+ public $got;
+
+ public function __sleep(): array
+ {
+ return ['7'];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->got = [gettype(array_key_first($data)), isset($data[7]), $data[7] ?? null];
+ }
+}
+
+function unserialize_route_numeric_property_keys(): void
+{
+ $cache = UserCache\Cache::getPool('unserialize-route-numeric-keys');
+ foreach ([new Plain, new Sleeps] as $object) {
+ unset($object->got);
+ $object->{'7'} = 'seven';
+ $cache->store('k', [$object]);
+ var_dump($cache->fetch('k')[0]->got === unserialize(serialize([$object]))[0]->got, $cache->fetch('k')[0]->got);
+ }
+}
+
+echo "\nunserialize route numeric property keys:\n";
+unserialize_route_numeric_property_keys();
+
+class FatalInCacheUnserialize implements Serializable
+{
+ public function serialize()
+ {
+ return 'payload';
+ }
+
+ public function unserialize($data)
+ {
+ unserialize('a:1:{i:0;O:8:"stdClass":0:{}}');
+ eval('class SerdesUnserializeBailoutRedeclared {} class SerdesUnserializeBailoutRedeclared {}');
+ }
+}
+
+function serdes_unserialize_bailout(): void
+{
+ register_shutdown_function(function () {
+ echo "shutdown\n";
+
+ $restored = unserialize('a:2:{i:0;O:8:"stdClass":0:{}i:1;r:2;}');
+ var_dump($restored[0] === $restored[1]);
+ });
+
+ $cache = UserCache\Cache::getPool('serdes-unserialize-bailout');
+ var_dump($cache->store('key', new FatalInCacheUnserialize()));
+ $cache->fetch('key');
+ echo "unreachable\n";
+}
+
+echo "\nserdes unserialize bailout:\n";
+serdes_unserialize_bailout();
+?>
+--EXPECTF--
+bool(true)
+string(9) "log:entry"
+bool(true)
+int(20)
+bool(true)
+int(3)
+bool(true)
+bool(true)
+int(2)
+string(5) "child"
+bool(true)
+string(5) "tok-1"
+bool(true)
+bool(true)
+int(1)
+bool(true)
+string(3) "b:n"
+int(1)
+ContractBadReturn::__serialize() must return an array
+basic instanceof: OK
+basic kept restored: OK
+basic omitted reverts to default: OK
+basic parity: OK
+wakeful value: OK
+wakeful __wakeup per fetch: OK
+wakeful distinct instances: OK
+rich typed: OK
+rich private: OK
+rich nested object: OK
+rich parity: OK
+graph values: OK
+graph parity: OK
+integer key parity: OK
+integer key property: OK
+resource rejected: OK
+bool(true)
+int(7)
+int(21)
+int(1)
+bool(true)
+int(5)
+int(-1)
+bool(false)
+array(1) {
+ [0]=>
+ string(4) "keep"
+}
+array(2) {
+ [0]=>
+ string(5) "sleep"
+ [1]=>
+ string(11) "unserialize"
+}
+
+unserialize route numeric property keys:
+bool(true)
+array(3) {
+ [0]=>
+ string(7) "integer"
+ [1]=>
+ bool(true)
+ [2]=>
+ string(5) "seven"
+}
+bool(true)
+array(3) {
+ [0]=>
+ string(7) "integer"
+ [1]=>
+ bool(true)
+ [2]=>
+ string(5) "seven"
+}
+
+serdes unserialize bailout:
+bool(true)
+
+Fatal error: Cannot redeclare class SerdesUnserializeBailoutRedeclared (previously declared in %s) in %s on line 1
+shutdown
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_shutdown_window_session_handler.phpt b/ext/user_cache/tests/user_cache_shutdown_window_session_handler.phpt
new file mode 100644
index 000000000000..6ca85d37486d
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_shutdown_window_session_handler.phpt
@@ -0,0 +1,70 @@
+--TEST--
+UserCache\Cache: API calls from a session save handler after rshutdown are gated
+--EXTENSIONS--
+session
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+session.save_path=
+session.use_cookies=0
+session.use_trans_sid=0
+session.cache_limiter=
+--FILE--
+store('k', 'v'));
+ var_dump($pool->fetch('k', 'default'));
+ var_dump($pool->lock('k'));
+ var_dump(UserCache\Cache::getStatus()->getAvailability()->name);
+ $cacheStatus = UserCache\Cache::getStatus();
+ var_dump($cacheStatus->getConfiguredMemory(), $cacheStatus->getSharedMemorySize(), $cacheStatus->getEntryCount(), $cacheStatus->getUsedMemory());
+ $status = UserCache\Cache::getPool('main')->getPoolStatus();
+ var_dump($status->getEntryCount(), $status->getEntryKeys(), $status->getUsedMemory());
+
+ return true;
+ }
+});
+
+class LateSession {
+ public function __destruct() {
+ session_start();
+ $_SESSION['x'] = 1;
+ }
+}
+$GLOBALS['late'] = new LateSession();
+
+$cache = UserCache\Cache::getPool('main');
+$cache->clear();
+var_dump($cache->store('before', 1));
+var_dump(UserCache\Cache::getStatus()->getAvailability()->name);
+echo "script end\n";
+?>
+--EXPECT--
+bool(true)
+string(9) "Available"
+script end
+in shutdown window
+bool(false)
+string(7) "default"
+bool(false)
+string(26) "UnavailableByUnknownReason"
+int(16777216)
+int(0)
+int(0)
+int(0)
+int(0)
+array(0) {
+}
+int(0)
diff --git a/ext/user_cache/tests/user_cache_sleep_wakeup.phpt b/ext/user_cache/tests/user_cache_sleep_wakeup.phpt
new file mode 100644
index 000000000000..481e4d85488e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_sleep_wakeup.phpt
@@ -0,0 +1,289 @@
+--TEST--
+UserCache\Cache: __sleep() and __wakeup() objects restore from shared graph state with per-fetch isolation
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+protectedKept = $protectedKept;
+ $this->privateKept = $privateKept;
+ }
+
+ public function __sleep(): array
+ {
+ self::$sleepCalls++;
+ return ['kept', 'protectedKept', 'privateKept', 'self'];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ $this->kept .= ':awake';
+ }
+
+ public function describe(): string
+ {
+ return $this->kept . '/' .
+ $this->dropped . '/' .
+ $this->protectedKept . '/' .
+ count($this->privateKept) . '/' .
+ ($this->self === $this ? 'self' : 'other');
+ }
+}
+
+UCSleepSharedGraphBox::$sleepCalls = 0;
+UCSleepSharedGraphBox::$wakeupCalls = 0;
+
+$box = new UCSleepSharedGraphBox();
+$box->kept = 'stored-kept';
+$box->dropped = 'stored-dropped';
+$box->setHidden(7, ['x', 'y']);
+$box->self = $box;
+
+var_dump($cache->store('box', $box));
+var_dump(UCSleepSharedGraphBox::$sleepCalls);
+
+$first = $cache->fetch('box');
+var_dump($first instanceof UCSleepSharedGraphBox);
+var_dump(UCSleepSharedGraphBox::$wakeupCalls);
+var_dump($first->describe());
+
+$second = $cache->fetch('box');
+var_dump(UCSleepSharedGraphBox::$wakeupCalls);
+var_dump($second !== $first);
+var_dump($second->self === $second);
+
+class UCSleepSharedGraphReadonly
+{
+ public function __construct(public readonly string $token)
+ {
+ }
+
+ public function __sleep(): array
+ {
+ return ['token'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+var_dump($cache->store('readonly', new UCSleepSharedGraphReadonly('tok-1')));
+var_dump($cache->fetch('readonly')->token);
+
+class UCSleepSharedGraphWakeupMutates
+{
+ public array $items = [1];
+
+ public function __sleep(): array
+ {
+ return ['items'];
+ }
+
+ public function __wakeup(): void
+ {
+ $this->items[] = 2;
+ }
+}
+
+var_dump($cache->store('wakeup-mutates', new UCSleepSharedGraphWakeupMutates()));
+$mutated = $cache->fetch('wakeup-mutates');
+var_dump($mutated->items);
+$mutated->items[] = 3;
+var_dump($cache->fetch('wakeup-mutates')->items);
+
+/* Release builds do not collect cycles at shutdown. */
+unset($box, $first, $second, $mutated);
+gc_collect_cycles();
+
+/* __sleep() naming a missing member warns at store(); __wakeup()-only classes still wake */
+$cache = UserCache\Cache::getPool('sleep-wakeup-missing-member');
+
+class SerdesBadSleeper
+{
+ public int $real = 1;
+
+ public function __sleep(): array
+ {
+ return ['real', 'missing'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+var_dump($cache->store('bad-sleeper', new SerdesBadSleeper()));
+var_dump($cache->fetch('bad-sleeper')->real);
+
+class SerdesWakerOnly
+{
+ public static int $wakeupCalls = 0;
+ public int $value = 5;
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ }
+}
+
+SerdesWakerOnly::$wakeupCalls = 0;
+$waker = new SerdesWakerOnly();
+$waker->value = 11;
+var_dump($cache->store('waker', $waker));
+var_dump($cache->fetch('waker')->value);
+var_dump(SerdesWakerOnly::$wakeupCalls);
+
+/* Shared arrays and twice-referenced nodes inside a __sleep() graph */
+$cache = UserCache\Cache::getPool('sleep-wakeup-graph');
+
+class SerdesNode
+{
+ public ?SerdesNode $link = null;
+ public array $items = [];
+
+ public function __sleep(): array
+ {
+ return ['link', 'items'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+$shared = ['payload' => range(1, 4)];
+$a = new SerdesNode();
+$b = new SerdesNode();
+$a->items = $shared;
+$b->items = $shared;
+$a->link = $b;
+var_dump($cache->store('pair', $a));
+$pair = $cache->fetch('pair');
+var_dump($pair->link instanceof SerdesNode);
+var_dump($pair->items === $pair->link->items);
+
+$node = new SerdesNode();
+$node->items = ['x' => 1];
+var_dump($cache->store('nested', ['first' => $node, 'second' => $node, 'plain' => [1, 2, 3]]));
+$nested = $cache->fetch('nested');
+var_dump($nested['first'] instanceof SerdesNode);
+var_dump($nested['first'] === $nested['second']);
+var_dump($nested['first']->items['x']);
+var_dump($nested['plain']);
+
+/* __wakeup()-only holder keeps identity with a child shared outside it */
+$cache = UserCache\Cache::getPool('sleep-wakeup-holder');
+
+class WakeupOnlyIdentityHolder
+{
+ public static int $wakeupCalls = 0;
+ public object $child;
+
+ public function __wakeup(): void
+ {
+ self::$wakeupCalls++;
+ }
+}
+
+class WakeupOnlyIdentityChild
+{
+ public int $value = 1;
+}
+
+$child = new WakeupOnlyIdentityChild();
+$holder = new WakeupOnlyIdentityHolder();
+$holder->child = $child;
+
+$native = unserialize(serialize(['holder' => $holder, 'child' => $child]));
+var_dump($native['holder']->child === $native['child']);
+
+WakeupOnlyIdentityHolder::$wakeupCalls = 0;
+
+var_dump($cache->store('graph', ['holder' => $holder, 'child' => $child]));
+
+$first = $cache->fetch('graph');
+var_dump($first['holder']->child === $first['child']);
+$first['holder']->child->value = 42;
+var_dump($first['child']->value);
+var_dump(WakeupOnlyIdentityHolder::$wakeupCalls);
+
+$second = $cache->fetch('graph');
+var_dump($second['holder']->child === $second['child']);
+var_dump($second['child']->value);
+var_dump(WakeupOnlyIdentityHolder::$wakeupCalls);
+var_dump($first['holder'] !== $second['holder']);
+?>
+--EXPECTF--
+bool(true)
+int(1)
+bool(true)
+int(1)
+string(42) "stored-kept:awake/default-dropped/7/2/self"
+int(2)
+bool(true)
+bool(true)
+bool(true)
+string(5) "tok-1"
+bool(true)
+array(2) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+}
+array(2) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+}
+
+Warning: UserCache\Cache::store(): "missing" returned as member variable from __sleep() but does not exist in %s on line %d
+bool(true)
+int(1)
+bool(true)
+int(11)
+int(1)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(1)
+array(3) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+ [2]=>
+ int(3)
+}
+bool(true)
+bool(true)
+bool(true)
+int(42)
+int(1)
+bool(true)
+int(1)
+int(2)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_small_segment_fork.phpt b/ext/user_cache/tests/user_cache_small_segment_fork.phpt
new file mode 100644
index 000000000000..523496fcdde9
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_small_segment_fork.phpt
@@ -0,0 +1,229 @@
+--TEST--
+UserCache\Cache: a 1 MiB segment shared with forked children keeps LRU eviction of a small entry table on the oldest candidates, lets a lock table filled by a dead owner be taken over, keeps a dead owner's leased lock guarding its key from LRU eviction until the lease expires, and does not make remember() wait for a slot of a full lock table
+--EXTENSIONS--
+pcntl
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+--FILE--
+getFreeMemory() < 47 * 1024) {
+ break;
+ }
+ $cache->store("k$i", $chunk . $i);
+ }
+ $stored = $i;
+ sleep(1);
+
+ /* A later reader makes the odd entries newer than the even ones. */
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $child = Cache::getPool('eviction-lru-small-table');
+ $child->store('tick', 1, 100);
+ $child->delete('tick');
+ for ($i = 1; $i < $stored; $i += 2) {
+ $child->fetch("k$i");
+ }
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ $before = Cache::getStatus();
+ var_dump($cache->store('big', str_repeat('B', 100 * 1024)));
+ $after = Cache::getStatus();
+ $evicted = $after->getEvictionCount() - $before->getEvictionCount();
+ $left = 0;
+ for ($i = 0; $i < $stored; $i++) {
+ $left += (int) $cache->has("k$i");
+ }
+ var_dump(
+ $after->getExpungeCount() === $before->getExpungeCount(),
+ $evicted > 1,
+ $left === $stored - $evicted,
+ $left > $stored / 2
+ );
+}
+
+function lock_table_dead_owners(): void
+{
+ $cache = UserCache\Cache::getPool('lock-dead-owners');
+ $cache->clear();
+
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $child = UserCache\Cache::getPool('lock-dead-owners');
+ $held = 0;
+ for ($i = 0; $i < 200; $i++) {
+ $held += $child->lock("dead-$i") ? 1 : 0;
+ }
+ echo "child holds $held locks\n";
+ /* exec() replaces the process without the request shutdown that would release the locks. */
+ pcntl_exec('/bin/true');
+ exit(1);
+ }
+ pcntl_waitpid($pid, $status);
+
+ var_dump($cache->lock('fresh'), $cache->unlock('fresh'));
+ var_dump($cache->lock('dead-0'), $cache->unlock('dead-0'));
+ var_dump($cache->lock('dead-127'), $cache->unlock('dead-127'));
+}
+
+function dead_owner_lease_eviction(): void
+{
+ $cache = Cache::getPool('dead-owner-lease-eviction');
+ $cache->clear();
+ $chunk = str_repeat('g', 45 * 1024);
+ var_dump(
+ $cache->store('guarded', $chunk . 'guarded'),
+ $cache->store('unleased', $chunk . 'unleased'),
+ $cache->store('unlocked', $chunk . 'unlocked')
+ );
+
+ $forked_at = hrtime();
+ $pid = pcntl_fork();
+ if ($pid === 0) {
+ $child = Cache::getPool('dead-owner-lease-eviction');
+ if ($child->lock('guarded', 2) && $child->lock('unleased')) {
+ pcntl_exec('/bin/true');
+ }
+ exit(1);
+ }
+ pcntl_waitpid($pid, $status);
+ $exited_at = hrtime();
+ var_dump(pcntl_wexitstatus($status));
+ sleep(1);
+
+ $before = Cache::getStatus();
+ $stored = 0;
+ for ($i = 0; $i < 60; $i++) {
+ $stored += (int) $cache->store("churn-$i", $chunk . $i);
+ }
+ $after = Cache::getStatus();
+ var_dump(
+ $stored,
+ $after->getEvictionCount() > $before->getEvictionCount(),
+ $after->getExpungeCount() === $before->getExpungeCount()
+ );
+ var_dump(
+ $cache->has('guarded') || seconds_since($forked_at) >= 2,
+ $cache->has('unleased'),
+ $cache->has('unlocked')
+ );
+
+ usleep(max(0, (int) ((2.3 - seconds_since($exited_at)) * 1000000)));
+ for ($i = 0; $i < 60; $i++) {
+ $stored += (int) $cache->store("late-churn-$i", $chunk . $i);
+ }
+ var_dump($stored, $cache->has('guarded'));
+ $cache->clear();
+}
+
+function remember_lock_table_full(): void
+{
+ $cache = UserCache\Cache::getPool('remember-lock-table-full');
+
+ $held = 0;
+ while ($held < 5000 && $cache->lock("held-$held")) {
+ $held++;
+ }
+ var_dump($held > 0 && $held < 5000);
+
+ $pid = pcntl_fork();
+ if ($pid < 0) die('fork failed');
+ if ($pid === 0) {
+ $calls = 0;
+ $start = hrtime(true);
+ $value = $cache->remember('fresh', function () use (&$calls) {
+ $calls++;
+
+ return 42;
+ });
+ $elapsed = (hrtime(true) - $start) / 1e9;
+
+ printf("remember=%d calls=%d fast=%s lock=%s\n", $value, $calls, $elapsed < 5.0 ? 'yes' : 'no', var_export($cache->lock('fresh-lock'), true));
+ exit(0);
+ }
+
+ pcntl_waitpid($pid, $status);
+ var_dump(pcntl_wexitstatus($status));
+}
+
+/* The segment outlives the request (php --repeat): start from fresh pools. */
+foreach ([
+ 'eviction-lru-small-table',
+ 'lock-dead-owners',
+ 'dead-owner-lease-eviction',
+ 'remember-lock-table-full'
+ ] as $pool) {
+ Cache::deletePool($pool);
+}
+
+echo "eviction lru small table:\n";
+eviction_lru_small_table();
+
+echo "\nlock table dead owners:\n";
+Cache::getPool('eviction-lru-small-table')->clear();
+lock_table_dead_owners();
+
+echo "\ndead owner lease eviction:\n";
+dead_owner_lease_eviction();
+
+echo "\nremember lock table full:\n";
+$dead = Cache::getPool('lock-dead-owners');
+for ($i = 0; $i < 200; $i++) {
+ if ($dead->lock("dead-$i")) {
+ $dead->unlock("dead-$i");
+ }
+}
+$dead->clear();
+remember_lock_table_full();
+?>
+--EXPECT--
+eviction lru small table:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+lock table dead owners:
+child holds 128 locks
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+dead owner lease eviction:
+bool(true)
+bool(true)
+bool(true)
+int(0)
+int(60)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+int(120)
+bool(false)
+
+remember lock table full:
+bool(true)
+remember=42 calls=1 fast=yes lock=false
+int(0)
diff --git a/ext/user_cache/tests/user_cache_spl_safe_direct.phpt b/ext/user_cache/tests/user_cache_spl_safe_direct.phpt
new file mode 100644
index 000000000000..947ba54dd905
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_spl_safe_direct.phpt
@@ -0,0 +1,608 @@
+--TEST--
+UserCache\Cache: SPL safe-direct state and SplObjectStorage graphs are restored, and storing a corrupted heap or a heap being modified throws the same exception as serialize()
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+ parent::__serialize()];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCalls++;
+ parent::__unserialize($data['payload']);
+ }
+}
+
+class UserCacheSerializedStack extends SplStack
+{
+ public static int $serializeCalls = 0;
+ public static int $unserializeCalls = 0;
+
+ public function __serialize(): array
+ {
+ self::$serializeCalls++;
+
+ return parent::__serialize();
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$unserializeCalls++;
+ parent::__unserialize($data);
+ }
+}
+
+class UserCacheTaggedFixedArray extends SplFixedArray
+{
+ private string $tag;
+ protected int $version;
+
+ public function __construct(int $size, string $tag, int $version)
+ {
+ parent::__construct($size);
+ $this->tag = $tag;
+ $this->version = $version;
+ }
+
+ public function describe(): string
+ {
+ return $this->tag . ':' . $this->version;
+ }
+}
+
+class UserCacheLabelIterator extends ArrayIterator
+{
+}
+
+class UserCacheTaggedCollection extends ArrayObject
+{
+ private string $type;
+
+ public function __construct(array $data, string $type, string $iteratorClass)
+ {
+ parent::__construct($data, 0, $iteratorClass);
+ $this->type = $type;
+ }
+
+ public function type(): string
+ {
+ return $this->type;
+ }
+}
+
+class UserCacheTaggedIterator extends ArrayIterator
+{
+ private string $label;
+
+ public function __construct(array $data, string $label)
+ {
+ parent::__construct($data);
+ $this->label = $label;
+ }
+
+ public function label(): string
+ {
+ return $this->label;
+ }
+}
+
+class UserCacheTaggedRecursiveIterator extends RecursiveArrayIterator
+{
+ private string $name;
+
+ public function __construct(array $data, string $name)
+ {
+ parent::__construct($data);
+ $this->name = $name;
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+}
+
+class UserCacheCountingMaxHeap extends SplMaxHeap
+{
+ public static int $compareCalls = 0;
+
+ protected function compare(mixed $a, mixed $b): int
+ {
+ self::$compareCalls++;
+
+ return $a['priority'] <=> $b['priority'];
+ }
+}
+
+$arrayObject = new ArrayObject(['a' => 1, 'b' => ['c' => 2]], ArrayObject::ARRAY_AS_PROPS);
+$arrayObject->extra = 'prop';
+
+$arrayIterator = new ArrayIterator(['x' => 10, 'y' => 20]);
+$recursiveArrayIterator = new RecursiveArrayIterator(['nested' => ['leaf' => 30]]);
+
+$fixed = SplFixedArray::fromArray(['zero', 'one', ['two']], false);
+
+$taggedFixed = new UserCacheTaggedFixedArray(3, 'vec', 7);
+$taggedFixed[0] = 'a';
+$taggedFixed[1] = ['nested' => 1];
+$taggedFixed[2] = 42;
+
+$taggedCollection = new UserCacheTaggedCollection(['alpha' => 10, 'beta' => 20], 'metric', UserCacheLabelIterator::class);
+$taggedIterator = new UserCacheTaggedIterator([3, 5, 8], 'fib');
+$taggedRecursiveIterator = new UserCacheTaggedRecursiveIterator(['leaf' => ['value' => 99]], 'tree');
+
+$dll = new SplDoublyLinkedList();
+$dll->setIteratorMode(SplDoublyLinkedList::IT_MODE_FIFO);
+$dll->push('first');
+$dll->push('second');
+
+$queue = new SplQueue();
+$queue->enqueue('q1');
+$queue->enqueue('q2');
+
+$stack = new SplStack();
+$stack->push('s1');
+$stack->push('s2');
+
+$min = new SplMinHeap();
+$min->insert(3);
+$min->insert(1);
+$min->insert(2);
+
+$max = new SplMaxHeap();
+$max->insert(3);
+$max->insert(1);
+$max->insert(2);
+
+$pq = new SplPriorityQueue();
+$pq->setExtractFlags(SplPriorityQueue::EXTR_BOTH);
+$pq->insert('low', 1);
+$pq->insert('high', 10);
+
+$countingMaxHeap = new UserCacheCountingMaxHeap();
+$countingMaxHeap->insert(['priority' => 1, 'node' => (object) ['score' => 61]]);
+$countingMaxHeap->insert(['priority' => 2, 'node' => (object) ['score' => 67]]);
+UserCacheCountingMaxHeap::$compareCalls = 0;
+
+$serializedArrayObject = new UserCacheSerializedArrayObject(['x' => 1]);
+
+$serializedStack = new UserCacheSerializedStack();
+$serializedStack->push('fallback');
+
+$payload = compact(
+ 'arrayObject',
+ 'arrayIterator',
+ 'recursiveArrayIterator',
+ 'fixed',
+ 'taggedFixed',
+ 'taggedCollection',
+ 'taggedIterator',
+ 'taggedRecursiveIterator',
+ 'dll',
+ 'queue',
+ 'stack',
+ 'min',
+ 'max',
+ 'pq',
+ 'countingMaxHeap',
+ 'serializedArrayObject',
+ 'serializedStack'
+);
+
+var_dump($cache->store('spl', $payload));
+
+$dirty = $cache->fetch('spl');
+$dirty['arrayObject']['a'] = 999;
+$clean = $cache->fetch('spl');
+var_dump($clean['arrayObject']['a']);
+
+$fetched = $cache->fetch('spl');
+
+var_dump($fetched['arrayObject'] instanceof ArrayObject);
+var_dump($fetched['arrayObject']['b']['c']);
+var_dump($fetched['arrayObject']->extra);
+
+var_dump($fetched['arrayIterator'] instanceof ArrayIterator);
+var_dump(iterator_to_array($fetched['arrayIterator']));
+
+var_dump($fetched['recursiveArrayIterator'] instanceof RecursiveArrayIterator);
+var_dump($fetched['recursiveArrayIterator']->hasChildren());
+
+var_dump($fetched['fixed'] instanceof SplFixedArray);
+var_dump($fetched['fixed']->getSize());
+var_dump($fetched['fixed'][2][0]);
+
+var_dump($fetched['taggedFixed'] instanceof UserCacheTaggedFixedArray);
+var_dump($fetched['taggedFixed']->getSize());
+var_dump($fetched['taggedFixed'][0]);
+var_dump($fetched['taggedFixed'][1]['nested']);
+var_dump($fetched['taggedFixed'][2]);
+var_dump($fetched['taggedFixed']->describe());
+
+$taggedCollectionIterator = $fetched['taggedCollection']->getIterator();
+var_dump($fetched['taggedCollection'] instanceof UserCacheTaggedCollection);
+var_dump($taggedCollectionIterator instanceof UserCacheLabelIterator);
+var_dump($fetched['taggedCollection']['alpha']);
+var_dump($fetched['taggedCollection']['beta']);
+var_dump($fetched['taggedCollection']->type());
+
+$fetched['taggedIterator']->rewind();
+var_dump($fetched['taggedIterator'] instanceof UserCacheTaggedIterator);
+var_dump($fetched['taggedIterator']->count());
+var_dump($fetched['taggedIterator']->current());
+var_dump($fetched['taggedIterator']->label());
+
+$fetched['taggedRecursiveIterator']->rewind();
+var_dump($fetched['taggedRecursiveIterator'] instanceof UserCacheTaggedRecursiveIterator);
+var_dump($fetched['taggedRecursiveIterator']->count());
+var_dump($fetched['taggedRecursiveIterator']->hasChildren());
+var_dump($fetched['taggedRecursiveIterator']->name());
+
+var_dump($fetched['dll'] instanceof SplDoublyLinkedList);
+var_dump(iterator_to_array($fetched['dll'], false));
+
+var_dump($fetched['queue'] instanceof SplQueue);
+var_dump($fetched['queue']->dequeue());
+var_dump($fetched['queue']->dequeue());
+
+var_dump($fetched['stack'] instanceof SplStack);
+var_dump($fetched['stack']->pop());
+var_dump($fetched['stack']->pop());
+
+var_dump($fetched['min'] instanceof SplMinHeap);
+$minOut = [];
+while (!$fetched['min']->isEmpty()) {
+ $minOut[] = $fetched['min']->extract();
+}
+var_dump($minOut);
+
+var_dump($fetched['max'] instanceof SplMaxHeap);
+$maxOut = [];
+while (!$fetched['max']->isEmpty()) {
+ $maxOut[] = $fetched['max']->extract();
+}
+var_dump($maxOut);
+
+var_dump($fetched['pq'] instanceof SplPriorityQueue);
+var_dump($fetched['pq']->extract());
+var_dump($fetched['pq']->extract());
+
+var_dump($fetched['countingMaxHeap'] instanceof UserCacheCountingMaxHeap);
+var_dump($fetched['countingMaxHeap']->top()['node']->score);
+var_dump(UserCacheCountingMaxHeap::$compareCalls);
+
+var_dump($fetched['serializedArrayObject'] instanceof UserCacheSerializedArrayObject);
+var_dump($fetched['serializedArrayObject']['x']);
+var_dump(UserCacheSerializedArrayObject::$serializeCalls);
+var_dump(UserCacheSerializedArrayObject::$unserializeCalls);
+
+var_dump($fetched['serializedStack'] instanceof UserCacheSerializedStack);
+var_dump($fetched['serializedStack'][0]);
+var_dump(UserCacheSerializedStack::$serializeCalls);
+var_dump(UserCacheSerializedStack::$unserializeCalls);
+
+/* SplObjectStorage entries, info data, shared key identity, subclasses and nesting round-trip */
+function ok(string $label, bool $cond): void
+{
+ echo $label, ': ', $cond ? 'OK' : 'FAIL', "\n";
+}
+
+class TaggedStorage extends SplObjectStorage
+{
+ public string $label = 'default';
+}
+
+$cache = UserCache\Cache::getPool('spl-storage-graph');
+
+$a = new stdClass();
+$a->id = 1;
+$b = new stdClass();
+$b->id = 2;
+$c = new stdClass();
+$c->id = 3;
+
+$storage = new SplObjectStorage();
+$storage[$a] = 'scalar-data';
+$storage[$b] = ['nested' => [1, 2, 3], 'flag' => true];
+$storage[$c] = (object) ['tag' => 'object-data'];
+
+$cache->store('storage', $storage);
+$fetched = $cache->fetch('storage');
+
+ok('instanceof', $fetched instanceof SplObjectStorage);
+ok('count', count($fetched) === 3);
+
+$byId = [];
+foreach ($fetched as $object) {
+ $byId[$object->id] = $fetched->getInfo();
+}
+ok('data preserved', $byId[1] === 'scalar-data'
+ && $byId[2] === ['nested' => [1, 2, 3], 'flag' => true]
+ && $byId[3] instanceof stdClass && $byId[3]->tag === 'object-data');
+ok('parity', serialize($fetched) === serialize($storage));
+
+$shared = new stdClass();
+$shared->id = 100;
+$sharedStorage = new SplObjectStorage();
+$sharedStorage[$shared] = 'info';
+$graph = ['storage' => $sharedStorage, 'also' => $shared];
+
+$cache->store('graph', $graph);
+$fetchedGraph = $cache->fetch('graph');
+$fetchedGraph['storage']->rewind();
+$keyObject = $fetchedGraph['storage']->current();
+ok('shared key identity', $keyObject === $fetchedGraph['also']);
+$fetchedGraph['also']->id = 200;
+ok('shared mutation follows', $keyObject->id === 200);
+
+$empty = new SplObjectStorage();
+$cache->store('empty', $empty);
+ok('empty storage', count($cache->fetch('empty')) === 0);
+
+$tagged = new TaggedStorage();
+$tagged->label = 'tagged';
+$element = new stdClass();
+$element->id = 5;
+$tagged[$element] = 'x';
+$cache->store('tagged', $tagged);
+$fetchedTagged = $cache->fetch('tagged');
+ok('subclass instanceof', $fetchedTagged instanceof TaggedStorage);
+ok('subclass property', $fetchedTagged->label === 'tagged' && count($fetchedTagged) === 1);
+ok('subclass parity', serialize($fetchedTagged) === serialize($tagged));
+
+$inner = new SplObjectStorage();
+$innerKey = new stdClass();
+$innerKey->id = 7;
+$inner[$innerKey] = 'inner';
+$outer = new SplObjectStorage();
+$outerKey = new stdClass();
+$outerKey->id = 8;
+$outer[$outerKey] = $inner;
+$cache->store('nested', $outer);
+$fetchedNested = $cache->fetch('nested');
+$fetchedNested->rewind();
+$nestedInfo = $fetchedNested->getInfo();
+ok('nested storage', $nestedInfo instanceof SplObjectStorage && count($nestedInfo) === 1);
+ok('nested parity', serialize($fetchedNested) === serialize($outer));
+
+class ThrowingHeap extends SplMinHeap
+{
+ public bool $throw = false;
+
+ protected function compare($value1, $value2): int
+ {
+ if ($this->throw) {
+ throw new Exception('compare failed');
+ }
+
+ return parent::compare($value1, $value2);
+ }
+}
+
+class StoringQueue extends SplPriorityQueue
+{
+ public ?Closure $hook = null;
+
+ public function compare($priority1, $priority2): int
+ {
+ if ($this->hook !== null) {
+ $hook = $this->hook;
+ $this->hook = null;
+ $hook($this);
+ }
+
+ return parent::compare($priority1, $priority2);
+ }
+}
+
+function show(string $label, Closure $callback): void
+{
+ try {
+ var_dump($callback());
+ } catch (Throwable $e) {
+ echo $label, ': ', $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+echo "\nspl heap unstorable state:\n";
+$cache = UserCache\Cache::getPool('spl-heap-unstorable-state');
+
+$heap = new ThrowingHeap;
+$heap->insert(1);
+$heap->insert(2);
+$heap->throw = true;
+try {
+ $heap->insert(3);
+} catch (Exception $e) {
+}
+
+show('serialize', static fn () => serialize($heap));
+show('store', static fn () => $cache->store('corrupted', $heap));
+var_dump($cache->has('corrupted'));
+
+$queue = new StoringQueue;
+$queue->insert('a', 1);
+$queue->hook = static function (StoringQueue $queue): void {
+ show('serialize', static fn () => serialize($queue));
+ show('store', static fn () => $GLOBALS['cache']->store('modified', $queue));
+};
+$queue->insert('b', 2);
+var_dump($cache->has('modified'));
+
+/* ArrayObject and ArrayIterator are cached with array storage only; object-backed storage is refused at store time, so no deprecation can fire mid-fetch */
+echo "\narray storage only:\n";
+$cache = UserCache\Cache::getPool('spl-array-storage-only');
+$deprecations = 0;
+set_error_handler(static function (int $errno) use (&$deprecations): bool {
+ if ($errno !== E_DEPRECATED) {
+ return false;
+ }
+ $deprecations++;
+
+ return true;
+});
+$objectBacked = new ArrayObject((object) ['k' => 1]);
+$objectBackedIterator = new ArrayIterator((object) ['k' => 2]);
+$wrappingOther = new ArrayObject(new ArrayObject(['o' => 3]));
+$self = new ArrayObject();
+$self->exchangeArray($self);
+var_dump($deprecations);
+foreach (['object-backed' => $objectBacked, 'object-backed-iterator' => $objectBackedIterator, 'wrapping-other' => $wrappingOther] as $key => $value) {
+ try {
+ var_dump($cache->store($key, $value));
+ } catch (TypeError $e) {
+ echo $key, ': ', $e->getMessage(), "\n";
+ }
+ var_dump($cache->has($key));
+}
+var_dump($cache->store('self', $self));
+$fetchedSelf = $cache->fetch('self');
+var_dump($fetchedSelf instanceof ArrayObject, serialize($fetchedSelf) === serialize($self));
+var_dump($deprecations);
+restore_error_handler();
+?>
+--EXPECT--
+bool(true)
+int(1)
+bool(true)
+int(2)
+string(4) "prop"
+bool(true)
+array(2) {
+ ["x"]=>
+ int(10)
+ ["y"]=>
+ int(20)
+}
+bool(true)
+bool(true)
+bool(true)
+int(3)
+string(3) "two"
+bool(true)
+int(3)
+string(1) "a"
+int(1)
+int(42)
+string(5) "vec:7"
+bool(true)
+bool(true)
+int(10)
+int(20)
+string(6) "metric"
+bool(true)
+int(3)
+int(3)
+string(3) "fib"
+bool(true)
+int(1)
+bool(true)
+string(4) "tree"
+bool(true)
+array(2) {
+ [0]=>
+ string(5) "first"
+ [1]=>
+ string(6) "second"
+}
+bool(true)
+string(2) "q1"
+string(2) "q2"
+bool(true)
+string(2) "s2"
+string(2) "s1"
+bool(true)
+array(3) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+ [2]=>
+ int(3)
+}
+bool(true)
+array(3) {
+ [0]=>
+ int(3)
+ [1]=>
+ int(2)
+ [2]=>
+ int(1)
+}
+bool(true)
+array(2) {
+ ["data"]=>
+ string(4) "high"
+ ["priority"]=>
+ int(10)
+}
+array(2) {
+ ["data"]=>
+ string(3) "low"
+ ["priority"]=>
+ int(1)
+}
+bool(true)
+int(67)
+int(0)
+bool(true)
+int(1)
+int(1)
+int(3)
+bool(true)
+string(8) "fallback"
+int(1)
+int(3)
+instanceof: OK
+count: OK
+data preserved: OK
+parity: OK
+shared key identity: OK
+shared mutation follows: OK
+empty storage: OK
+subclass instanceof: OK
+subclass property: OK
+subclass parity: OK
+nested storage: OK
+nested parity: OK
+
+spl heap unstorable state:
+serialize: RuntimeException: Heap is corrupted, heap properties are no longer ensured.
+store: RuntimeException: Heap is corrupted, heap properties are no longer ensured.
+bool(false)
+serialize: RuntimeException: Cannot serialize heap while it is being modified.
+store: RuntimeException: Cannot serialize heap while it is being modified.
+bool(false)
+
+array storage only:
+int(4)
+object-backed: The state of the ArrayObject object cannot be stored in the user cache
+bool(false)
+object-backed-iterator: The state of the ArrayIterator object cannot be stored in the user cache
+bool(false)
+wrapping-other: The state of the ArrayObject object cannot be stored in the user cache
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+int(4)
diff --git a/ext/user_cache/tests/user_cache_stack_limit.phpt b/ext/user_cache/tests/user_cache_stack_limit.phpt
new file mode 100644
index 000000000000..23e8d3b2bc7a
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_stack_limit.phpt
@@ -0,0 +1,219 @@
+--TEST--
+UserCache\Cache: values nested beyond the C stack limit are refused at store() and fall back to the default at fetch()
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=32M
+memory_limit=256M
+--FILE--
+start();
+
+ return $fiber->getReturn();
+}
+
+function chain(int $depth): stdClass
+{
+ $node = null;
+ for ($i = 0; $i < $depth; $i++) {
+ $next = new stdClass();
+ $next->depth = $i;
+ $next->next = $node;
+ $node = $next;
+ }
+
+ return $node;
+}
+
+class DtorProbe
+{
+ public static ?Closure $onDestruct = null;
+
+ public function __destruct()
+ {
+ if (self::$onDestruct !== null) {
+ (self::$onDestruct)();
+ }
+ }
+}
+
+function chain_depth(mixed $node): int
+{
+ $depth = 0;
+ while ($node instanceof stdClass) {
+ $node = $node->next;
+ $depth++;
+ }
+
+ return $depth;
+}
+
+$cache = UserCache\Cache::getPool('stack-limit');
+$cache->clear();
+$cache->store('small', ['a' => [1, 2]]);
+
+/* Graphs are built and released on the process stack; only the cache calls run in fibers. */
+$graph = chain(DEPTH);
+
+/* store() refuses a graph that does not fit the remaining stack without a partial store */
+$refused = in_fiber('512K', function () use ($cache, $graph) {
+ try {
+ return $cache->store('refused', $graph) ? 'stored' : 'false';
+ } catch (TypeError $e) {
+ return $e->getMessage();
+ }
+});
+echo $refused, "\n";
+var_dump($cache->has('refused'));
+
+/* The same graph is storable and fetchable with a larger stack */
+$stored = in_fiber('32M', fn () => $cache->store('deep', $graph));
+var_dump($stored);
+var_dump(in_fiber('32M', fn () => chain_depth($cache->fetch('deep'))));
+
+/* A decode that runs out of stack yields the default and keeps the entry */
+$results = in_fiber('512K', function () use ($cache) {
+ $out = [];
+ $out['fetch'] = $cache->fetch('deep', 'DEFAULT');
+ $out['fetch-after'] = $cache->fetch('small');
+ $out['multi'] = $cache->fetchMultiple(['small', 'deep', 'missing'], 'DEFAULT');
+ $out['has'] = $cache->has('deep');
+
+ return $out;
+});
+ok('fetch default', $results['fetch'] === 'DEFAULT');
+ok('next fetch unaffected', $results['fetch-after'] === ['a' => [1, 2]]);
+ok('fetchMultiple default only for the deep key', $results['multi'] === [
+ 'small' => ['a' => [1, 2]],
+ 'deep' => 'DEFAULT',
+ 'missing' => 'DEFAULT',
+]);
+ok('entry kept', $results['has'] && $cache->has('deep'));
+var_dump(error_get_last());
+var_dump(in_fiber('32M', fn () => chain_depth($cache->fetch('deep'))));
+
+echo "exception from a destructor of the partial value:\n";
+$probe = new DtorProbe();
+var_dump(in_fiber('32M', fn () => $cache->store('deep-dtor', ['probe' => new DtorProbe(), 'deep' => $graph])));
+var_dump(in_fiber('32M', fn () => $cache->store('deep-dtor-shared', ['probe' => $probe, 'again' => $probe, 'deep' => $graph])));
+unset($probe);
+DtorProbe::$onDestruct = static function (): void {
+ throw new RuntimeException('thrown by a destructor');
+};
+$results = in_fiber('512K', function () use ($cache) {
+ $calls = [
+ 'fetch' => fn () => $cache->fetch('deep-dtor', 'DEFAULT'),
+ 'fetchMultiple' => fn () => $cache->fetchMultiple(['small', 'deep-dtor', 'missing'], 'DEFAULT'),
+ 'remember' => fn () => $cache->remember('deep-dtor', function () {
+ echo "remember callback ran\n";
+
+ return 'computed';
+ }),
+ ];
+ $out = [];
+ foreach ($calls as $method => $call) {
+ try {
+ $out[] = $method . ': ' . var_export($call(), true);
+ } catch (RuntimeException $e) {
+ $out[] = $method . ': ' . get_class($e) . ': ' . $e->getMessage();
+ }
+ }
+
+ return $out;
+});
+DtorProbe::$onDestruct = null;
+echo implode("\n", $results), "\n";
+ok('entry kept after destructor exceptions', $cache->has('deep-dtor'));
+
+echo "cache calls from a destructor of the partial value:\n";
+DtorProbe::$onDestruct = static function () use ($cache): void {
+ $cache->has('small');
+ $cache->fetch('small');
+};
+$results = in_fiber('512K', fn () => [
+ 'fetch' => $cache->fetch('deep-dtor', 'DEFAULT'),
+ 'fetchMultiple' => $cache->fetchMultiple(['deep-dtor'], 'DEFAULT'),
+ 'fetch shared' => $cache->fetch('deep-dtor-shared', 'DEFAULT'),
+ 'fetchMultiple shared' => $cache->fetchMultiple(['deep-dtor-shared'], 'DEFAULT'),
+]);
+DtorProbe::$onDestruct = null;
+ok('defaults returned', $results === [
+ 'fetch' => 'DEFAULT',
+ 'fetchMultiple' => ['deep-dtor' => 'DEFAULT'],
+ 'fetch shared' => 'DEFAULT',
+ 'fetchMultiple shared' => ['deep-dtor-shared' => 'DEFAULT'],
+]);
+ok('entries kept after nested cache calls', $cache->has('deep-dtor') && $cache->has('deep-dtor-shared'));
+
+/* Nested arrays either store or throw; no depth fails without an exception. */
+$nested = [];
+for ($depth = 500; $depth <= 20000; $depth += 500) {
+ $arr = [];
+ for ($i = 0; $i < $depth; $i++) {
+ $arr = [$arr];
+ }
+ $nested[$depth] = $arr;
+}
+$silent = in_fiber('512K', function () use ($cache, $nested) {
+ $silent = [];
+ foreach ($nested as $depth => $arr) {
+ try {
+ if (!$cache->store("nested-$depth", $arr)) {
+ $silent[] = $depth;
+ }
+ } catch (TypeError $e) {
+ }
+ }
+ return $silent;
+});
+var_dump($silent);
+
+/* Destroying a deep value recurses once per level: release the cached prototypes on a large fiber
+ * stack and dismantle local values from the outside in. */
+in_fiber('32M', fn () => $cache->clear());
+foreach (array_keys($nested) as $depth) {
+ $arr = $nested[$depth];
+ unset($nested[$depth]);
+ while ($arr !== []) {
+ $arr = $arr[0];
+ }
+}
+while ($graph instanceof stdClass) {
+ $graph = $graph->next;
+}
+?>
+--EXPECT--
+Value is nested too deeply to be stored in the user cache
+bool(false)
+bool(true)
+int(5000)
+fetch default: OK
+next fetch unaffected: OK
+fetchMultiple default only for the deep key: OK
+entry kept: OK
+NULL
+int(5000)
+exception from a destructor of the partial value:
+bool(true)
+bool(true)
+fetch: RuntimeException: thrown by a destructor
+fetchMultiple: RuntimeException: thrown by a destructor
+remember: RuntimeException: thrown by a destructor
+entry kept after destructor exceptions: OK
+cache calls from a destructor of the partial value:
+defaults returned: OK
+entries kept after nested cache calls: OK
+array(0) {
+}
diff --git a/ext/user_cache/tests/user_cache_state_schema.phpt b/ext/user_cache/tests/user_cache_state_schema.phpt
new file mode 100644
index 000000000000..6594626ea477
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_state_schema.phpt
@@ -0,0 +1,358 @@
+--TEST--
+UserCache\Cache: class-scoped state schemas preserve magic semantics, distinguish key shapes, and restore __sleep() and __serialize() states on both sides of the shared schema size limit like unserialize()
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+ $this->id,
+ 'label' => $this->label,
+ 'nested' => [
+ 'label' => $this->label,
+ 'checksum' => $this->id + strlen($this->label),
+ ],
+ ];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $id = $data['id'];
+ $label = $data['label'];
+
+ self::$unserializeCalls++;
+ $data['id'] = -1;
+ $data[] = 'local-only';
+ self::$events[] = [array_keys($data), array_key_last($data)];
+
+ $this->id = $id;
+ $this->label = $label . ':' . $data['nested']['checksum'];
+ }
+
+ public function label(): string
+ {
+ return $this->id . ':' . $this->label;
+ }
+}
+
+class UserCacheSchemaA
+{
+ public function __construct(private int $value)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['value' => $this->value, 'tag' => 'a'];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->value = $data['value'] + 10;
+ }
+
+ public function value(): int
+ {
+ return $this->value;
+ }
+}
+
+class UserCacheSchemaB
+{
+ public function __construct(private int $value)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['value' => $this->value, 'tag' => 'b'];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->value = $data['value'] + 20;
+ }
+
+ public function value(): int
+ {
+ return $this->value;
+ }
+}
+
+class UserCacheSchemaSelfState
+{
+ public static int $selfMatches = 0;
+
+ public function __construct(private string $name)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['self' => $this, 'name' => $this->name];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ if ($data['self'] === $this) {
+ self::$selfMatches++;
+ }
+
+ $this->name = $data['name'];
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+}
+
+UserCacheSchemaPayload::$serializeCalls = 0;
+UserCacheSchemaPayload::$unserializeCalls = 0;
+UserCacheSchemaPayload::$events = [];
+UserCacheSchemaSelfState::$selfMatches = 0;
+
+var_dump($cache->store('payload', [
+ new UserCacheSchemaPayload(1, 'one'),
+ new UserCacheSchemaPayload(2, 'two'),
+ new UserCacheSchemaPayload(3, 'three'),
+]));
+
+$first = $cache->fetch('payload');
+$second = $cache->fetch('payload');
+
+echo UserCacheSchemaPayload::$serializeCalls, ',', UserCacheSchemaPayload::$unserializeCalls, "\n";
+echo $first[0]->label(), '|', $first[1]->label(), '|', $first[2]->label(), "\n";
+echo $second[0]->label(), '|', $second[1]->label(), '|', $second[2]->label(), "\n";
+var_dump(UserCacheSchemaPayload::$events[0]);
+var_dump(UserCacheSchemaPayload::$events[5]);
+
+$sharedPayload = new UserCacheSchemaPayload(4, 'four');
+var_dump($cache->store('shared-payload', [$sharedPayload, $sharedPayload]));
+$sharedPayload = $cache->fetch('shared-payload');
+var_dump($sharedPayload[0] === $sharedPayload[1]);
+echo $sharedPayload[0]->label(), '|', $sharedPayload[1]->label(), "\n";
+
+var_dump($cache->store('classes', [new UserCacheSchemaA(5), new UserCacheSchemaB(5)]));
+$classes = $cache->fetch('classes');
+echo get_class($classes[0]), ':', $classes[0]->value(), "\n";
+echo get_class($classes[1]), ':', $classes[1]->value(), "\n";
+
+var_dump($cache->store('self', new UserCacheSchemaSelfState('kept')));
+$self = $cache->fetch('self');
+echo $self->name(), ',', UserCacheSchemaSelfState::$selfMatches, "\n";
+
+/* Shape keys keep binary names, boundaries, order and class identity */
+$cache = UserCache\Cache::getPool('state-schema-key-shapes');
+
+class ShapeKeyBoundaryA
+{
+ public static int $stored = 0;
+ public static int $restored = 0;
+
+ public function __construct(public array $state)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ self::$stored++;
+ return $this->state;
+ }
+
+ public function __unserialize(array $state): void
+ {
+ self::$restored++;
+ $this->state = $state;
+ }
+}
+
+class ShapeKeyBoundaryB extends ShapeKeyBoundaryA
+{
+}
+
+ShapeKeyBoundaryA::$stored = 0;
+ShapeKeyBoundaryA::$restored = 0;
+
+$long = str_repeat('key', 512);
+$states = [
+ ['ab' => 1, 'c' => 2],
+ ['a' => 3, 'bc' => 4],
+ ['c' => 5, 'ab' => 6],
+ ["a\0b" => 7, "c\0" => 8],
+ [$long . 'a' => 9, $long . 'b' => 10],
+ ['one' => 1, 'two' => 2, 'three' => 3, 'four' => 4,
+ 'five' => 5, 'six' => 6, 'seven' => 7, 'eight' => 8],
+];
+$payload = [];
+foreach ($states as $state) {
+ /* A nonnegative next-free index without a hole keeps the shaped route. */
+ $state[0] = null;
+ unset($state[0]);
+ $payload[] = new ShapeKeyBoundaryA($state);
+ $payload[] = new ShapeKeyBoundaryB($state);
+}
+
+var_dump($cache->store('payload', $payload));
+for ($fetch = 0; $fetch < 2; $fetch++) {
+ $fetched = $cache->fetch('payload');
+ $valid = true;
+ foreach ($payload as $i => $original) {
+ $valid = $valid && get_class($fetched[$i]) === get_class($original)
+ && $fetched[$i]->state === $original->state;
+ $appended = $fetched[$i]->state;
+ $appended[] = null;
+ $valid = $valid && array_key_last($appended) === 1;
+ }
+ var_dump($valid);
+ $fetched[0]->state['ab'] = -1;
+}
+echo ShapeKeyBoundaryA::$stored, ',', ShapeKeyBoundaryA::$restored, "\n";
+
+class Sleepy
+{
+ public static int $woken = 0;
+
+ public $skipped = 'skip';
+
+ public function __construct(public int $id = 0, public string $name = '', public array $list = [])
+ {
+ }
+
+ public function __sleep(): array
+ {
+ return ['id', 'name', 'list'];
+ }
+
+ public function __wakeup(): void
+ {
+ self::$woken++;
+ }
+}
+
+class Magic
+{
+ public static int $restored = 0;
+
+ public function __construct(private int $id = 0, private array $list = [])
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['id' => $this->id, 'list' => $this->list];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ self::$restored++;
+ $this->id = $data['id'];
+ $this->list = $data['list'];
+ }
+}
+
+function state_schema_limit(): void
+{
+ $cache = UserCache\Cache::getPool('state-schema-limit');
+ /* 61 list elements make 64 state values, the most that share a schema; 62 exceed it. */
+ foreach ([61, 62] as $count) {
+ $sleepy = [];
+ $magic = [];
+ for ($i = 0; $i < 3; $i++) {
+ $sleepy[] = new Sleepy($i, "sleepy-$i", range($i, $i + $count - 1));
+ $magic[] = new Magic($i, array_fill(0, $count + 1, "magic-$i"));
+ }
+ $value = ['sleepy' => $sleepy, 'magic' => $magic];
+
+ var_dump($cache->store("state-$count", $value));
+ Sleepy::$woken = 0;
+ Magic::$restored = 0;
+ $fetched = $cache->fetch("state-$count");
+ echo "$count: woken ", Sleepy::$woken, ', restored ', Magic::$restored, "\n";
+ var_dump(serialize($fetched) === serialize(unserialize(serialize($value))));
+ var_dump($fetched['sleepy'][2]->skipped, count($fetched['sleepy'][2]->list));
+ }
+}
+
+echo "\nstate schema limit:\n";
+state_schema_limit();
+?>
+--EXPECT--
+bool(true)
+3,6
+1:one:4|2:two:5|3:three:8
+1:one:4|2:two:5|3:three:8
+array(2) {
+ [0]=>
+ array(4) {
+ [0]=>
+ string(2) "id"
+ [1]=>
+ string(5) "label"
+ [2]=>
+ string(6) "nested"
+ [3]=>
+ int(0)
+ }
+ [1]=>
+ int(0)
+}
+array(2) {
+ [0]=>
+ array(4) {
+ [0]=>
+ string(2) "id"
+ [1]=>
+ string(5) "label"
+ [2]=>
+ string(6) "nested"
+ [3]=>
+ int(0)
+ }
+ [1]=>
+ int(0)
+}
+bool(true)
+bool(true)
+4:four:8|4:four:8
+bool(true)
+UserCacheSchemaA:15
+UserCacheSchemaB:25
+bool(true)
+kept,1
+bool(true)
+bool(true)
+bool(true)
+12,24
+
+state schema limit:
+bool(true)
+61: woken 3, restored 3
+bool(true)
+string(4) "skip"
+int(61)
+bool(true)
+62: woken 3, restored 3
+bool(true)
+string(4) "skip"
+int(62)
diff --git a/ext/user_cache/tests/user_cache_status_commit.phpt b/ext/user_cache/tests/user_cache_status_commit.phpt
new file mode 100644
index 000000000000..2157f3952bfe
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_status_commit.phpt
@@ -0,0 +1,20 @@
+--TEST--
+UserCache\CacheStatus: getCommittedMemory() reports the whole segment on platforms that back the segment lazily, and getCommitFailureCount() stays 0
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+store('value', str_repeat('v', 100000));
+$status = UserCache\Cache::getStatus();
+var_dump($status->getCommittedMemory() === $status->getSharedMemorySize(), $status->getCommitFailureCount());
+?>
+--EXPECT--
+bool(true)
+int(0)
diff --git a/ext/user_cache/tests/user_cache_store_hook_gc.phpt b/ext/user_cache/tests/user_cache_store_hook_gc.phpt
new file mode 100644
index 000000000000..ae524255ec1e
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_store_hook_gc.phpt
@@ -0,0 +1,488 @@
+--TEST--
+UserCache\Cache: serialization hooks and destructors that run the cycle collector or suspend their Fiber while store() encodes or copies a value leave the collector usable, still collect released cycles and never confuse a freed object with a new one
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=32M
+--FILE--
+ 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class EncodeSuspends
+{
+ public function __serialize(): array
+ {
+ Fiber::suspend();
+
+ return ['k' => 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class EncodeInserts
+{
+ public function __construct(public EncodeHolder $holder, public string $class)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ $this->holder->late = new $this->class;
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class CopyGarbage
+{
+ public $self;
+
+ public function __destruct()
+ {
+ }
+}
+
+class Collects
+{
+ public $late;
+
+ public function __destruct()
+ {
+ echo 'Collects::__destruct collected=', gc_collect_cycles(), "\n";
+ }
+}
+
+class Suspends
+{
+ public $late;
+
+ public function __destruct()
+ {
+ Fiber::suspend('from destructor');
+ }
+}
+
+class Drops
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['root']->held = null;
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class DropsAndThrows
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['root']->held->late = null;
+
+ throw new RuntimeException('hook failed');
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+
+ public function __destruct()
+ {
+ echo 'DropsAndThrows::__destruct collected=', gc_collect_cycles(), "\n";
+ }
+}
+
+class Inserts
+{
+ public function __construct(public string $class)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ $held = $GLOBALS['root']->held;
+ if (is_array($held)) {
+ $GLOBALS['root']->held[] = new $this->class;
+ } else {
+ $held->late = new $this->class;
+ }
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class Root
+{
+ public $held;
+ public $late;
+}
+
+class Named
+{
+ public function __construct(public string $name)
+ {
+ }
+}
+
+/* Each hook inserts the next hooked object into a container that was already encoded, so the last one first runs in the
+ * last encoding attempt; the first hook frees room for the objects the later ones add. */
+class Holder
+{
+ public $a;
+ public $l2 = [];
+ public $l1 = [];
+ public $l0 = [];
+ public $space;
+ public $first;
+ public $z;
+}
+
+abstract class Chain
+{
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+final class First extends Chain
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['holder']->space = null;
+ $GLOBALS['holder']->l0[] = new Second;
+
+ return [];
+ }
+}
+
+final class Second extends Chain
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['holder']->l1[] = new Third;
+
+ return [];
+ }
+}
+
+final class Third extends Chain
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['holder']->l2[] = new Last;
+
+ return [];
+ }
+}
+
+final class Last extends Chain
+{
+ public function __serialize(): array
+ {
+ $holder = $GLOBALS['holder'];
+ $holder->a = null;
+ $holder->z = new Named('B');
+
+ return [];
+ }
+}
+
+/* A destructor run by the cycle collector while the value is encoded frees an encoded object. */
+class Garbage
+{
+ public $self;
+
+ public function __destruct()
+ {
+ global $phase, $garbageHolder;
+
+ if ($phase === 'encoding') {
+ $garbageHolder->a = null;
+ $garbageHolder->z = new Named('B');
+ $phase = 'mutated';
+
+ return;
+ }
+
+ if ($phase !== 'done') {
+ $next = new Garbage;
+ $next->self = $next;
+ }
+ }
+}
+
+class Leaf
+{
+ public $v = 1;
+}
+
+class EndMarker
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['phase'] = 'encoding';
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+function make_garbage(): void
+{
+ for ($i = 0; $i < 100; $i++) {
+ $garbage = new CopyGarbage;
+ $garbage->self = $garbage;
+ }
+}
+
+function report(string $label): void
+{
+ make_garbage();
+ echo $label, ': running=', var_export(gc_status()['running'], true),
+ ' protected=', var_export(gc_status()['protected'], true),
+ ' collected=', gc_collect_cycles(), "\n";
+}
+
+function consistent(UserCache\Cache $cache, string $key, bool $stored, object $actual): string
+{
+ if (!$stored) {
+ return 'not stored, ' . var_export($cache->has($key), true);
+ }
+
+ return serialize($cache->fetch($key)) === serialize($actual) ? 'stored as it is' : 'stored differently';
+}
+
+function store_hook_releases_cycle(): void
+{
+ global $node_ref, $items_ref;
+
+ $node = new Node;
+ $node->items = [$node, new DropsReferences];
+ $node_ref = $node;
+ $items_ref = $node->items;
+ $holder = new stdClass;
+ $holder->items = &$items_ref;
+ $holder->node = &$node_ref;
+ $weak = WeakReference::create($node);
+ unset($node);
+
+ var_dump(UserCache\Cache::getPool('store-hook-releases-cycle')->store('k', $holder));
+ unset($holder);
+ gc_collect_cycles();
+ var_dump($weak->get());
+}
+
+function store_hook_gc_during_encode(): void
+{
+ $cache = UserCache\Cache::getPool('store-hook-gc-during-encode');
+
+ $garbage = new Destructible;
+ $garbage->self = $garbage;
+ unset($garbage);
+ $holder = new EncodeHolder;
+ $cache->store('collects', [$holder, new EncodeInserts($holder, EncodeCollects::class)]);
+ var_dump(gc_status()['running'], gc_status()['protected']);
+ for ($i = 0; $i < 20000; $i++) {
+ $garbage = new Destructible;
+ $garbage->self = $garbage;
+ unset($garbage);
+ }
+ gc_collect_cycles();
+ var_dump(Destructible::$destroyed === 20001);
+
+ $fiber = new Fiber(function () use ($cache): void {
+ $holder = new EncodeHolder;
+ $cache->store('suspends', [$holder, new EncodeInserts($holder, EncodeSuspends::class)]);
+ });
+ $fiber->start();
+ var_dump(gc_status()['protected']);
+ $fiber->resume();
+ var_dump(gc_status()['protected']);
+}
+
+function store_copy_release_destructor_gc(): void
+{
+ global $root;
+
+ $cache = UserCache\Cache::getPool('store-copy-release-destructor-gc');
+
+ $root = new Root;
+ $root->held = new Collects;
+ make_garbage();
+ var_dump($cache->store('object', [$root, new Inserts(Drops::class)]));
+ report('object');
+
+ $root = new Root;
+ $root->held = [new Collects, 'x' => 1];
+ make_garbage();
+ var_dump($cache->store('array', [$root, new Inserts(Drops::class)]));
+ report('array');
+
+ $root = new Root;
+ $root->held = new Root;
+ make_garbage();
+ try {
+ $cache->store('failing', [$root, new Inserts(DropsAndThrows::class)]);
+ } catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+ }
+ report('failing');
+
+ $root = new Root;
+ $root->held = new Suspends;
+ $fiber = new Fiber(fn () => $cache->store('fiber', [$root, new Inserts(Drops::class)]));
+ var_dump($fiber->start());
+ report('suspended');
+ $fiber->resume();
+ var_dump($fiber->getReturn());
+ report('resumed');
+}
+
+function store_encoder_object_reuse(): void
+{
+ global $holder, $phase, $garbageHolder;
+
+ $cache = UserCache\Cache::getPool('store-encoder-object-reuse');
+
+ $holder = new Holder;
+ $holder->a = new Named('A');
+ $holder->space = str_repeat('x', 20000);
+ $holder->first = new First;
+ echo 'hook in the last attempt: ', consistent($cache, 'hooks', $cache->store('hooks', $holder), $holder), "\n";
+
+ $phase = 'building';
+ $garbageHolder = new stdClass;
+ $garbageHolder->a = new Named('A');
+ $garbageHolder->mid = [];
+ for ($i = 0; $i < 40000; $i++) {
+ $garbageHolder->mid[] = [new Leaf];
+ }
+ $garbageHolder->z = null;
+ $garbageHolder->end = new EndMarker;
+ $garbage = new Garbage;
+ $garbage->self = $garbage;
+ unset($garbage);
+ $stored = $cache->store('gc', $garbageHolder);
+ $phase = 'done';
+ $fetched = $stored ? $cache->fetch('gc') : null;
+ echo 'collector during encoding: ', $fetched === null || $fetched->a === null || $fetched->a !== $fetched->z ? 'no aliasing' : 'aliased', "\n";
+
+ /* Release builds do not collect cycles at shutdown. */
+ gc_collect_cycles();
+}
+
+gc_collect_cycles();
+echo "store hook releases cycle:\n";
+store_hook_releases_cycle();
+
+gc_collect_cycles();
+echo "\nstore hook gc during encode:\n";
+store_hook_gc_during_encode();
+
+gc_collect_cycles();
+echo "\nstore copy release destructor gc:\n";
+store_copy_release_destructor_gc();
+
+gc_collect_cycles();
+echo "\nstore encoder object reuse:\n";
+store_encoder_object_reuse();
+?>
+--EXPECT--
+store hook releases cycle:
+bool(true)
+NULL
+
+store hook gc during encode:
+bool(false)
+bool(false)
+bool(true)
+bool(false)
+bool(false)
+
+store copy release destructor gc:
+Collects::__destruct collected=100
+bool(true)
+object: running=false protected=false collected=100
+Collects::__destruct collected=100
+bool(true)
+array: running=false protected=false collected=100
+DropsAndThrows::__destruct collected=100
+hook failed
+failing: running=false protected=false collected=100
+string(15) "from destructor"
+suspended: running=false protected=false collected=100
+bool(true)
+resumed: running=false protected=false collected=100
+
+store encoder object reuse:
+hook in the last attempt: not stored, false
+collector during encoding: no aliasing
diff --git a/ext/user_cache/tests/user_cache_store_hook_mutation.phpt b/ext/user_cache/tests/user_cache_store_hook_mutation.phpt
new file mode 100644
index 000000000000..bbd523f63c0b
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_store_hook_mutation.phpt
@@ -0,0 +1,697 @@
+--TEST--
+UserCache\Cache: serialization hooks that grow, replace, drop or write the containers, references and objects being stored, or throw during sizing, leave store() and the live values intact
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+error_reporting=E_ALL & ~E_DEPRECATED
+zend.exception_ignore_args=1
+--FILE--
+list[] = $i;
+ }
+ self::$count = 0;
+
+ return ['x' => 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+/* Replaces the array that is being walked, freeing it. */
+class Replacer
+{
+ public static ?Holder $owner = null;
+
+ public function __serialize(): array
+ {
+ self::$owner->list = ['replaced'];
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+#[AllowDynamicProperties]
+class GraphDate extends DateTime
+{
+}
+
+/* Adds dynamic properties to the safe-direct object that is being walked. */
+class DateMutator
+{
+ public ?GraphDate $date = null;
+ public static int $count = 200;
+
+ public function __serialize(): array
+ {
+ for ($i = 0; $i < self::$count; $i++) {
+ $this->date->{"p$i"} = $i;
+ }
+ self::$count = 0;
+
+ return ['x' => 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class Box
+{
+ public $item;
+ public $pad = 'tail';
+}
+
+class Root
+{
+ public ?Box $box = null;
+}
+
+/* Drops the last reference to the plain object that is being walked. */
+class Unlinker
+{
+ public static ?Root $root = null;
+
+ public function __serialize(): array
+ {
+ self::$root->box = null;
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class Late
+{
+ public static ?stdClass $owner = null;
+
+ public function __serialize(): array
+ {
+ for ($i = 0; $i < 300; $i++) {
+ self::$owner->arr[] = $i;
+ }
+
+ return ['n' => 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+/* Adds a new hooked object behind an array that CALC already walked, so the
+ * copy pass runs a hook that grows the array it is writing. */
+class Injector
+{
+ public function __serialize(): array
+ {
+ Late::$owner->arr[] = new Late();
+ Late::$owner->arr[] = 't1';
+ Late::$owner->arr[] = 't2';
+
+ return ['b' => 1];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+final class DropHolders
+{
+ public function __serialize(): array
+ {
+ $GLOBALS['holders'] = [];
+
+ return ['dropped' => true];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+#[AllowDynamicProperties]
+class Order
+{
+ public $status = 'new';
+ public $child;
+}
+
+#[AllowDynamicProperties]
+class Stamp extends DateTimeImmutable
+{
+ public $label = 'draft';
+ public $child;
+}
+
+class Line
+{
+ public $owner;
+
+ public function __serialize(): array
+ {
+ $this->owner->touched = true;
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+class SleepPlainHolder
+{
+ public $inner;
+}
+
+class SleepSnapshotHolder
+{
+ public $inner;
+
+ public function __sleep(): array
+ {
+ return ['inner'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+class SleepDropper
+{
+ public static $holder;
+
+ public $a = 1;
+
+ public function __sleep(): array
+ {
+ SleepDropper::$holder->inner = null;
+
+ return ['a'];
+ }
+}
+
+class SerializePlainHolder
+{
+ public $inner;
+}
+
+class SerializeSnapshotHolder
+{
+ public $inner;
+
+ public function __serialize(): array
+ {
+ return ['inner' => $this->inner];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->inner = $data['inner'];
+ }
+}
+
+class SerializeDropper
+{
+ public static $holder;
+
+ public $a = 1;
+
+ public function __serialize(): array
+ {
+ SerializeDropper::$holder->inner = null;
+
+ return ['a' => $this->a];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->a = $data['a'];
+ }
+}
+
+class SerializeTracked
+{
+ public static int $destroyed = 0;
+
+ public function __serialize(): array
+ {
+ return ['value' => 7];
+ }
+
+ public function __destruct()
+ {
+ self::$destroyed++;
+ }
+}
+
+class SerializeThrows
+{
+ public function __serialize(): array
+ {
+ throw new RuntimeException('prepare failed');
+ }
+}
+
+class SerPlainHolder
+{
+ public $inner;
+}
+
+class SerDropper implements Serializable
+{
+ public static $holder;
+
+ public $a = 1;
+
+ public function serialize(): string
+ {
+ SerDropper::$holder->inner = null;
+
+ return (string) $this->a;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->a = (int) $data;
+ }
+}
+
+class SerSnapshotHolder implements Serializable
+{
+ public $inner;
+
+ public function serialize(): string
+ {
+ return serialize($this->inner);
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->inner = unserialize($data);
+ }
+}
+
+class SerDropper2 implements Serializable
+{
+ public static $holder;
+
+ public $a = 7;
+
+ public function serialize(): string
+ {
+ SerDropper2::$holder->inner = null;
+
+ return (string) $this->a;
+ }
+
+ public function unserialize(string $data): void
+ {
+ $this->a = (int) $data;
+ }
+}
+
+function make(object $owner): object
+{
+ $line = new Line;
+ $line->owner = $owner;
+ $owner->child = $line;
+
+ return $owner;
+}
+
+function ok(string $label, bool $cond): void
+{
+ echo $label, ': ', $cond ? 'OK' : 'FAIL', "\n";
+}
+
+function graph_encoder_hook_mutation(): void
+{
+ $cache = UserCache\Cache::getPool('graph-encoder-hook-mutation');
+
+ $holder = new Holder();
+ $holder->list = [new Grower(), 'a', 'b', 'c'];
+ Grower::$owner = $holder;
+ var_dump($cache->store('grow', $holder));
+ var_dump(count($holder->list));
+ var_dump($cache->store('grow', $holder));
+ var_dump(count($cache->fetch('grow')->list));
+
+ $holder = new Holder();
+ $holder->list = [new Replacer(), str_repeat('y', 64)];
+ Replacer::$owner = $holder;
+ var_dump($cache->store('replace', $holder));
+ var_dump($cache->fetch('replace')->list);
+
+ $date = new GraphDate('2020-01-02 03:04:05', new DateTimeZone('UTC'));
+ $mutator = new DateMutator();
+ $mutator->date = $date;
+ $date->a = $mutator;
+ $date->b = 'tail';
+ var_dump($cache->store('date', $date));
+ var_dump(count(get_object_vars($date)));
+ var_dump($cache->store('date', $date));
+ $fetched = $cache->fetch('date');
+ var_dump($fetched->format('Y-m-d H:i:s'), count(get_object_vars($fetched)), $fetched->b);
+
+ $root = new Root();
+ $root->box = new Box();
+ $root->box->item = new Unlinker();
+ Unlinker::$root = $root;
+ var_dump($cache->store('unlink', $root));
+ var_dump($cache->fetch('unlink'));
+
+ $owner = new stdClass();
+ $owner->arr = [];
+ Late::$owner = $owner;
+ var_dump($cache->store('late', [$owner, new Injector(), str_repeat('x', 20000)]));
+ var_dump(count($owner->arr));
+
+ /* Release builds do not collect cycles at shutdown. */
+ $date = $mutator = $fetched = null;
+ gc_collect_cycles();
+}
+
+function store_hook_drops_reference_holders(): void
+{
+ global $holders;
+
+ $cache = UserCache\Cache::getPool('store-hook-drops-reference-holders');
+
+ $holders = [];
+ $references = [];
+ for ($i = 0; $i < 100; $i++) {
+ $holders[$i] = $i;
+ $references[$i] = &$holders[$i];
+ }
+ $value = [$references, new DropHolders, $references];
+ unset($references);
+
+ var_dump($cache->store('value', $value));
+
+ $fetched = $cache->fetch('value');
+ $fetched[0][0] = 'changed';
+ var_dump($fetched[2][0], $fetched[0][99]);
+}
+
+function store_hook_writes_container(): void
+{
+ $cache = UserCache\Cache::getPool('store-hook-writes-container');
+
+ $order = make(new Order);
+ /* The hook changes an object that was already visited, so the store itself may fail. */
+ $cache->store('order', $order);
+ $order->status = 'paid';
+ var_dump(get_object_vars($order)['status'], ((array) $order)['status'], $order->touched);
+
+ $stamp = make(new Stamp('2020-01-01'));
+ $cache->store('stamp', $stamp);
+ $stamp->label = 'final';
+ var_dump(get_object_vars($stamp)['label']);
+
+ /* Declared slots stay visible to the cycle collector. */
+ $order = make(new Order);
+ $cache->store('cycle', $order);
+ $weak = WeakReference::create($order);
+ $order = $stamp = null;
+ gc_collect_cycles();
+ var_dump($weak->get());
+}
+
+function store_hook_lifetimes(): void
+{
+ /* __sleep()/__serialize() releasing the only reference to $this while the store walks it */
+ $cache = UserCache\Cache::getPool('hook-lifetimes-magic');
+
+ $holder = new SleepPlainHolder();
+ $holder->inner = new SleepDropper();
+ SleepDropper::$holder = $holder;
+ ok('sleep plain store', $cache->store('sleep-plain', $holder) === true);
+ ok('sleep plain live property cleared', $holder->inner === null);
+ $fetched = $cache->fetch('sleep-plain');
+ ok('sleep plain fetched class', $fetched instanceof SleepPlainHolder);
+ ok('sleep plain fetched property', $fetched->inner === null);
+
+ $holder = new SleepSnapshotHolder();
+ $holder->inner = new SleepDropper();
+ SleepDropper::$holder = $holder;
+ ok('sleep snapshot store', $cache->store('sleep-snapshot', $holder) === true);
+ ok('sleep snapshot live property cleared', $holder->inner === null);
+ $fetched = $cache->fetch('sleep-snapshot');
+ ok('sleep snapshot fetched class', $fetched instanceof SleepSnapshotHolder);
+ ok('sleep snapshot inner class', $fetched->inner instanceof SleepDropper);
+ ok('sleep snapshot inner value', $fetched->inner->a === 1);
+
+ $holder = new SerializePlainHolder();
+ $holder->inner = new SerializeDropper();
+ SerializeDropper::$holder = $holder;
+ ok('serialize plain store', $cache->store('serialize-plain', $holder) === true);
+ ok('serialize plain live property cleared', $holder->inner === null);
+ $fetched = $cache->fetch('serialize-plain');
+ ok('serialize plain fetched class', $fetched instanceof SerializePlainHolder);
+ ok('serialize plain fetched property', $fetched->inner === null);
+
+ $holder = new SerializeSnapshotHolder();
+ $holder->inner = new SerializeDropper();
+ SerializeDropper::$holder = $holder;
+ ok('serialize snapshot store', $cache->store('serialize-snapshot', $holder) === true);
+ ok('serialize snapshot live property cleared', $holder->inner === null);
+ $fetched = $cache->fetch('serialize-snapshot');
+ ok('serialize snapshot fetched class', $fetched instanceof SerializeSnapshotHolder);
+ ok('serialize snapshot inner class', $fetched->inner instanceof SerializeDropper);
+ ok('serialize snapshot inner value', $fetched->inner->a === 1);
+
+ /* Sizing fails after the memo pins an earlier object, before a graph buffer exists. */
+ SerializeTracked::$destroyed = 0;
+ try {
+ $cache->store('failed', [new SerializeTracked(), new SerializeThrows()]);
+ } catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+ }
+ gc_collect_cycles();
+ ok('failed preparation releases memo', SerializeTracked::$destroyed === 1);
+ ok('failed preparation leaves no entry', !$cache->has('failed'));
+ ok('cache still usable', $cache->store('scalar', 7) && $cache->fetch('scalar') === 7);
+
+ /* Release builds do not collect cycles at shutdown. */
+ SleepDropper::$holder = null;
+ SerializeDropper::$holder = null;
+ unset($holder, $fetched);
+ gc_collect_cycles();
+
+ /* Serializable::serialize() releasing the only reference to $this while the encoder walks it */
+ $cache = UserCache\Cache::getPool('hook-lifetimes-serializable');
+
+ $holder = new SerPlainHolder();
+ $holder->inner = new SerDropper();
+ SerDropper::$holder = $holder;
+ ok('serializable plain store', $cache->store('serdes-plain', $holder) === true);
+ ok('serializable plain live property cleared', $holder->inner === null);
+ $fetched = $cache->fetch('serdes-plain');
+ ok('serializable plain fetched class', $fetched instanceof SerPlainHolder);
+ ok('serializable plain fetched property', $fetched->inner === null);
+
+ $holder = new SerSnapshotHolder();
+ $holder->inner = new SerDropper2();
+ SerDropper2::$holder = $holder;
+ ok('serializable snapshot store', $cache->store('serdes-snapshot', $holder) === true);
+ ok('serializable snapshot live property cleared', $holder->inner === null);
+ $fetched = $cache->fetch('serdes-snapshot');
+ ok('serializable snapshot fetched class', $fetched instanceof SerSnapshotHolder);
+ ok('serializable snapshot inner class', $fetched->inner instanceof SerDropper2);
+ ok('serializable snapshot inner value', $fetched->inner->a === 7);
+
+ ok('cache still usable', $cache->store('scalar', 5) && $cache->fetch('scalar') === 5);
+
+ /* Release builds do not collect cycles at shutdown. */
+ SerDropper::$holder = null;
+ SerDropper2::$holder = null;
+ unset($holder, $fetched);
+ gc_collect_cycles();
+}
+
+/* Appends to the ArrayObject being stored; ArrayObject writes its storage in place without separating it. */
+class ArrayObjectGrower
+{
+ public ?ArrayObject $target = null;
+ public string $keyPrefix = '';
+
+ public function __serialize(): array
+ {
+ for ($i = 0; $i < 200; $i++) {
+ if ($this->keyPrefix === '') {
+ $this->target[] = str_repeat('x', 40) . $i;
+ } else {
+ $this->target[$this->keyPrefix . $i] = str_repeat('x', 40) . $i;
+ }
+ }
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+function store_hook_writes_array_object(): void
+{
+ $cache = UserCache\Cache::getPool('hook-writes-array-object');
+ $grower = new ArrayObjectGrower();
+ $target = new ArrayObject();
+ $grower->target = $target;
+ $target['grower'] = $grower;
+ $target['b'] = 'y';
+ $target['c'] = 'z';
+
+ ok('array object store', $cache->store('ao', $target) === true);
+ ok('array object live storage grew', count($target) === 203);
+
+ $fetched = $cache->fetch('ao');
+ ok('array object fetched snapshot', $fetched instanceof ArrayObject && count($fetched) === 3);
+ ok('array object fetched element', $fetched['grower'] instanceof ArrayObjectGrower && $fetched['c'] === 'z');
+
+ $later = new ArrayObject(['b' => 'y', 'c' => 'z']);
+ $laterGrower = new ArrayObjectGrower();
+ $laterGrower->target = $later;
+ ok('array object written after its state store', $cache->store('ao-later', [$later, $laterGrower]) === true);
+ ok('array object written after its state snapshot', count($cache->fetch('ao-later')[0]) === 2);
+
+ $self = new ArrayObject();
+ $self->exchangeArray($self);
+ $selfGrower = new ArrayObjectGrower();
+ $selfGrower->target = $self;
+ $selfGrower->keyPrefix = 'p';
+ $self['grower'] = $selfGrower;
+ $self['c'] = 'z';
+ ok('self-backed array object store', $cache->store('ao-self', $self) === true);
+ ok('self-backed array object fetched', $cache->fetch('ao-self') instanceof ArrayObject);
+
+ /* Release builds do not collect cycles at shutdown. */
+ $grower->target = $laterGrower->target = $selfGrower->target = null;
+ unset($grower, $target, $fetched, $later, $laterGrower, $self, $selfGrower);
+ gc_collect_cycles();
+}
+
+echo "graph encoder hook mutation:\n";
+graph_encoder_hook_mutation();
+
+echo "\nstore hook drops reference holders:\n";
+store_hook_drops_reference_holders();
+
+echo "\nstore hook writes container:\n";
+store_hook_writes_container();
+
+echo "\nstore hook lifetimes:\n";
+store_hook_lifetimes();
+
+echo "\nstore hook writes ArrayObject:\n";
+store_hook_writes_array_object();
+?>
+--EXPECTF--
+graph encoder hook mutation:
+bool(true)
+int(504)
+bool(true)
+int(504)
+bool(true)
+array(1) {
+ [0]=>
+ string(8) "replaced"
+}
+bool(true)
+int(202)
+bool(true)
+string(19) "2020-01-02 03:04:05"
+int(202)
+string(4) "tail"
+bool(true)
+object(Root)#%d (1) {
+ ["box"]=>
+ NULL
+}
+bool(false)
+int(303)
+
+store hook drops reference holders:
+bool(true)
+int(0)
+int(99)
+
+store hook writes container:
+string(4) "paid"
+string(4) "paid"
+bool(true)
+string(5) "final"
+NULL
+
+store hook lifetimes:
+sleep plain store: OK
+sleep plain live property cleared: OK
+sleep plain fetched class: OK
+sleep plain fetched property: OK
+sleep snapshot store: OK
+sleep snapshot live property cleared: OK
+sleep snapshot fetched class: OK
+sleep snapshot inner class: OK
+sleep snapshot inner value: OK
+serialize plain store: OK
+serialize plain live property cleared: OK
+serialize plain fetched class: OK
+serialize plain fetched property: OK
+serialize snapshot store: OK
+serialize snapshot live property cleared: OK
+serialize snapshot fetched class: OK
+serialize snapshot inner class: OK
+serialize snapshot inner value: OK
+prepare failed
+failed preparation releases memo: OK
+failed preparation leaves no entry: OK
+cache still usable: OK
+serializable plain store: OK
+serializable plain live property cleared: OK
+serializable plain fetched class: OK
+serializable plain fetched property: OK
+serializable snapshot store: OK
+serializable snapshot live property cleared: OK
+serializable snapshot fetched class: OK
+serializable snapshot inner class: OK
+serializable snapshot inner value: OK
+cache still usable: OK
+
+store hook writes ArrayObject:
+array object store: OK
+array object live storage grew: OK
+array object fetched snapshot: OK
+array object fetched element: OK
+array object written after its state store: OK
+array object written after its state snapshot: OK
+self-backed array object store: OK
+self-backed array object fetched: OK
diff --git a/ext/user_cache/tests/user_cache_store_hook_nested_store_overflow.phpt b/ext/user_cache/tests/user_cache_store_hook_nested_store_overflow.phpt
new file mode 100644
index 000000000000..e948879e4ea1
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_store_hook_nested_store_overflow.phpt
@@ -0,0 +1,163 @@
+--TEST--
+UserCache\Cache: a store() run by a __serialize() hook of an outer store() neither hides nor forges the outer value's nesting failure
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=32M
+memory_limit=256M
+--FILE--
+start();
+
+ return $fiber->getReturn();
+}
+
+function chain(int $depth): stdClass
+{
+ $node = null;
+ for ($i = 0; $i < $depth; $i++) {
+ $next = new stdClass();
+ $next->depth = $i;
+ $next->next = $node;
+ $node = $next;
+ }
+
+ return $node;
+}
+
+function chain_depth(mixed $node): int
+{
+ $depth = 0;
+ while ($node instanceof stdClass) {
+ $node = $node->next;
+ $depth++;
+ }
+
+ return $depth;
+}
+
+class NestedStorer
+{
+ public static ?UserCache\Cache $cache = null;
+ public static mixed $inner = null;
+
+ public function __construct(public int $id)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ try {
+ $stored = var_export(self::$cache->store("inner-{$this->id}", self::$inner), true);
+ } catch (TypeError $e) {
+ $stored = $e->getMessage();
+ }
+ echo " inner store: $stored\n";
+
+ return ['id' => $this->id];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->id = $data['id'];
+ }
+}
+
+function outer_store(UserCache\Cache $cache, string $key, mixed $value, string $stackSize): string
+{
+ return in_fiber($stackSize, function () use ($cache, $key, $value) {
+ try {
+ return var_export($cache->store($key, $value), true);
+ } catch (TypeError $e) {
+ return $e->getMessage();
+ }
+ });
+}
+
+$cache = UserCache\Cache::getPool('nested-store-overflow');
+$cache->clear();
+NestedStorer::$cache = $cache;
+
+$deep = chain(DEPTH);
+$overflowing = chain(OVERFLOWING_DEPTH);
+$small = ['a' => [1, 2]];
+
+echo "inner overflows, outer overflows:\n";
+NestedStorer::$inner = $overflowing;
+echo outer_store($cache, 'outer-1', [new NestedStorer(1), $overflowing], '512K'), "\n";
+var_dump($cache->has('outer-1'), $cache->has('inner-1'));
+
+echo "inner overflows, outer fits:\n";
+echo outer_store($cache, 'outer-2', [new NestedStorer(2), $small], '512K'), "\n";
+var_dump($cache->has('inner-2'));
+$fetched = $cache->fetch('outer-2');
+var_dump($fetched[0] instanceof NestedStorer && $fetched[0]->id === 2 && $fetched[1] === $small);
+
+echo "inner fits, outer overflows:\n";
+NestedStorer::$inner = $small;
+echo outer_store($cache, 'outer-3', [new NestedStorer(3), $overflowing], '512K'), "\n";
+var_dump($cache->has('outer-3'), $cache->fetch('inner-3') === $small);
+
+echo "outer overflows before the hook runs:\n";
+echo outer_store($cache, 'outer-4', [$overflowing, new NestedStorer(4)], '512K'), "\n";
+var_dump($cache->has('outer-4'), $cache->has('inner-4'));
+
+echo "both fit on a larger stack:\n";
+NestedStorer::$inner = $deep;
+echo outer_store($cache, 'outer-5', [new NestedStorer(5), $deep], '32M'), "\n";
+var_dump(in_fiber('32M', fn () => chain_depth($cache->fetch('inner-5'))));
+var_dump(in_fiber('32M', fn () => chain_depth($cache->fetch('outer-5')[1])));
+
+echo "the cache stays usable afterwards:\n";
+var_dump($cache->store('after', 'ok'), $cache->fetch('after'));
+var_dump(error_get_last());
+
+/* Destroying a deep value recurses once per level: release the cached prototypes on a large fiber
+ * stack and dismantle the local chain from the outside in. */
+in_fiber('32M', fn () => $cache->clear());
+unset($fetched);
+NestedStorer::$inner = null;
+while ($deep instanceof stdClass) {
+ $deep = $deep->next;
+}
+while ($overflowing instanceof stdClass) {
+ $overflowing = $overflowing->next;
+}
+?>
+--EXPECT--
+inner overflows, outer overflows:
+ inner store: Value is nested too deeply to be stored in the user cache
+Value is nested too deeply to be stored in the user cache
+bool(false)
+bool(false)
+inner overflows, outer fits:
+ inner store: Value is nested too deeply to be stored in the user cache
+true
+bool(false)
+bool(true)
+inner fits, outer overflows:
+ inner store: true
+Value is nested too deeply to be stored in the user cache
+bool(false)
+bool(true)
+outer overflows before the hook runs:
+Value is nested too deeply to be stored in the user cache
+bool(false)
+bool(false)
+both fit on a larger stack:
+ inner store: true
+true
+int(5000)
+int(5000)
+the cache stays usable afterwards:
+bool(true)
+string(2) "ok"
+NULL
diff --git a/ext/user_cache/tests/user_cache_store_multiple_bailout.phpt b/ext/user_cache/tests/user_cache_store_multiple_bailout.phpt
new file mode 100644
index 000000000000..30f04b7689c2
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_store_multiple_bailout.phpt
@@ -0,0 +1,65 @@
+--TEST--
+UserCache\Cache: storeMultiple() rolls back a committed replacement and releases the lock when the commit loop bails out (CLI)
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+--FILE--
+clear();
+$cache->store('a', 'ORIGINAL');
+$cache->store('b', ['ORIGINAL-B']);
+$baseline = UserCache\Cache::getStatus()->getFreeMemory();
+$entries = UserCache\Cache::getStatus()->getEntryCount();
+
+register_shutdown_function(function () use ($cache, $baseline, $entries) {
+ putenv('USER_CACHE_DEBUG_FORCE_BULK_COMMIT_BAILOUT');
+
+ echo "shutdown\n";
+ var_dump($cache->fetch('a', 'MISS'));
+ var_dump($cache->fetch('b', 'MISS'));
+ var_dump($cache->has('z'));
+ var_dump(UserCache\Cache::getStatus()->getFreeMemory() === $baseline);
+ var_dump(UserCache\Cache::getStatus()->getEntryCount() === $entries);
+
+ /* The global lock was released: later writes and bulk writes proceed */
+ var_dump($cache->store('after', 1));
+ var_dump($cache->storeMultiple(['a' => 'REPLACED', 'z' => 'NEW']));
+ var_dump($cache->fetchMultiple(['a', 'b', 'z', 'after']));
+});
+
+/* The fault fires after the first item ('a') has been committed */
+putenv('USER_CACHE_DEBUG_FORCE_BULK_COMMIT_BAILOUT=1');
+$cache->storeMultiple(['a' => 'REPLACEMENT', 'z' => 'NEW', 'b' => ['REPLACEMENT-B']]);
+echo "not reached\n";
+?>
+--EXPECT--
+shutdown
+string(8) "ORIGINAL"
+array(1) {
+ [0]=>
+ string(10) "ORIGINAL-B"
+}
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+array(4) {
+ ["a"]=>
+ string(8) "REPLACED"
+ ["b"]=>
+ array(1) {
+ [0]=>
+ string(10) "ORIGINAL-B"
+ }
+ ["z"]=>
+ string(3) "NEW"
+ ["after"]=>
+ int(1)
+}
diff --git a/ext/user_cache/tests/user_cache_store_referenced_payload_no_futile_eviction.phpt b/ext/user_cache/tests/user_cache_store_referenced_payload_no_futile_eviction.phpt
new file mode 100644
index 000000000000..c1e26fecd83a
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_store_referenced_payload_no_futile_eviction.phpt
@@ -0,0 +1,54 @@
+--TEST--
+UserCache\Cache: a value that cannot fit beside the payloads still referenced by readers fails without evicting or clearing entries
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=4M
+user_cache.eviction_policy=lru
+--FILE--
+clear();
+ $cache->store('big', range(1, 40000));
+ $held = $cache->fetch('big');
+ if ($mode === 'overwritten') {
+ $cache->store('big', 1);
+ } elseif ($mode === 'deleted') {
+ $cache->delete('big');
+ }
+ for ($i = 0; $i < 50; $i++) {
+ $cache->store("small$i", $i);
+ }
+
+ $before = Cache::getStatus();
+ $stored = $cache->store('huge', str_repeat('h', 3500000));
+ $after = Cache::getStatus();
+
+ echo $mode, ': ', json_encode([
+ 'stored' => $stored,
+ 'entries kept' => $after->getEntryCount() === $before->getEntryCount(),
+ 'nothing evicted' => $after->getEvictionCount() === $before->getEvictionCount(),
+ 'not cleared' => $after->getExpungeCount() === $before->getExpungeCount(),
+ 'small49' => $cache->fetch('small49'),
+ 'held' => count($held),
+ ]), "\n";
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+}
+?>
+--EXPECT--
+kept: {"stored":false,"entries kept":true,"nothing evicted":true,"not cleared":true,"small49":49,"held":40000}
+overwritten: {"stored":false,"entries kept":true,"nothing evicted":true,"not cleared":true,"small49":49,"held":40000}
+deleted: {"stored":false,"entries kept":true,"nothing evicted":true,"not cleared":true,"small49":49,"held":40000}
diff --git a/ext/user_cache/tests/user_cache_ttl_minimum_lifetime.phpt b/ext/user_cache/tests/user_cache_ttl_minimum_lifetime.phpt
new file mode 100644
index 000000000000..35987b8eaa14
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_ttl_minimum_lifetime.phpt
@@ -0,0 +1,35 @@
+--TEST--
+UserCache\Cache: a TTL lasts at least its length on the monotonic clock and ends shortly after
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=8M
+--FILE--
+clear();
+
+$start = hrtime();
+var_dump($cache->store('short', 'value', 1));
+$hits = 0;
+$misses = 0;
+while (seconds_since($start) < 0.9) {
+ if ($cache->fetch('short') === 'value') {
+ $hits++;
+ } else {
+ $misses++;
+ }
+ usleep(10000);
+}
+var_dump($hits > 0, $misses);
+
+usleep(max(0, (int) ((1.2 - seconds_since($start)) * 1000000)));
+var_dump($cache->fetch('short', 'expired'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(0)
+string(7) "expired"
diff --git a/ext/user_cache/tests/user_cache_ttl_reclaim.phpt b/ext/user_cache/tests/user_cache_ttl_reclaim.phpt
new file mode 100644
index 000000000000..84530fd0f3ea
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_ttl_reclaim.phpt
@@ -0,0 +1,180 @@
+--TEST--
+UserCache\Cache: expiry reclaim survives TTL transitions, bulk rollback and tombstone rehash, and entry counts include expired entries until they are reclaimed
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.entries_hint=127
+user_cache.eviction_policy=none
+--FILE--
+clear();
+var_dump($semantics->increment('counter', 1, 1));
+var_dump($semantics->store('overwritten', 'v1', 1));
+var_dump($semantics->store('overwritten', 'v2'));
+var_dump($semantics->store('shortened', 'v1'));
+var_dump($semantics->store('shortened', 'v2', 1));
+
+/* TTL transitions and bulk rollback reclaimed by the write sweep */
+$cache = UserCache\Cache::getPool('ttl-transitions');
+$cache->clear();
+$cache->store('persistent', 1, 1);
+$cache->store('persistent', 2);
+$cache->store('expired', 1);
+$cache->store('expired', 2, 1);
+$cache->store('rollback', 3, 1);
+var_dump($cache->storeMultiple([
+ 'rollback' => 4,
+ 'too-large' => str_repeat('x', 4 * 1024 * 1024),
+]));
+$cache->store('deleted', 1, 1);
+$cache->delete('deleted');
+$cache->increment('counter', 1, 1);
+$cache->increment('counter', 1);
+$other = UserCache\Cache::getPool('ttl-cleared');
+$other->store('removed', 1, 1);
+$other->clear();
+
+sleep(2);
+
+var_dump($semantics->fetch('counter', 'MISS'));
+var_dump($semantics->increment('counter'));
+var_dump($semantics->fetch('overwritten', 'MISS'));
+var_dump($semantics->fetch('shortened', 'MISS'));
+
+for ($i = 0; $i < 128; $i++) {
+ $cache->store('write', $i);
+}
+$keys = $cache->getPoolStatus()->getEntryKeys();
+sort($keys);
+var_dump($keys, $cache->fetch('persistent'));
+
+$cache->clear();
+$cache->store('expired-after-clear', 1, 1);
+sleep(2);
+for ($i = 0; $i < 128; $i++) {
+ $cache->store('write', $i);
+}
+var_dump($cache->getPoolStatus()->getEntryKeys());
+
+/* Delete churn rehashes the table while TTL tracking stays active */
+$cache = UserCache\Cache::getPool('ttl-rehash');
+$cache->clear();
+var_dump($cache->store('expired', 1, 1));
+for ($i = 0; $i < 50; $i++) {
+ $cache->store("keep_$i", ['id' => $i, 'name' => "keeper_$i"]);
+}
+for ($round = 0; $round < 10; $round++) {
+ for ($i = 0; $i < 80; $i++) {
+ $cache->store("churn_{$round}_{$i}", $i);
+ }
+ for ($i = 0; $i < 80; $i++) {
+ $cache->delete("churn_{$round}_{$i}");
+ }
+}
+var_dump(UserCache\Cache::getStatus()->getTombstoneCount() < 80);
+
+$ok = true;
+for ($i = 0; $i < 50; $i++) {
+ $value = $cache->fetch("keep_$i");
+ if (!is_array($value) || $value['id'] !== $i || $value['name'] !== "keeper_$i") {
+ $ok = false;
+ echo "lost keep_$i\n";
+ }
+}
+var_dump($ok);
+var_dump($cache->fetch('churn_0_0', 'gone'));
+var_dump($cache->has('churn_9_79'));
+var_dump($cache->store('after_rehash', 'value'));
+var_dump($cache->fetch('after_rehash'));
+
+sleep(2);
+for ($i = 0; $i < 128; $i++) {
+ $cache->store('live', $i);
+}
+$expected = ['after_rehash', 'live'];
+for ($i = 0; $i < 50; $i++) {
+ $expected[] = "keep_$i";
+}
+sort($expected);
+$keys = $cache->getPoolStatus()->getEntryKeys();
+sort($keys);
+var_dump($keys === $expected);
+
+function expired_entries_counted(): void
+{
+ $cache = UserCache\Cache::getPool('ttl-counted');
+ $cache->clear();
+ $base = UserCache\Cache::getStatus()->getEntryCount();
+ var_dump($cache->store('short-a', 1, 1), $cache->store('short-b', 'b', 1), $cache->store('kept', 0));
+ var_dump(UserCache\Cache::getStatus()->getEntryCount() - $base, $cache->getPoolStatus()->getEntryCount());
+
+ usleep(1200000);
+ var_dump($cache->has('short-a'), $cache->fetch('short-b', 'MISS'));
+ $pool_status = $cache->getPoolStatus();
+ $keys = $pool_status->getEntryKeys();
+ sort($keys);
+ var_dump(UserCache\Cache::getStatus()->getEntryCount() - $base, $pool_status->getEntryCount(), $keys);
+
+ for ($i = 0; $i < 128; $i++) {
+ $cache->store('kept', $i);
+ }
+ var_dump(UserCache\Cache::getStatus()->getEntryCount() - $base, $cache->getPoolStatus()->getEntryCount());
+}
+
+echo "\nexpired entries counted:\n";
+expired_entries_counted();
+?>
+--EXPECT--
+int(1)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+string(4) "MISS"
+int(1)
+string(2) "v2"
+string(4) "MISS"
+array(2) {
+ [0]=>
+ string(10) "persistent"
+ [1]=>
+ string(5) "write"
+}
+int(2)
+array(1) {
+ [0]=>
+ string(5) "write"
+}
+bool(true)
+bool(true)
+bool(true)
+string(4) "gone"
+bool(false)
+bool(true)
+string(5) "value"
+bool(true)
+
+expired entries counted:
+bool(true)
+bool(true)
+bool(true)
+int(3)
+int(3)
+bool(false)
+string(4) "MISS"
+int(3)
+int(3)
+array(3) {
+ [0]=>
+ string(4) "kept"
+ [1]=>
+ string(7) "short-a"
+ [2]=>
+ string(7) "short-b"
+}
+int(1)
+int(1)
diff --git a/ext/user_cache/tests/user_cache_ttl_reclaim_fork.phpt b/ext/user_cache/tests/user_cache_ttl_reclaim_fork.phpt
new file mode 100644
index 000000000000..dc2c749c0b14
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_ttl_reclaim_fork.phpt
@@ -0,0 +1,106 @@
+--TEST--
+UserCache\Cache: expired entries are reclaimed across forked workers, read-only shutdown and write traffic
+--EXTENSIONS--
+pcntl
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=1M
+user_cache.entries_hint=127
+--FILE--
+clear();
+$cache->store('parent', 1);
+$pid = pcntl_fork();
+if ($pid === 0) {
+ exit($cache->store('child', 2, 1) ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+var_dump($cache->getPoolStatus()->getEntryCount());
+sleep(2);
+for ($i = 0; $i < 128; $i++) {
+ $cache->store('parent', $i);
+}
+var_dump($cache->getPoolStatus()->getEntryKeys());
+
+/* Expired entries observed by read misses are reclaimed by the next single store */
+$cache = UserCache\Cache::getPool('expired-read-reclaim');
+$cache->clear();
+$start = hrtime();
+for ($i = 0; $i < 80; $i++) {
+ $cache->store('expiring-' . $i, str_repeat('v', 64), 1);
+}
+$cache->store('persistent', 'kept');
+/* A 1s TTL lasts at least a second; only a slower loop lets the write sweep reclaim entries. */
+var_dump(seconds_since($start) >= 1 || $cache->getPoolStatus()->getEntryCount() === 81);
+sleep(2);
+for ($i = 0; $i < 80; $i++) {
+ if ($cache->fetch('expiring-' . $i, null) !== null) {
+ exit('unexpected hit');
+ }
+}
+$cache->store('trigger', 'mutation');
+var_dump($cache->getPoolStatus()->getEntryCount());
+var_dump($cache->fetch('persistent'));
+var_dump($cache->fetch('trigger'));
+
+/* Read-only request shutdown and write traffic alone return memory to the baseline */
+const KEYS = 100;
+$cache = UserCache\Cache::getPool('expired-reclaim');
+$cache->clear();
+
+function free_memory(): int {
+ return UserCache\Cache::getStatus()->getFreeMemory();
+}
+
+function seed(UserCache\Cache $cache): void {
+ $pad = str_repeat('x', 2000);
+ for ($i = 0; $i < KEYS; $i++) {
+ $cache->store("e:$i", $pad, 1);
+ }
+}
+
+var_dump(UserCache\Cache::getStatus()->getEntryCapacity() <= 4096);
+$baseline = free_memory();
+
+seed($cache);
+sleep(2);
+var_dump(free_memory() < $baseline);
+$pid = pcntl_fork();
+if ($pid === 0) {
+ for ($i = 0; $i < KEYS; $i++) {
+ $cache->fetch("e:$i");
+ }
+ exit(0);
+}
+pcntl_waitpid($pid, $status);
+var_dump(free_memory() === $baseline);
+
+seed($cache);
+sleep(2);
+for ($w = 0; $w < 70; $w++) {
+ $cache->store('w', $w);
+}
+$cache->delete('w');
+var_dump(free_memory() === $baseline);
+?>
+--EXPECT--
+int(0)
+int(2)
+array(1) {
+ [0]=>
+ string(6) "parent"
+}
+bool(true)
+int(2)
+string(4) "kept"
+string(8) "mutation"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/tests/user_cache_unrestorable_entry_cleanup_race.phpt b/ext/user_cache/tests/user_cache_unrestorable_entry_cleanup_race.phpt
new file mode 100644
index 000000000000..e14e416b22a5
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_unrestorable_entry_cleanup_race.phpt
@@ -0,0 +1,54 @@
+--TEST--
+UserCache\Cache: cleaning up an entry that cannot be restored never deletes a newer value stored while restoring it
+--SKIPIF--
+
+--EXTENSIONS--
+pcntl
+--ENV--
+USER_CACHE_DEBUG_FORCE_LOCKED_FETCH=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+--FILE--
+clear();
+
+$pid = pcntl_fork();
+if ($pid < 0) die('fork failed');
+if ($pid === 0) {
+ eval('final class OnlyInChild { public int $value = 1; }');
+ exit($cache->store('single', new OnlyInChild()) && $cache->store('multiple', new OnlyInChild()) ? 0 : 1);
+}
+pcntl_waitpid($pid, $status);
+var_dump(pcntl_wexitstatus($status));
+
+$replacing = null;
+spl_autoload_register(function (string $class) use ($cache, &$replacing) {
+ if ($replacing !== null) {
+ var_dump($cache->store($replacing, "fresh $replacing"));
+ $replacing = null;
+ }
+});
+
+$replacing = 'single';
+var_dump($cache->fetch('single', 'MISS'));
+var_dump($cache->fetch('single', 'GONE'));
+
+$replacing = 'multiple';
+var_dump($cache->fetchMultiple(['multiple'], 'MISS'));
+var_dump($cache->fetch('multiple', 'GONE'));
+?>
+--EXPECT--
+int(0)
+bool(true)
+string(4) "MISS"
+string(12) "fresh single"
+bool(true)
+array(1) {
+ ["multiple"]=>
+ string(4) "MISS"
+}
+string(14) "fresh multiple"
diff --git a/ext/user_cache/tests/user_cache_unstorable_values.phpt b/ext/user_cache/tests/user_cache_unstorable_values.phpt
new file mode 100644
index 000000000000..354132934699
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_unstorable_values.phpt
@@ -0,0 +1,300 @@
+--TEST--
+UserCache\Cache: opaque, lazy, magic-exposed, hook-inserted and uninitialized values are refused without a partial store
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=16M
+date.timezone=UTC
+error_reporting=E_ALL & ~E_DEPRECATED
+--FILE--
+store($key, $value) ? 'stored' : 'REFUSED';
+ } catch (TypeError $e) {
+ $outcome = 'TypeError' . ($cache->has($key) ? ' (partial store)' : '');
+ }
+ echo $label, ': ', $outcome, "\n";
+}
+
+$resource = fopen(__FILE__, 'r');
+$closure = static fn () => true;
+
+attempt($cache, 'resource', $resource);
+attempt($cache, 'closure', $closure);
+attempt($cache, 'nested resource', ['value' => $resource]);
+attempt($cache, 'nested closure', ['value' => $closure]);
+attempt($cache, 'object resource', new UserCacheUnsupportedBox($resource));
+attempt($cache, 'object closure', new UserCacheUnsupportedBox($closure));
+attempt($cache, 'fixed resource', SplFixedArray::fromArray([$resource], false));
+attempt($cache, 'fixed closure', SplFixedArray::fromArray([$closure], false));
+attempt($cache, 'array resource', new ArrayObject(['value' => $resource]));
+attempt($cache, 'array closure', new ArrayObject(['value' => $closure]));
+
+$fiber = new Fiber(function () { Fiber::suspend(); });
+$fiber->start();
+attempt($cache, 'Fiber', $fiber);
+attempt($cache, 'Generator', (function () { yield 1; })());
+attempt($cache, 'WeakMap', (function () { $m = new WeakMap(); $m[new stdClass()] = 1; return $m; })());
+
+fclose($resource);
+
+/* Lazy objects, opaque internal objects and WeakReference report exact messages */
+$cache = UserCache\Cache::getPool('unstorable-lazy');
+
+class LazyTarget
+{
+ public int $x = 1;
+}
+
+$reflector = new ReflectionClass(LazyTarget::class);
+
+$values = [
+ 'ghost' => $reflector->newLazyGhost(function ($object) { $object->x = 2; }),
+ 'proxy' => $reflector->newLazyProxy(fn() => new LazyTarget()),
+ 'nested-ghost' => ['inner' => $reflector->newLazyGhost(function ($object) { $object->x = 2; })],
+ 'not-serializable' => UserCache\Cache::getStatus(),
+ 'weakref' => WeakReference::create(new LazyTarget()),
+ 'nested-weakref' => ['inner' => WeakReference::create(new LazyTarget())],
+];
+
+foreach ($values as $key => $value) {
+ try {
+ $cache->store($key, $value);
+ } catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+ }
+ var_dump($cache->has($key));
+}
+
+/* Unstorable values exposed through __serialize() and __sleep() */
+$cache = UserCache\Cache::getPool('unstorable-magic');
+
+class UserCacheSerializeResource
+{
+ public $value;
+
+ public function __serialize(): array
+ {
+ return ['value' => $this->value];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->value = $data['value'] ?? null;
+ }
+}
+
+class UserCacheSleepResource
+{
+ public $value;
+
+ public function __sleep(): array
+ {
+ return ['value'];
+ }
+
+ public function __wakeup(): void
+ {
+ }
+}
+
+function show(string $label, callable $callback): void
+{
+ try {
+ var_dump($callback());
+ } catch (TypeError $e) {
+ echo $label, ": TypeError\n";
+ }
+}
+
+$serializeResource = new UserCacheSerializeResource();
+$serializeResource->value = fopen(__FILE__, 'r');
+show('serialize-resource', fn() => $cache->store('serialize-resource', $serializeResource));
+fclose($serializeResource->value);
+
+$serializeClosure = new UserCacheSerializeResource();
+$serializeClosure->value = static fn() => null;
+show('serialize-closure', fn() => $cache->store('serialize-closure', $serializeClosure));
+
+$sleepResource = new UserCacheSleepResource();
+$sleepResource->value = fopen(__FILE__, 'r');
+show('sleep-resource', fn() => $cache->store('sleep-resource', $sleepResource));
+fclose($sleepResource->value);
+
+/* Uninitialized date objects throw DateObjectError like serialize() */
+$cache = UserCache\Cache::getPool('unstorable-date');
+
+foreach ([DateTime::class, DateTimeZone::class, DateInterval::class, DatePeriod::class] as $class) {
+ $object = (new ReflectionClass($class))->newInstanceWithoutConstructor();
+
+ try {
+ $result = $cache->store('date', $object);
+
+ echo $class, ': stored, returned ', var_export($result, true), "\n";
+ } catch (Error $e) {
+ echo $class, ': ', get_class($e), ', message ',
+ str_contains($e->getMessage(), 'has not been correctly initialized') ? 'OK' : $e->getMessage(),
+ "\n";
+ }
+}
+
+var_dump($cache->has('date'));
+var_dump($cache->store('scalar', 7));
+var_dump($cache->fetch('scalar'));
+
+class Holder
+{
+ public array $list = [];
+ public $hook;
+}
+
+class Inserts
+{
+ public static Holder $holder;
+ public static Closure $factory;
+
+ public function __serialize(): array
+ {
+ self::$holder->list[] = (self::$factory)();
+
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+}
+
+function store_hook_inserts_unstorable(): void
+{
+ $cache = UserCache\Cache::getPool('store-hook-inserts-unstorable');
+ $factories = [
+ 'closure' => fn () => function () {},
+ 'resource' => fn () => fopen('php://memory', 'r'),
+ 'generator' => fn () => (function () { yield 1; })(),
+ 'anonymous class' => fn () => new class {},
+ 'WeakReference' => fn () => WeakReference::create(new stdClass),
+ ];
+ foreach ($factories as $name => $factory) {
+ $holder = new Holder;
+ $holder->hook = new Inserts;
+ Inserts::$holder = $holder;
+ Inserts::$factory = $factory;
+ foreach (['inserted by a hook' => $holder, 'stored directly' => [$factory()]] as $how => $value) {
+ try {
+ var_dump($cache->store($name, $value));
+ } catch (TypeError $e) {
+ echo "$name $how: ", $e->getMessage(), "\n";
+ }
+ }
+ var_dump($cache->has($name));
+ }
+
+ /* Release builds do not collect cycles at shutdown. */
+ $holder = $value = $e = null;
+ gc_collect_cycles();
+}
+
+echo "\nstore hook inserts unstorable:\n";
+store_hook_inserts_unstorable();
+
+echo "\nroot resource and Closure through every storing method:\n";
+$cache = UserCache\Cache::getPool('unstorable-root-messages');
+$resource = fopen(__FILE__, 'r');
+foreach (['resource' => $resource, 'Closure' => static fn () => true] as $label => $value) {
+ $calls = [
+ 'store' => fn () => $cache->store('root', $value),
+ 'add' => fn () => $cache->add('root', $value),
+ 'storeMultiple' => fn () => $cache->storeMultiple(['root' => $value]),
+ 'remember' => fn () => $cache->remember('root', fn () => $value),
+ ];
+ foreach ($calls as $method => $call) {
+ try {
+ var_dump($call());
+ } catch (TypeError $e) {
+ echo "$label $method: ", $e->getMessage(), "\n";
+ }
+ }
+}
+var_dump($cache->has('root'));
+fclose($resource);
+?>
+--EXPECT--
+resource: TypeError
+closure: TypeError
+nested resource: TypeError
+nested closure: TypeError
+object resource: TypeError
+object closure: TypeError
+fixed resource: TypeError
+fixed closure: TypeError
+array resource: TypeError
+array closure: TypeError
+Fiber: TypeError
+Generator: TypeError
+WeakMap: TypeError
+Uninitialized lazy objects cannot be stored in the user cache
+bool(false)
+Uninitialized lazy objects cannot be stored in the user cache
+bool(false)
+Uninitialized lazy objects cannot be stored in the user cache
+bool(false)
+UserCache\CacheStatus objects cannot be stored in the user cache
+bool(false)
+WeakReference objects cannot be stored in the user cache
+bool(false)
+WeakReference objects cannot be stored in the user cache
+bool(false)
+serialize-resource: TypeError
+serialize-closure: TypeError
+sleep-resource: TypeError
+DateTime: DateObjectError, message OK
+DateTimeZone: DateObjectError, message OK
+DateInterval: DateObjectError, message OK
+DatePeriod: DateObjectError, message OK
+bool(false)
+bool(true)
+int(7)
+
+store hook inserts unstorable:
+closure inserted by a hook: Closure objects cannot be stored in the user cache
+closure stored directly: Closure objects cannot be stored in the user cache
+bool(false)
+resource inserted by a hook: Resources cannot be stored in the user cache
+resource stored directly: Resources cannot be stored in the user cache
+bool(false)
+generator inserted by a hook: Generator objects cannot be stored in the user cache
+generator stored directly: Generator objects cannot be stored in the user cache
+bool(false)
+anonymous class inserted by a hook: class@anonymous objects cannot be stored in the user cache
+anonymous class stored directly: class@anonymous objects cannot be stored in the user cache
+bool(false)
+WeakReference inserted by a hook: WeakReference objects cannot be stored in the user cache
+WeakReference stored directly: WeakReference objects cannot be stored in the user cache
+bool(false)
+
+root resource and Closure through every storing method:
+resource store: Resources cannot be stored in the user cache
+resource add: Resources cannot be stored in the user cache
+resource storeMultiple: Resources cannot be stored in the user cache
+resource remember: Resources cannot be stored in the user cache
+Closure store: Closure objects cannot be stored in the user cache
+Closure add: Closure objects cannot be stored in the user cache
+Closure storeMultiple: Closure objects cannot be stored in the user cache
+Closure remember: Closure objects cannot be stored in the user cache
+bool(false)
diff --git a/ext/user_cache/tests/user_cache_value_matrix.phpt b/ext/user_cache/tests/user_cache_value_matrix.phpt
new file mode 100644
index 000000000000..dd831a2825ed
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_value_matrix.phpt
@@ -0,0 +1,370 @@
+--TEST--
+UserCache\Cache: every serializable value class round-trips with identity, readonly state and float bits intact
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=32M
+--FILE--
+store($key, $value)) {
+ echo $label, ": STORE FAILED\n";
+ return;
+ }
+
+ $fetched = $cache->fetch($key);
+ $ok = $verify !== null
+ ? $verify($value, $fetched)
+ : serialize($fetched) === serialize($value);
+ echo $label, ': ', $ok ? 'OK' : 'MISMATCH', "\n";
+
+ /* Release builds do not collect cycles at shutdown. */
+ unset($fetched);
+ gc_collect_cycles();
+}
+
+check($cache, 'null', null);
+check($cache, 'bool', true);
+check($cache, 'int', PHP_INT_MAX);
+check($cache, 'negative int', PHP_INT_MIN);
+check($cache, 'float', 1.5);
+check($cache, 'float INF', INF);
+check($cache, 'float NAN', NAN);
+check($cache, 'empty string', '');
+check($cache, 'binary string', "a\0b\xff\xfe");
+check($cache, 'unicode string', 'こんにちは🌏');
+
+check($cache, 'empty array', []);
+check($cache, 'packed array', range(1, 64));
+check($cache, 'hashed array', ['a' => 1, 'b' => ['c' => 2.5, 'd' => null], 10 => 'x']);
+check($cache, 'binary keys', ["k\0ey" => 1, '0' => 2]);
+$deep = 'leaf';
+for ($d = 0; $d < 32; $d++) {
+ $deep = ['level' => $d, 'inner' => $deep];
+}
+check($cache, 'deep array', $deep);
+$aliased = ['v' => 1];
+$aliased['alias'] = &$aliased['v'];
+check($cache, 'array with references', $aliased);
+$selfref = ['x' => 1];
+$selfref['self'] = &$selfref;
+check($cache, 'self-referencing array', $selfref, static function ($orig, $fetched) {
+ /* Argument copying detaches the outer reference; inspect the cycle directly. */
+ return $fetched['self']['self']['self']['x'] === 1 && is_array($fetched['self']['self']);
+});
+
+check($cache, 'stdClass', (object) ['a' => 1, 'nested' => (object) ['b' => 2]]);
+
+class CoveragePlain
+{
+ public int $pub = 1;
+ protected string $prot = 'p';
+ private array $priv = ['x'];
+}
+check($cache, 'plain class with visibility', new CoveragePlain());
+
+#[\AllowDynamicProperties]
+class CoverageDynamic
+{
+ public int $declared = 1;
+}
+$dynamic = new CoverageDynamic();
+$dynamic->added = 'dynamic';
+check($cache, 'dynamic properties', $dynamic);
+
+class CoverageReadonly
+{
+ public function __construct(public readonly string $name) {}
+}
+check($cache, 'readonly property', new CoverageReadonly('ro'));
+
+class CoverageMagicSerialize
+{
+ public function __construct(private array $state) {}
+ public function __serialize(): array { return ['state' => $this->state]; }
+ public function __unserialize(array $data): void { $this->state = $data['state']; }
+}
+check($cache, '__serialize/__unserialize', new CoverageMagicSerialize(['k' => [1, 2]]));
+
+class CoverageSleep
+{
+ public int $kept = 5;
+ public string $dropped = 'gone';
+ public function __sleep(): array { return ['kept']; }
+ public function __wakeup(): void {}
+}
+check($cache, '__sleep/__wakeup', new CoverageSleep());
+
+$shared = new stdClass();
+$shared->tag = 'shared';
+check($cache, 'shared object identity', [$shared, $shared], static function ($orig, $fetched) {
+ return $fetched[0] === $fetched[1] && $fetched[0]->tag === 'shared';
+});
+
+class CoverageNode
+{
+ public ?CoverageNode $parent = null;
+ public array $children = [];
+ public function __construct(public string $id) {}
+}
+$root = new CoverageNode('root');
+$child = new CoverageNode('child');
+$child->parent = $root;
+$root->children[] = $child;
+check($cache, 'cyclic object graph', $root, static function ($orig, $fetched) {
+ return $fetched->children[0]->parent === $fetched && $fetched->children[0]->id === 'child';
+});
+
+$selfobj = new CoverageNode('self');
+$selfobj->parent = $selfobj;
+check($cache, 'self-referencing object', $selfobj, static function ($orig, $fetched) {
+ return $fetched->parent === $fetched;
+});
+
+enum CoverageSuit: string
+{
+ case Hearts = 'h';
+ case Spades = 's';
+}
+check($cache, 'backed enum case', CoverageSuit::Spades, static function ($orig, $fetched) {
+ return $fetched === CoverageSuit::Spades;
+});
+check($cache, 'enum inside array', ['suit' => CoverageSuit::Hearts], static function ($orig, $fetched) {
+ return $fetched['suit'] === CoverageSuit::Hearts;
+});
+
+check($cache, 'DateTimeImmutable', new DateTimeImmutable('2026-07-02 12:00:00', new DateTimeZone('Asia/Tokyo')));
+check($cache, 'DateInterval', new DateInterval('P1DT2H'));
+check($cache, 'ArrayObject', new ArrayObject(['a' => 1, 'b' => 2]));
+check($cache, 'SplStack', (static function () {
+ $s = new SplStack();
+ $s->push('one');
+ $s->push('two');
+ return $s;
+})());
+check($cache, 'SplFixedArray', SplFixedArray::fromArray([1, 'two', 3.0]));
+check($cache, 'SplObjectStorage', (static function () {
+ $s = new SplObjectStorage();
+ $o = new stdClass();
+ $o->v = 1;
+ $s[$o] = 'data';
+ return $s;
+})(), static function ($orig, $fetched) {
+ if (!$fetched instanceof SplObjectStorage || count($fetched) !== 1) {
+ return false;
+ }
+ $fetched->rewind();
+ return $fetched->current()->v === 1 && $fetched->getInfo() === 'data';
+});
+check($cache, 'Randomizer engine object', new Random\Randomizer(new Random\Engine\Xoshiro256StarStar(1234)), static function ($orig, $fetched) {
+ return $fetched->getInt(0, PHP_INT_MAX) === $orig->getInt(0, PHP_INT_MAX);
+});
+
+check($cache, 'mixed payload', [
+ 'config' => ['ttl' => 300, 'flags' => [true, false]],
+ 'entity' => new CoverageMagicSerialize(['id' => 7]),
+ 'sleeper' => new CoverageSleep(),
+ 'when' => new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC')),
+ 'suit' => CoverageSuit::Hearts,
+], static function ($orig, $fetched) {
+ return $fetched['config']['ttl'] === 300
+ && $fetched['entity'] instanceof CoverageMagicSerialize
+ && $fetched['sleeper'] instanceof CoverageSleep
+ && $fetched['when'] instanceof DateTimeImmutable
+ && $fetched['suit'] === CoverageSuit::Hearts;
+});
+
+/* Release builds do not collect cycles at shutdown. */
+unset($deep, $aliased, $selfref, $dynamic, $shared, $root, $child, $selfobj);
+gc_collect_cycles();
+
+/* Pure enum cases restore as the singleton */
+$cache = UserCache\Cache::getPool('value-matrix-pure-enum');
+
+enum PureSuit
+{
+ case Hearts;
+ case Spades;
+}
+
+var_dump($cache->store('single', PureSuit::Hearts));
+var_dump($cache->fetch('single') === PureSuit::Hearts);
+
+var_dump($cache->store('list', [PureSuit::Hearts, PureSuit::Spades, PureSuit::Hearts]));
+$fetched = $cache->fetch('list');
+var_dump($fetched[0] === PureSuit::Hearts);
+var_dump($fetched[1] === PureSuit::Spades);
+var_dump($fetched[2] === PureSuit::Hearts);
+
+/* Readonly flag is re-established on fetched objects, including inherited properties */
+$cache = UserCache\Cache::getPool('value-matrix-readonly');
+
+class UserCacheReadonlyBase
+{
+ public function __construct(
+ public readonly string $baseName,
+ protected readonly int $baseCount,
+ ) {}
+
+ public function describe(): string
+ {
+ return $this->baseName . ':' . $this->baseCount;
+ }
+}
+
+class UserCacheReadonlyChild extends UserCacheReadonlyBase
+{
+ public function __construct(
+ string $baseName,
+ int $baseCount,
+ public readonly array $items,
+ ) {
+ parent::__construct($baseName, $baseCount);
+ }
+}
+
+var_dump($cache->store('child', new UserCacheReadonlyChild('alpha', 7, ['a', 'b'])));
+
+$fetched = $cache->fetch('child');
+var_dump($fetched instanceof UserCacheReadonlyChild);
+var_dump($fetched->describe());
+var_dump($fetched->baseName);
+var_dump($fetched->items);
+
+try {
+ $fetched->baseName = 'changed';
+} catch (Error $e) {
+ echo get_class($e), "\n";
+}
+
+/* Floats round-trip bit-exactly */
+$cache = UserCache\Cache::getPool('value-matrix-floats');
+
+$floats = [
+ 'positive zero' => 0.0,
+ 'negative zero' => -0.0,
+ 'epsilon' => PHP_FLOAT_EPSILON,
+ 'float min' => PHP_FLOAT_MIN,
+ 'float max' => PHP_FLOAT_MAX,
+ 'smallest subnormal' => 4.9e-324,
+ 'one third' => 1 / 3,
+ 'point one plus point two' => 0.1 + 0.2,
+ 'large' => 1.7e308,
+ 'small negative' => -2.2250738585072014e-308,
+];
+
+foreach ($floats as $label => $value) {
+ $cache->store('f', $value);
+ ok($label, pack('d', $cache->fetch('f')) === pack('d', $value));
+}
+
+$cache->store('negzero', -0.0);
+ok('negative zero sign bit', fdiv(1.0, $cache->fetch('negzero')) === -INF);
+
+foreach (['inf' => INF, '-inf' => -INF] as $label => $value) {
+ $cache->store('nf', $value);
+ ok($label, $cache->fetch('nf') === $value);
+}
+$cache->store('nan', NAN);
+ok('nan', is_nan($cache->fetch('nan')));
+
+$graph = [
+ 'array' => [-0.0, PHP_FLOAT_MIN, 4.9e-324],
+ 'object' => (object) ['ratio' => 1 / 3, 'neg' => -0.0],
+];
+$cache->store('graph', $graph);
+$fetched = $cache->fetch('graph');
+$arrayOk = true;
+foreach ($graph['array'] as $index => $value) {
+ $arrayOk = $arrayOk && pack('d', $fetched['array'][$index]) === pack('d', $value);
+}
+ok('floats in array', $arrayOk);
+ok('floats in object', pack('d', $fetched['object']->ratio) === pack('d', 1 / 3)
+ && pack('d', $fetched['object']->neg) === pack('d', -0.0));
+
+$cache->store('all', $floats);
+ok('serialize parity', serialize($cache->fetch('all')) === serialize($floats));
+?>
+--EXPECT--
+null: OK
+bool: OK
+int: OK
+negative int: OK
+float: OK
+float INF: OK
+float NAN: OK
+empty string: OK
+binary string: OK
+unicode string: OK
+empty array: OK
+packed array: OK
+hashed array: OK
+binary keys: OK
+deep array: OK
+array with references: OK
+self-referencing array: OK
+stdClass: OK
+plain class with visibility: OK
+dynamic properties: OK
+readonly property: OK
+__serialize/__unserialize: OK
+__sleep/__wakeup: OK
+shared object identity: OK
+cyclic object graph: OK
+self-referencing object: OK
+backed enum case: OK
+enum inside array: OK
+DateTimeImmutable: OK
+DateInterval: OK
+ArrayObject: OK
+SplStack: OK
+SplFixedArray: OK
+SplObjectStorage: OK
+Randomizer engine object: OK
+mixed payload: OK
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(7) "alpha:7"
+string(5) "alpha"
+array(2) {
+ [0]=>
+ string(1) "a"
+ [1]=>
+ string(1) "b"
+}
+Error
+positive zero: OK
+negative zero: OK
+epsilon: OK
+float min: OK
+float max: OK
+smallest subnormal: OK
+one third: OK
+point one plus point two: OK
+large: OK
+small negative: OK
+negative zero sign bit: OK
+inf: OK
+-inf: OK
+nan: OK
+floats in array: OK
+floats in object: OK
+serialize parity: OK
diff --git a/ext/user_cache/tests/user_cache_win32_commit_failure.phpt b/ext/user_cache/tests/user_cache_win32_commit_failure.phpt
new file mode 100644
index 000000000000..87183a749d77
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_win32_commit_failure.phpt
@@ -0,0 +1,32 @@
+--TEST--
+UserCache\Cache: on Windows a commit that fails while the data grows is counted and makes stores behave as if the cache were full; the cache stays available
+--SKIPIF--
+
+--ENV--
+USER_CACHE_DEBUG_FAIL_WIN32_COMMIT=1
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=64M
+user_cache.entries_hint=1024
+user_cache.eviction_policy=none
+--FILE--
+store('small', 'fits in the committed prefix chunk'));
+var_dump($cache->store('big', str_repeat('b', 8 << 20)), $cache->has('big'));
+$status = UserCache\Cache::getStatus();
+var_dump($status->getAvailability()->name, $status->getCommitFailureCount() > 0, $status->getCommittedMemory() < $status->getSharedMemorySize());
+var_dump($cache->fetch('small'));
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(false)
+string(9) "Available"
+bool(true)
+bool(true)
+string(34) "fits in the committed prefix chunk"
diff --git a/ext/user_cache/tests/user_cache_win32_lazy_commit.phpt b/ext/user_cache/tests/user_cache_win32_lazy_commit.phpt
new file mode 100644
index 000000000000..62754709f2b4
--- /dev/null
+++ b/ext/user_cache/tests/user_cache_win32_lazy_commit.phpt
@@ -0,0 +1,45 @@
+--TEST--
+UserCache\Cache: on Windows the segment reserves user_cache.shm_size and commits memory as the data grows, so a large segment starts with a small commit, and the tail released by a delete is reset and reused intact
+--SKIPIF--
+
+--INI--
+user_cache.enable=1
+user_cache.enable_cli=1
+user_cache.shm_size=2G
+user_cache.entries_hint=1024
+--FILE--
+store('small', str_repeat('s', 1000)));
+$status = UserCache\Cache::getStatus();
+var_dump($status->getAvailability()->name, $status->getSharedMemorySize() === 2 << 30);
+var_dump($status->getCommittedMemory() > 0 && $status->getCommittedMemory() < 64 << 20, $status->getCommitFailureCount());
+
+$big = str_repeat('b', 32 << 20);
+var_dump($cache->store('big', $big), $cache->fetch('big') === $big);
+var_dump(UserCache\Cache::getStatus()->getCommittedMemory() >= 32 << 20);
+
+echo "reset tail:\n";
+$released = str_repeat('r', 16 << 20);
+var_dump($cache->store('released', $released), $cache->delete('released'));
+$reused = str_repeat('u', 16 << 20);
+var_dump($cache->store('reused', $reused), $cache->fetch('reused') === $reused, $cache->fetch('big') === $big);
+?>
+--EXPECT--
+bool(true)
+string(9) "Available"
+bool(true)
+bool(true)
+int(0)
+bool(true)
+bool(true)
+bool(true)
+reset tail:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/user_cache/user_cache.c b/ext/user_cache/user_cache.c
new file mode 100644
index 000000000000..dc20030dede3
--- /dev/null
+++ b/ext/user_cache/user_cache.c
@@ -0,0 +1,3399 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Go Kudo |
+ +----------------------------------------------------------------------+
+ */
+
+#include "user_cache_internal.h"
+#include "user_cache_arginfo.h"
+
+#include "Zend/zend_closures.h"
+#include "Zend/zend_fibers.h"
+
+#include "ext/standard/info.h"
+
+#define UCACHE_KEY_RECORD_LIMIT_MIN 16384U
+#define UCACHE_KEY_RECORD_TABLE_BYTES (sizeof(Bucket) + 2 * sizeof(uint32_t))
+
+#define UCACHE_DEFINE_SAFE_DIRECT_HANDLER_GETTER(member) \
+ php_ucache_safe_direct_##member##_func_t ucache_safe_direct_##member##_func( \
+ zend_class_entry *ce) \
+ { \
+ const php_ucache_safe_direct_handlers *handlers = \
+ ucache_safe_direct_find_handlers(ce, NULL) \
+ ; \
+ return handlers != NULL ? handlers->member : NULL; \
+ }
+
+#define UCACHE_METHOD_BODY(name) \
+ static void ZEND_FASTCALL ucache_method_##name(INTERNAL_FUNCTION_PARAMETERS)
+
+#define UCACHE_METHOD_ENTRY(name) \
+ ZEND_METHOD(UserCache_Cache, name) \
+ { \
+ bool tracked = UC_G(persistent_exec); \
+ if (UNEXPECTED(tracked)) { \
+ UC_G(op_depth)++; \
+ } \
+ ucache_method_##name(INTERNAL_FUNCTION_PARAM_PASSTHRU); \
+ if (UNEXPECTED(tracked) && UC_G(op_depth) != 0) { \
+ UC_G(op_depth)--; \
+ } \
+ }
+
+#define UCACHE_REQ_HANDLER(key, method) \
+ { key, sizeof(key) - 1, ucache_method_##method }
+
+typedef struct _ucache_held_records {
+ struct _ucache_held_records *prev;
+ struct _ucache_held_records *next;
+ ucache_key_record **records;
+ uint32_t count;
+} ucache_held_records;
+
+typedef struct {
+ zval val;
+ ucache_key_record *record;
+ ucache_prepared_val prepared;
+ ucache_store_result store_result;
+} ucache_bulk_store_item;
+
+static zend_never_inline void ucache_key_records_trim(void);
+static zend_never_inline ucache_obj *ucache_obj_uninitialized(zval *this_ptr);
+static zend_result ucache_post_deactivate(void);
+static ZEND_INI_MH(OnUpdateUserCacheShmSize);
+static ZEND_INI_MH(OnUpdateUserCacheEvictionPolicy);
+static ZEND_INI_MH(OnUpdateUserCacheLockfilePath);
+static ZEND_INI_MH(OnUpdateUserCachePreferredMemoryModel);
+static ZEND_INI_MH(OnUpdateUserCacheEntriesHint);
+static ZEND_INI_MH(OnUpdateUserCacheLockLeaseMax);
+static PHP_MINIT_FUNCTION(user_cache);
+static PHP_MSHUTDOWN_FUNCTION(user_cache);
+static PHP_RINIT_FUNCTION(user_cache);
+static PHP_RSHUTDOWN_FUNCTION(user_cache);
+static PHP_MINFO_FUNCTION(user_cache);
+
+#ifdef ZTS
+int user_cache_globals_id;
+#endif
+static HashTable ucache_safe_direct_handler_table;
+static bool ucache_safe_direct_handlers_initialized = false;
+static zend_class_entry *ucache_ce;
+static zend_class_entry *ucache_status_ce;
+static zend_class_entry *ucache_pool_status_ce;
+static zend_object_handlers ucache_obj_handlers;
+#ifndef ZEND_WIN32
+static bool ucache_self_pid_uncached = false;
+static bool ucache_pid_atfork_registered = false;
+#endif
+#if defined(ZTS) && !defined(ZEND_WIN32)
+static bool ucache_zts_atfork_registered = false;
+#endif
+
+PHP_INI_BEGIN()
+ STD_PHP_INI_ENTRY("user_cache.enable", "1", PHP_INI_SYSTEM, OnUpdateBool, enable, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.enable_cli", "0", PHP_INI_SYSTEM, OnUpdateBool, enable_cli, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.shm_size", "16M", PHP_INI_SYSTEM, OnUpdateUserCacheShmSize, shm_size, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.entries_hint", "0", PHP_INI_SYSTEM, OnUpdateUserCacheEntriesHint, entries_hint, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.eviction_policy", "lru", PHP_INI_SYSTEM, OnUpdateUserCacheEvictionPolicy, eviction_policy, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.lockfile_path", "/tmp", PHP_INI_SYSTEM, OnUpdateUserCacheLockfilePath, lockfile_path, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.preferred_memory_model", "", PHP_INI_SYSTEM, OnUpdateUserCachePreferredMemoryModel, mem_model, ucache_globals, user_cache_globals)
+ STD_PHP_INI_ENTRY("user_cache.lock_lease_max", "3600", PHP_INI_SYSTEM, OnUpdateUserCacheLockLeaseMax, lock_lease_max, ucache_globals, user_cache_globals)
+PHP_INI_END()
+
+#ifndef ZTS
+ucache_globals user_cache_globals;
+#else
+size_t user_cache_globals_offset;
+#endif
+uint64_t ucache_self_pid = 0;
+
+ucache_ctx ucache_ctx_state = {
+ .storage = { .lock_file = -1 },
+ .lock_name = "php_user_cache_lock",
+};
+
+bool ucache_runtime_opted_in = false;
+zend_class_entry *ucache_availability_ce;
+zend_object_handlers ucache_status_obj_handlers;
+zend_object_handlers ucache_pool_status_obj_handlers;
+php_ucache_mode ucache_registered_mode = PHP_UCACHE_MODE_REQ;
+atomic_bool ucache_registration_closed = false;
+
+zend_module_entry user_cache_module_entry = {
+ STANDARD_MODULE_HEADER,
+ "user_cache",
+ NULL,
+ PHP_MINIT(user_cache),
+ PHP_MSHUTDOWN(user_cache),
+ PHP_RINIT(user_cache),
+ PHP_RSHUTDOWN(user_cache),
+ PHP_MINFO(user_cache),
+ PHP_VERSION,
+ NO_MODULE_GLOBALS,
+ ucache_post_deactivate,
+ STANDARD_MODULE_PROPERTIES_EX
+};
+
+UCACHE_DEFINE_OBJ_FROM_STD(ucache_obj, obj)
+
+static zend_always_inline ucache_obj *ucache_obj_from_this(zval *this_ptr)
+{
+ ucache_obj *cache = ucache_obj_from_obj(Z_OBJ_P(this_ptr));
+
+ ZEND_ASSERT(cache->ctx == ucache_active_ctx());
+
+ if (UNEXPECTED(cache->scope_prefix == NULL)) {
+ return ucache_obj_uninitialized(this_ptr);
+ }
+
+ if (UNEXPECTED(UC_G(key_records_trim_due))) {
+ ucache_key_records_trim();
+
+ if (UNEXPECTED(EG(exception))) {
+ return NULL;
+ }
+ }
+
+ return cache;
+}
+
+static zend_always_inline bool ucache_runtime_is_resolved(void)
+{
+ return UC_G(runtime_resolved) &&
+ UC_G(runtime_resolved_ctx) == ucache_active_ctx() &&
+ UC_G(runtime_resolved_enabled) == UC_G(enable)
+ ;
+}
+
+static zend_always_inline void ucache_ensure_ready(void)
+{
+ if (ucache_runtime_is_resolved()) {
+ return;
+ }
+
+ ucache_ensure_ready_impl();
+}
+
+static zend_always_inline uint32_t ucache_key_record_limit(void)
+{
+ const ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (!storage->layout_memo_valid) {
+ return UCACHE_KEY_RECORD_LIMIT_MIN;
+ }
+
+ return MAX(UCACHE_KEY_RECORD_LIMIT_MIN, storage->capacity_memo);
+}
+
+static zend_always_inline size_t ucache_key_record_footprint(const ucache_key_record *record)
+{
+ size_t storage_key_bytes = _ZSTR_STRUCT_SIZE(ZSTR_LEN(record->storage_key));
+ size_t lookup_key_bytes_max = storage_key_bytes;
+
+ return sizeof(*record) + UCACHE_KEY_RECORD_TABLE_BYTES + storage_key_bytes + lookup_key_bytes_max;
+}
+
+static zend_always_inline bool ucache_key_records_within(uint32_t limit, size_t budget)
+{
+ return UC_G(key_record_count) < limit && UC_G(key_record_bytes) < budget;
+}
+
+static zend_always_inline ucache_obj *ucache_frame_pool(const zend_execute_data *ex)
+{
+ if (ex->func == NULL || ex->func->common.scope != ucache_ce || Z_TYPE(ex->This) != IS_OBJECT) {
+ return NULL;
+ }
+
+ return ucache_obj_from_obj(Z_OBJ(ex->This));
+}
+
+static zend_always_inline uint64_t ucache_cur_pid(void)
+{
+#ifdef ZEND_WIN32
+ return (uint64_t) GetCurrentProcessId();
+#else
+ return (uint64_t) getpid();
+#endif
+}
+
+static zend_always_inline size_t ucache_key_len_max(const ucache_obj *cache)
+{
+ return cache->key_len_max;
+}
+
+static zend_always_inline bool ucache_validate_root_val(zval *val)
+{
+ ZVAL_DEREF(val);
+
+ if (Z_TYPE_P(val) == IS_RESOURCE) {
+ ucache_throw_unstorable_res();
+
+ return false;
+ }
+
+ if (Z_TYPE_P(val) == IS_OBJECT && Z_OBJCE_P(val) == zend_ce_closure) {
+ ucache_throw_unstorable_obj(Z_OBJCE_P(val));
+
+ return false;
+ }
+
+ return true;
+}
+
+static zend_always_inline zend_long ucache_clamp_lock_lease(zend_long lease)
+{
+ zend_long lease_max = UC_G(lock_lease_max);
+
+ return lease_max > 0 && lease > lease_max
+ ? lease_max
+ : lease
+ ;
+}
+
+#ifndef ZEND_WIN32
+static void ucache_pid_atfork_child(void)
+{
+ ucache_self_pid = 0;
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ ucache_shared_boundary_segs_after_fork();
+#endif
+}
+#endif
+
+#if defined(ZTS) && !defined(ZEND_WIN32)
+static void ucache_zts_atfork_prepare(void)
+{
+ ucache_boundary_partitions_lock();
+ ucache_lock_storage_startup_before_fork();
+}
+
+static void ucache_zts_atfork_release(void)
+{
+ ucache_unlock_storage_startup_after_fork();
+ ucache_boundary_partitions_unlock();
+}
+
+static void ucache_zts_atfork_child(void)
+{
+ ucache_zts_atfork_release();
+
+ ucache_reinit_storage_locks_after_fork();
+}
+#endif
+
+static bool ucache_user_key_is_valid(zend_string *key)
+{
+ return ZSTR_LEN(key) != 0 &&
+ memchr(
+ ZSTR_VAL(key),
+ UCACHE_KEY_DELIM_CHAR,
+ ZSTR_LEN(key)
+ ) == NULL
+ ;
+}
+
+static bool ucache_validate_delim_free(zend_string *str, const char *empty_err)
+{
+ if (ZSTR_LEN(str) == 0) {
+ zend_argument_value_error(1, "%s", empty_err);
+
+ return false;
+ }
+
+ if (memchr(ZSTR_VAL(str), UCACHE_KEY_DELIM_CHAR, ZSTR_LEN(str)) != NULL) {
+ zend_argument_value_error(1, "must not contain the user-cache key delimiter " UCACHE_KEY_DELIM_NAME);
+
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_validate_pool_name(zend_string *pool)
+{
+ if (!ucache_validate_delim_free(pool, "must not be empty")) {
+ return false;
+ }
+
+ if (ZSTR_LEN(pool) > UCACHE_POOL_NAME_MAX) {
+ zend_argument_value_error(1, "must not be longer than %d bytes", (int) UCACHE_POOL_NAME_MAX);
+
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_validate_store_arr(HashTable *vals, size_t key_len_max)
+{
+ zend_string *key;
+ zval *val;
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(vals, key, val) {
+ if (key != NULL && !ucache_user_key_is_valid(key)) {
+ zend_argument_value_error(1, "must be an array with non-empty string or int keys that do not contain " UCACHE_KEY_DELIM_NAME);
+
+ return false;
+ }
+
+ if (key != NULL && ZSTR_LEN(key) > key_len_max) {
+ zend_argument_value_error(1, "must be an array whose keys are not longer than %zu bytes", key_len_max);
+
+ return false;
+ }
+
+ if (!ucache_validate_root_val(val)) {
+ return false;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return true;
+}
+
+static zend_string *ucache_build_scope_prefix(zend_string *scope)
+{
+ return zend_string_concat2(
+ ZSTR_VAL(scope),
+ ZSTR_LEN(scope),
+ ZEND_STRL(UCACHE_KEY_DELIM)
+ );
+}
+
+static void ucache_reset_class_entries(void)
+{
+ ucache_availability_ce = NULL;
+ ucache_status_ce = NULL;
+ ucache_pool_status_ce = NULL;
+ ucache_ce = NULL;
+}
+
+static bool ucache_validate_non_negative(zend_long val, uint32_t arg_num)
+{
+ if (val < 0) {
+ zend_argument_value_error(arg_num, "must be greater than or equal to 0");
+
+ return false;
+ }
+
+ return true;
+}
+
+static zend_never_inline bool ucache_available_after_resolving(void)
+{
+ ucache_ensure_ready_impl();
+
+ return ucache_active_runtime()->available && !EG(exception);
+}
+
+static PHP_UCACHE_HOT bool ucache_available(void)
+{
+ if (EXPECTED(ucache_runtime_is_resolved())) {
+ return ucache_active_runtime()->available;
+ }
+
+ return ucache_available_after_resolving();
+}
+
+static bool ucache_begin_read(void)
+{
+ if (!ucache_available()) {
+ return false;
+ }
+
+ if (!ucache_rlock()) {
+ return false;
+ }
+
+ if (!ucache_hdr_is_initialized_locked()) {
+ ucache_unlock();
+
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_begin_write(void)
+{
+ if (!ucache_wlock()) {
+ return false;
+ }
+
+ if (!ucache_hdr_init_locked()) {
+ ucache_unlock();
+
+ return false;
+ }
+
+ return true;
+}
+
+static void ucache_forget_deleted_record(ucache_key_record *record, uint64_t epoch)
+{
+ ucache_key_record_deleted(record, epoch);
+
+ ucache_release_req_local_slot(record->storage_key);
+}
+
+static bool ucache_delete_api(ucache_key_record *record)
+{
+ uint64_t epoch;
+
+ if (!ucache_wlock_for_entry_mutation(record->storage_key)) {
+ return false;
+ }
+
+ epoch = ucache_delete_locked(record->storage_key);
+
+ ucache_unlock();
+
+ ucache_forget_deleted_record(record, epoch);
+
+ return true;
+}
+
+static void ucache_delete_unrestorable_entry(
+ ucache_key_record *record,
+ uint64_t gen)
+{
+ uint64_t epoch;
+
+ if (EG(exception) || gen == 0 || !ucache_try_wlock_for_entry_mutation(record->storage_key)) {
+ return;
+ }
+
+ epoch = ucache_delete_gen_locked(record->storage_key, gen);
+
+ ucache_unlock();
+
+ if (epoch != 0) {
+ ucache_forget_deleted_record(record, epoch);
+ }
+}
+
+static void ucache_drop_unrestorable_gen(
+ ucache_key_record *record,
+ uint64_t gen)
+{
+ bool overflowed = UC_G(stack_overflowed);
+
+ UC_G(stack_overflowed) = false;
+
+ if (!overflowed) {
+ ucache_delete_unrestorable_entry(record, gen);
+ }
+}
+
+static zend_never_inline void ucache_drop_unrestorable_entry(ucache_key_record *record)
+{
+ ucache_drop_unrestorable_gen(record, UC_G(unrestorable_gen));
+}
+
+static bool ucache_clear_api(
+ zend_string *scope_prefix)
+{
+ if (!ucache_begin_write()) {
+ return false;
+ }
+
+ if (!ucache_entry_locks_allow_clear_locked(scope_prefix)) {
+ ucache_unlock();
+
+ return false;
+ }
+
+ ucache_delete_by_prefix_locked(scope_prefix);
+
+ ucache_unlock();
+
+ ucache_release_active_req_local_slots_by_prefix(scope_prefix);
+
+ return true;
+}
+
+static zend_never_inline bool ucache_fetch_if_present_fallback(
+ ucache_key_record *record,
+ zval *return_value)
+{
+ ucache_fetch_pending_seed pending_seed;
+ bool fetched, entry_found = false, lock_held = true;
+
+ if (EG(exception)) {
+ UC_G(stack_overflowed) = false;
+
+ return false;
+ }
+
+ if (!ucache_begin_read()) {
+ return false;
+ }
+
+ UC_G(stack_overflowed) = false;
+
+ fetched = ucache_fetch_locked(
+ record,
+ return_value,
+ &entry_found,
+ &pending_seed,
+ &lock_held
+ );
+
+ if (lock_held) {
+ ucache_unlock();
+ }
+
+ ucache_fetch_finish(record, &pending_seed, return_value);
+
+ if (fetched) {
+ return true;
+ }
+
+ if (!entry_found) {
+ return false;
+ }
+
+ ucache_drop_unrestorable_gen(record, pending_seed.gen);
+
+ return false;
+}
+
+static PHP_UCACHE_HOT bool ucache_fetch_if_present_api(
+ ucache_key_record *record,
+ zval *return_value)
+{
+ ucache_optimistic_result result;
+
+ if (!ucache_available()) {
+ return false;
+ }
+
+ UC_G(stack_overflowed) = false;
+
+ result = ucache_fetch_optimistic(record, return_value, true);
+ if (EXPECTED(result == UCACHE_OPTIMISTIC_FOUND)) {
+ return true;
+ }
+
+ if (result == UCACHE_OPTIMISTIC_MISS) {
+ return false;
+ }
+
+ if (result == UCACHE_OPTIMISTIC_UNRESTORABLE) {
+ ucache_drop_unrestorable_entry(record);
+
+ return false;
+ }
+
+ return ucache_fetch_if_present_fallback(record, return_value);
+}
+
+static void ucache_fetch_api(
+ ucache_key_record *record,
+ zval *default_val,
+ zval *return_value)
+{
+ if (!ucache_fetch_if_present_api(record, return_value)) {
+ if (default_val != NULL) {
+ ZVAL_COPY(return_value, default_val);
+ } else {
+ ZVAL_NULL(return_value);
+ }
+ }
+}
+
+static void ucache_fetch_multiple_fetch_one(
+ ucache_key_record *record,
+ zval *default_val,
+ bool *rlock_held,
+ zval *out,
+ ucache_fetch_pending_seed *pending_seed)
+{
+ bool fetched, found, lock_held = true;
+
+ pending_seed->should_seed = false;
+
+ if (!*rlock_held) {
+ ucache_fetch_api(record, default_val, out);
+
+ return;
+ }
+
+ UC_G(stack_overflowed) = false;
+
+ fetched = ucache_fetch_locked(record, out, &found, pending_seed, &lock_held);
+
+ *rlock_held = lock_held;
+
+ if (fetched) {
+ return;
+ }
+
+ if (!found) {
+ ZVAL_COPY(out, default_val);
+
+ return;
+ }
+
+ if (lock_held) {
+ ucache_unlock();
+
+ *rlock_held = false;
+ }
+
+ ucache_drop_unrestorable_gen(record, pending_seed->gen);
+
+ if (EG(exception)) {
+ ZVAL_UNDEF(out);
+ } else {
+ ZVAL_COPY(out, default_val);
+ }
+}
+
+static void ucache_key_records_schedule_trim(uint32_t limit, size_t budget)
+{
+ if (ucache_key_records_within(limit, budget)) {
+ UC_G(key_record_trim_at) = limit;
+ UC_G(key_record_bytes_trim_at) = budget;
+
+ return;
+ }
+
+ UC_G(key_record_trim_at) = UC_G(key_record_count) + limit / 2;
+ UC_G(key_record_bytes_trim_at) = UC_G(key_record_bytes) + budget / 2;
+}
+
+static void ucache_key_record_free(ucache_key_record *record)
+{
+ UC_G(key_record_bytes) -= ucache_key_record_footprint(record);
+
+ ucache_key_record_reset(record, NULL);
+ zend_string_release(record->storage_key);
+
+ efree(record);
+
+ UC_G(key_record_count)--;
+}
+
+static void ucache_key_record_dtor(zval *zv)
+{
+ ucache_key_record_free(Z_PTR_P(zv));
+}
+
+static PHP_UCACHE_HOT ucache_key_record *ucache_key_record_find(
+ ucache_obj *cache,
+ zend_string *key)
+{
+ uint32_t i;
+
+ if (cache->key_records != NULL) {
+ return zend_hash_find_ptr(cache->key_records, key);
+ }
+
+ for (i = 0; i < cache->inline_count; i++) {
+ if (zend_string_equals(cache->inline_keys[i], key)) {
+ return cache->inline_records[i];
+ }
+ }
+
+ return NULL;
+}
+
+static ucache_key_record *ucache_key_record_create(
+ ucache_obj *cache,
+ zend_string *key)
+{
+ ucache_key_record *record = emalloc(sizeof(*record));
+ uint32_t i;
+
+ record->storage_key = zend_string_concat2(
+ ZSTR_VAL(cache->scope_prefix),
+ ZSTR_LEN(cache->scope_prefix),
+ ZSTR_VAL(key),
+ ZSTR_LEN(key)
+ );
+ record->mutation_epoch = 0;
+ record->slot_idx = 0;
+ record->pinned_payload_offset = 0;
+ record->expires_at = 0;
+ record->state = UCACHE_RECORD_EMPTY;
+ record->val_kind = UCACHE_RECORD_VAL_NONE;
+ record->access_touches = 0;
+
+ ZVAL_UNDEF(&record->val);
+
+ zend_string_hash_val(record->storage_key);
+
+ UC_G(key_record_count)++;
+ UC_G(key_record_bytes) += ucache_key_record_footprint(record);
+
+ if (UNEXPECTED(UC_G(key_record_trim_at) == 0)) {
+ ucache_key_records_schedule_trim(ucache_key_record_limit(), ucache_req_cache_budget());
+ }
+
+ if (UNEXPECTED(UC_G(key_record_count) >= UC_G(key_record_trim_at)) ||
+ UNEXPECTED(UC_G(key_record_bytes) >= UC_G(key_record_bytes_trim_at))
+ ) {
+ UC_G(key_records_trim_due) = true;
+ }
+
+ if (cache->key_records == NULL) {
+ if (cache->inline_count < UCACHE_INLINE_KEY_RECORDS) {
+ cache->inline_keys[cache->inline_count] = zend_string_copy(key);
+ cache->inline_records[cache->inline_count++] = record;
+
+ return record;
+ }
+
+ ALLOC_HASHTABLE(cache->key_records);
+
+ zend_hash_init(cache->key_records, UCACHE_INLINE_KEY_RECORDS * 2, NULL, ucache_key_record_dtor, 0);
+
+ for (i = 0; i < cache->inline_count; i++) {
+ zend_hash_add_new_ptr(cache->key_records, cache->inline_keys[i], cache->inline_records[i]);
+ zend_string_release(cache->inline_keys[i]);
+ }
+
+ cache->inline_count = 0;
+ }
+
+ zend_hash_add_new_ptr(cache->key_records, key, record);
+
+ return record;
+}
+
+static ucache_key_record *ucache_key_record_lookup(
+ ucache_obj *cache,
+ zend_string *key)
+{
+ ucache_key_record *record = ucache_key_record_find(cache, key);
+
+ return record != NULL ? record : ucache_key_record_create(cache, key);
+}
+
+static void ucache_key_record_reset_deferred(ucache_key_record *record, zval *released)
+{
+ zval detached;
+
+ ZVAL_UNDEF(&detached);
+
+ ucache_key_record_reset(record, &detached);
+
+ if (Z_ISUNDEF(detached)) {
+ return;
+ }
+
+ if (Z_ISUNDEF_P(released)) {
+ array_init(released);
+ }
+
+ add_next_index_zval(released, &detached);
+}
+
+static void ucache_key_records_reset(ucache_obj *cache)
+{
+ ucache_key_record *record;
+ zval released;
+ uint32_t i;
+
+ ZVAL_UNDEF(&released);
+
+ if (cache->key_records == NULL) {
+ for (i = 0; i < cache->inline_count; i++) {
+ ucache_key_record_reset_deferred(cache->inline_records[i], &released);
+ }
+ } else {
+ ZEND_HASH_FOREACH_PTR(cache->key_records, record) {
+ ucache_key_record_reset_deferred(record, &released);
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ zval_ptr_dtor(&released);
+}
+
+static void ucache_key_records_free(ucache_obj *cache)
+{
+ ucache_key_record *inline_records[UCACHE_INLINE_KEY_RECORDS];
+ zend_string *inline_keys[UCACHE_INLINE_KEY_RECORDS];
+ HashTable *records = cache->key_records;
+ uint32_t i, inline_count = cache->inline_count;
+
+ memcpy(inline_records, cache->inline_records, sizeof(inline_records));
+ memcpy(inline_keys, cache->inline_keys, sizeof(inline_keys));
+
+ cache->key_records = NULL;
+ cache->inline_count = 0;
+
+ if (records != NULL) {
+ zend_hash_destroy(records);
+
+ FREE_HASHTABLE(records);
+ }
+
+ for (i = 0; i < inline_count; i++) {
+ zend_string_release(inline_keys[i]);
+ ucache_key_record_free(inline_records[i]);
+ }
+}
+
+static bool ucache_key_record_is_held(const HashTable *held, const ucache_key_record *record)
+{
+ return zend_hash_index_exists(held, (zend_ulong) (uintptr_t) record);
+}
+
+static void ucache_key_records_drop_oldest_inline(ucache_obj *cache, const HashTable *held, zval *released)
+{
+ ucache_key_record *dropped[UCACHE_INLINE_KEY_RECORDS], *record;
+ zend_string *dropped_keys[UCACHE_INLINE_KEY_RECORDS];
+ uint32_t i, kept_count = 0, dropped_count = 0;
+
+ for (i = 0; i < cache->inline_count; i++) {
+ if (!ucache_key_record_is_held(held, cache->inline_records[i])) {
+ ucache_key_record_reset_deferred(cache->inline_records[i], released);
+ }
+ }
+
+ if (UCACHE_DEBUG_FAULT("FORCE_KEY_RECORD_TRIM_BAILOUT")) {
+ zend_bailout();
+ }
+
+ for (i = 0; i < cache->inline_count; i++) {
+ record = cache->inline_records[i];
+ if (ucache_key_record_is_held(held, record)) {
+ cache->inline_keys[kept_count] = cache->inline_keys[i];
+ cache->inline_records[kept_count++] = record;
+
+ continue;
+ }
+
+ dropped_keys[dropped_count] = cache->inline_keys[i];
+ dropped[dropped_count++] = record;
+ }
+
+ cache->inline_count = kept_count;
+
+ for (i = 0; i < dropped_count; i++) {
+ zend_string_release(dropped_keys[i]);
+ ucache_key_record_free(dropped[i]);
+ }
+}
+
+static void ucache_key_records_drop_oldest_half(ucache_obj *cache, const HashTable *held, zval *released)
+{
+ ucache_key_record **dropped, *record;
+ HashTable *kept;
+ Bucket *bucket;
+ uint32_t i, count, drop, dropped_count = 0;
+
+ if (cache->key_records == NULL) {
+ ucache_key_records_drop_oldest_inline(cache, held, released);
+
+ return;
+ }
+
+ count = zend_hash_num_elements(cache->key_records);
+ drop = count - count / 2;
+ if (drop == 0) {
+ return;
+ }
+
+ dropped = safe_emalloc(drop, sizeof(*dropped), 0);
+
+ ALLOC_HASHTABLE(kept);
+
+ zend_hash_init(kept, count - drop, NULL, ucache_key_record_dtor, 0);
+
+ ZEND_HASH_MAP_FOREACH_BUCKET(cache->key_records, bucket) {
+ record = Z_PTR(bucket->val);
+ if (dropped_count < drop && !ucache_key_record_is_held(held, record)) {
+ ucache_key_record_reset_deferred(record, released);
+
+ dropped[dropped_count++] = record;
+
+ continue;
+ }
+
+ zend_hash_add_new_ptr(kept, bucket->key, record);
+ } ZEND_HASH_FOREACH_END();
+
+ cache->key_records->pDestructor = NULL;
+
+ zend_hash_destroy(cache->key_records);
+
+ FREE_HASHTABLE(cache->key_records);
+
+ cache->key_records = kept;
+
+ for (i = 0; i < dropped_count; i++) {
+ ucache_key_record_free(dropped[i]);
+ }
+
+ efree(dropped);
+}
+
+static ucache_held_records *ucache_held_records_begin(ucache_key_record **records, uint32_t count)
+{
+ ucache_held_records *held_records;
+
+ if (count == 0) {
+ return NULL;
+ }
+
+ held_records = emalloc(sizeof(*held_records));
+ held_records->prev = NULL;
+ held_records->next = UC_G(held_records);
+ held_records->records = records;
+ held_records->count = count;
+
+ if (held_records->next != NULL) {
+ held_records->next->prev = held_records;
+ }
+
+ UC_G(held_records) = held_records;
+
+ return held_records;
+}
+
+static void ucache_held_records_end(ucache_held_records *held_records)
+{
+ if (held_records == NULL) {
+ return;
+ }
+
+ if (held_records->prev != NULL) {
+ held_records->prev->next = held_records->next;
+ } else {
+ UC_G(held_records) = held_records->next;
+ }
+
+ if (held_records->next != NULL) {
+ held_records->next->prev = held_records->prev;
+ }
+
+ efree(held_records);
+}
+
+static void ucache_key_records_hold_key(ucache_obj *cache, zend_string *key, HashTable *held)
+{
+ ucache_key_record *record = ucache_key_record_find(cache, key);
+
+ if (record != NULL) {
+ zend_hash_index_add_empty_element(held, (zend_ulong) (uintptr_t) record);
+ }
+}
+
+static void ucache_key_records_hold_idx_key(ucache_obj *cache, zend_ulong idx, HashTable *held)
+{
+ zend_string *key = zend_long_to_str((zend_long) idx);
+
+ ucache_key_records_hold_key(cache, key, held);
+
+ zend_string_release(key);
+}
+
+static void ucache_key_records_hold_frames(zend_execute_data *ex, HashTable *held)
+{
+ ucache_obj *cache;
+ zend_string *key;
+ zend_ulong idx;
+ zval *arg;
+
+ for (; ex != NULL; ex = ex->prev_execute_data) {
+ cache = ucache_frame_pool(ex);
+ if (cache == NULL || ZEND_CALL_NUM_ARGS(ex) == 0) {
+ continue;
+ }
+
+ arg = ZEND_CALL_ARG(ex, 1);
+
+ if (Z_TYPE_P(arg) == IS_STRING) {
+ ucache_key_records_hold_key(cache, Z_STR_P(arg), held);
+ } else if (Z_TYPE_P(arg) == IS_ARRAY &&
+ zend_string_equals_literal_ci(ex->func->common.function_name, "storeMultiple")
+ ) {
+ ZEND_HASH_FOREACH_KEY(Z_ARRVAL_P(arg), idx, key) {
+ if (key != NULL) {
+ ucache_key_records_hold_key(cache, key, held);
+ } else {
+ ucache_key_records_hold_idx_key(cache, idx, held);
+ }
+ } ZEND_HASH_FOREACH_END();
+ }
+ }
+}
+
+static void ucache_key_records_collect_held(HashTable *held)
+{
+ ucache_held_records *held_records;
+ zend_object **bucket, **end;
+ zend_fiber *fiber;
+ uint32_t i;
+
+ for (held_records = UC_G(held_records); held_records != NULL; held_records = held_records->next) {
+ for (i = 0; i < held_records->count; i++) {
+ zend_hash_index_add_empty_element(held, (zend_ulong) (uintptr_t) held_records->records[i]);
+ }
+ }
+
+ ucache_key_records_hold_frames(EG(current_execute_data)->prev_execute_data, held);
+
+ end = EG(objects_store).object_buckets + EG(objects_store).top;
+ for (bucket = EG(objects_store).object_buckets + 1; bucket < end; bucket++) {
+ if (!IS_OBJ_VALID(*bucket) || (*bucket)->ce != zend_ce_fiber) {
+ continue;
+ }
+
+ fiber = (zend_fiber *) *bucket;
+ if (fiber->context.status == ZEND_FIBER_STATUS_SUSPENDED) {
+ ucache_key_records_hold_frames(fiber->execute_data, held);
+ }
+ }
+}
+
+static zend_never_inline void ucache_key_records_trim_pools(uint32_t limit, size_t budget)
+{
+ ucache_obj *cache;
+ HashTable held;
+ zval released;
+ uint32_t before;
+
+ ZVAL_UNDEF(&released);
+
+ zend_hash_init(&held, 8, NULL, NULL, 0);
+
+ ucache_key_records_collect_held(&held);
+
+ do {
+ before = UC_G(key_record_count);
+
+ for (cache = UC_G(live_pools); cache != NULL; cache = cache->live_next) {
+ ucache_key_records_drop_oldest_half(cache, &held, &released);
+ }
+ } while (UC_G(key_record_count) < before &&
+ (UC_G(key_record_count) > limit / 2 || UC_G(key_record_bytes) > budget / 2)
+ );
+
+ zend_hash_destroy(&held);
+
+ zval_ptr_dtor(&released);
+}
+
+static zend_never_inline void ucache_key_records_trim(void)
+{
+ uint32_t limit = ucache_key_record_limit();
+ size_t budget = ucache_req_cache_budget();
+
+ UC_G(key_records_trim_due) = false;
+
+ if (!ucache_key_records_within(limit, budget)) {
+ ucache_key_records_trim_pools(limit, budget);
+ }
+
+ ucache_key_records_schedule_trim(limit, budget);
+}
+
+static zend_never_inline ucache_obj *ucache_obj_uninitialized(zval *this_ptr)
+{
+ zend_throw_error(NULL, "%s instance was not initialized", ZSTR_VAL(Z_OBJCE_P(this_ptr)->name));
+
+ return NULL;
+}
+
+static zend_never_inline ucache_key_record *ucache_key_too_long(ucache_obj *cache)
+{
+ zend_argument_value_error(1, "must not be longer than %zu bytes", ucache_key_len_max(cache));
+
+ return NULL;
+}
+
+static PHP_UCACHE_HOT ucache_key_record *ucache_validated_key_record(
+ ucache_obj *cache,
+ zend_string *key)
+{
+ ucache_key_record *record = ucache_key_record_find(cache, key);
+
+ if (record != NULL) {
+ return record;
+ }
+
+ if (!ucache_validate_delim_free(key, "must be a non-empty string")) {
+ return NULL;
+ }
+
+ if (UNEXPECTED(ZSTR_LEN(key) > ucache_key_len_max(cache))) {
+ return ucache_key_too_long(cache);
+ }
+
+ return ucache_key_record_create(cache, key);
+}
+
+static bool ucache_key_record_list(
+ ucache_obj *cache,
+ HashTable *keys,
+ ucache_key_record ***records_ptr,
+ zval **key_copies_ptr)
+{
+ ucache_key_record **records;
+ zend_string *key;
+ zval *val, *key_copies = NULL;
+ uint32_t i = 0, count = zend_hash_num_elements(keys);
+ bool too_long = false;
+
+ *records_ptr = NULL;
+
+ if (key_copies_ptr != NULL) {
+ *key_copies_ptr = NULL;
+ }
+
+ if (count == 0) {
+ return true;
+ }
+
+ records = safe_emalloc(count, sizeof(*records), 0);
+
+ if (key_copies_ptr != NULL) {
+ key_copies = safe_emalloc(count, sizeof(zval), 0);
+ }
+
+ ZEND_HASH_FOREACH_VAL(keys, val) {
+ ZVAL_DEREF(val);
+
+ if (Z_TYPE_P(val) == IS_STRING) {
+ records[i] = ucache_key_record_find(cache, Z_STR_P(val));
+ if (records[i] == NULL) {
+ if (!ucache_user_key_is_valid(Z_STR_P(val))) {
+ break;
+ }
+
+ if (Z_STRLEN_P(val) > ucache_key_len_max(cache)) {
+ too_long = true;
+
+ break;
+ }
+
+ records[i] = ucache_key_record_create(cache, Z_STR_P(val));
+ }
+ } else if (Z_TYPE_P(val) == IS_LONG) {
+ key = zend_long_to_str(Z_LVAL_P(val));
+ records[i] = ucache_key_record_lookup(cache, key);
+
+ zend_string_release(key);
+ } else {
+ break;
+ }
+
+ if (key_copies != NULL) {
+ ZVAL_COPY(&key_copies[i], val);
+ }
+
+ i++;
+ } ZEND_HASH_FOREACH_END();
+
+ if (i != count) {
+ if (too_long) {
+ zend_argument_value_error(
+ 1,
+ "must contain only cache keys that are not longer than %zu bytes",
+ ucache_key_len_max(cache)
+ );
+ } else {
+ zend_argument_value_error(
+ 1,
+ "must contain only non-empty string or int cache keys that do not contain " UCACHE_KEY_DELIM_NAME
+ );
+ }
+
+ if (key_copies != NULL) {
+ while (i > 0) {
+ zval_ptr_dtor_nogc(&key_copies[--i]);
+ }
+
+ efree(key_copies);
+ }
+
+ efree(records);
+
+ return false;
+ }
+
+ *records_ptr = records;
+
+ if (key_copies_ptr != NULL) {
+ *key_copies_ptr = key_copies;
+ }
+
+ return true;
+}
+
+static zend_result ucache_fetch_multiple_api(
+ ucache_obj *cache,
+ HashTable *keys,
+ zval *default_val,
+ zval *return_value)
+{
+ ucache_fetch_pending_seed *pending_seeds;
+ ucache_held_records *held_records;
+ ucache_key_record **records;
+ zval *vals, *key_copies;
+ uint32_t count = zend_hash_num_elements(keys), i, p, pending_count = 0, *pending_idx;
+ bool rlock_held, backend_readable;
+
+ if (!ucache_key_record_list(cache, keys, &records, &key_copies)) {
+ return FAILURE;
+ }
+
+ held_records = ucache_held_records_begin(records, count);
+
+ backend_readable = ucache_available();
+
+ pending_seeds = NULL;
+ pending_idx = NULL;
+ vals = NULL;
+
+ if (count != 0) {
+ pending_seeds = safe_emalloc(count, sizeof(ucache_fetch_pending_seed), 0);
+ pending_idx = safe_emalloc(count, sizeof(uint32_t), 0);
+ vals = safe_emalloc(count, sizeof(zval), 0);
+
+ for (i = 0; i < count; i++) {
+ pending_seeds[i].should_seed = false;
+
+ ZVAL_UNDEF(&pending_seeds[i].detached_val);
+ ZVAL_UNDEF(&vals[i]);
+ }
+ }
+
+ for (i = 0; backend_readable && i < count && !EG(exception); i++) {
+ UC_G(stack_overflowed) = false;
+
+ switch (ucache_fetch_optimistic(records[i], &vals[i], false)) {
+ case UCACHE_OPTIMISTIC_FOUND:
+ case UCACHE_OPTIMISTIC_MISS:
+ continue;
+ case UCACHE_OPTIMISTIC_UNRESTORABLE:
+ ucache_drop_unrestorable_entry(records[i]);
+
+ continue;
+ case UCACHE_OPTIMISTIC_FALLBACK:
+ break;
+ }
+
+ if (EG(exception)) {
+ UC_G(stack_overflowed) = false;
+
+ break;
+ }
+
+ pending_idx[pending_count++] = i;
+ }
+
+ if (pending_count != 0 && !EG(exception)) {
+ if (ucache_begin_read()) {
+ rlock_held = true;
+
+ zend_try {
+ for (p = 0; p < pending_count; p++) {
+ i = pending_idx[p];
+
+ ucache_fetch_multiple_fetch_one(
+ records[i],
+ default_val,
+ &rlock_held,
+ &vals[i],
+ &pending_seeds[i]
+ );
+
+ if (EG(exception)) {
+ break;
+ }
+ }
+ } zend_catch {
+ ucache_unlock_if_held();
+
+ zend_bailout();
+ } zend_end_try();
+
+ if (rlock_held) {
+ ucache_unlock();
+ }
+ }
+ }
+
+ if (pending_idx != NULL) {
+ efree(pending_idx);
+ }
+
+ if (EG(exception)) {
+ for (i = 0; i < count; i++) {
+ zval_ptr_dtor(&vals[i]);
+ zval_ptr_dtor_nogc(&key_copies[i]);
+ ucache_fetch_finish(records[i], &pending_seeds[i], NULL);
+ }
+
+ ucache_held_records_end(held_records);
+
+ if (vals != NULL) {
+ efree(vals);
+ efree(key_copies);
+ efree(pending_seeds);
+ efree(records);
+ }
+
+ return FAILURE;
+ }
+
+ array_init_size(return_value, count);
+
+ for (i = 0; i < count; i++) {
+ if (Z_ISUNDEF(vals[i])) {
+ ZVAL_COPY(&vals[i], default_val);
+ }
+
+ ucache_fetch_finish(records[i], &pending_seeds[i], &vals[i]);
+
+ if (Z_TYPE(key_copies[i]) == IS_LONG) {
+ zend_hash_index_update(Z_ARRVAL_P(return_value), Z_LVAL(key_copies[i]), &vals[i]);
+ } else {
+ zend_symtable_update(Z_ARRVAL_P(return_value), Z_STR(key_copies[i]), &vals[i]);
+ zend_string_release(Z_STR(key_copies[i]));
+ }
+ }
+
+ ucache_held_records_end(held_records);
+
+ if (vals != NULL) {
+ efree(vals);
+ efree(key_copies);
+ efree(pending_seeds);
+ efree(records);
+ }
+
+ return SUCCESS;
+}
+
+static bool ucache_atomic_update_api(
+ ucache_key_record *record,
+ zend_long step,
+ zend_long ttl,
+ bool decrement,
+ ucache_atomic_update_result *result)
+{
+ bool updated;
+
+ if (!ucache_available()) {
+ memset(result, 0, sizeof(*result));
+
+ return false;
+ }
+
+ if (ucache_try_atomic_update(record, step, ttl, decrement, result, &updated)) {
+ return updated;
+ }
+
+ if (!ucache_wlock_for_entry_mutation(record->storage_key)) {
+ memset(result, 0, sizeof(*result));
+
+ return false;
+ }
+
+ updated = ucache_atomic_update_locked(
+ record->storage_key,
+ step,
+ ttl,
+ decrement,
+ result
+ );
+
+ ucache_unlock();
+
+ ucache_key_record_atomic_updated(record, result);
+
+ if (result->is_overflow || result->is_type_err) {
+ updated = false;
+ }
+
+ return updated;
+}
+
+static bool ucache_exists_api(ucache_key_record *record)
+{
+ bool exists;
+
+ if (!ucache_available()) {
+ return false;
+ }
+
+ switch (ucache_exists_optimistic(record)) {
+ case UCACHE_OPTIMISTIC_FOUND:
+ return true;
+ case UCACHE_OPTIMISTIC_MISS:
+ return false;
+ case UCACHE_OPTIMISTIC_UNRESTORABLE:
+ case UCACHE_OPTIMISTIC_FALLBACK:
+ break;
+ }
+
+ if (!ucache_begin_read()) {
+ return false;
+ }
+
+ exists = ucache_exists_locked(record->storage_key);
+
+ ucache_unlock();
+
+ return exists;
+}
+
+static bool ucache_lock_api(
+ zend_string *key,
+ zend_long lease)
+{
+ if (!ucache_available()) {
+ return false;
+ }
+
+ return ucache_try_acquire_entry_lock(key, ucache_clamp_lock_lease(lease));
+}
+
+static bool ucache_unlock_api(zend_string *key)
+{
+ if (!ucache_available()) {
+ return false;
+ }
+
+ return ucache_release_entry_lock(key);
+}
+
+static void ucache_release_remember_lock(zend_string *key)
+{
+ if (ucache_available()) {
+ (void) ucache_release_entry_lock_unless_requested(key);
+ }
+}
+
+static void ucache_return_status(zval *return_value)
+{
+ ucache_status_obj *status;
+
+ ucache_ensure_ready();
+
+ object_init_ex(return_value, ucache_status_ce);
+
+ status = ucache_status_obj_from_obj(Z_OBJ_P(return_value));
+ status->availability_reason = ucache_active_runtime()->failure_reason;
+
+ ucache_collect_info_stats(&status->stats);
+
+ status->initialized = true;
+}
+
+static void ucache_return_pool_status(
+ ucache_obj *cache,
+ zval *return_value)
+{
+ ucache_pool_status_obj *status;
+
+ ucache_ensure_ready();
+
+ object_init_ex(return_value, ucache_pool_status_ce);
+
+ status = ucache_pool_status_obj_from_obj(Z_OBJ_P(return_value));
+ status->scope = zend_string_copy(cache->scope);
+
+ ZVAL_EMPTY_ARRAY(&status->entry_keys);
+
+ if (!ucache_available()) {
+ return;
+ }
+
+ ucache_collect_pool_status(
+ cache,
+ &status->entry_count,
+ &status->used_mem,
+ &status->entry_keys
+ );
+}
+
+static void ucache_obj_free(zend_object *obj)
+{
+ ucache_obj *cache = ucache_obj_from_obj(obj);
+
+ if (cache->live_prev != NULL) {
+ cache->live_prev->live_next = cache->live_next;
+ } else {
+ UC_G(live_pools) = cache->live_next;
+ }
+
+ if (cache->live_next != NULL) {
+ cache->live_next->live_prev = cache->live_prev;
+ }
+
+ zend_object_std_dtor(&cache->std);
+
+ if (UC_G(pool_status_snapshots) != NULL) {
+ zend_hash_index_del(UC_G(pool_status_snapshots), obj->handle);
+ }
+
+ if (cache->scope != NULL) {
+ zend_string_release(cache->scope);
+ }
+
+ if (cache->scope_prefix != NULL) {
+ zend_string_release(cache->scope_prefix);
+ }
+
+ ucache_key_records_free(cache);
+}
+
+static zend_object *ucache_obj_create(zend_class_entry *ce)
+{
+ ucache_obj *cache;
+
+ cache = zend_object_alloc(sizeof(ucache_obj), ce);
+
+ zend_object_std_init(&cache->std, ce);
+ object_properties_init(&cache->std, ce);
+
+ cache->scope = NULL;
+ cache->scope_prefix = NULL;
+ cache->live_prev = NULL;
+ cache->live_next = UC_G(live_pools);
+ cache->key_records = NULL;
+ cache->inline_count = 0;
+ cache->key_len_max = 0;
+ cache->ctx = ucache_owning_ctx();
+ cache->std.handlers = &ucache_obj_handlers;
+
+ if (cache->live_next != NULL) {
+ cache->live_next->live_prev = cache;
+ }
+
+ UC_G(live_pools) = cache;
+
+ return &cache->std;
+}
+
+static void ucache_register_classes(void)
+{
+ if (ucache_ce != NULL) {
+ return;
+ }
+
+ ucache_availability_ce = register_class_UserCache_CacheAvailability();
+ ucache_status_ce = register_class_UserCache_CacheStatus();
+ ucache_pool_status_ce = register_class_UserCache_CachePoolStatus();
+ ucache_ce = register_class_UserCache_Cache();
+
+ ucache_ce->create_object = ucache_obj_create;
+ ucache_status_ce->create_object = ucache_status_obj_create;
+ ucache_pool_status_ce->create_object = ucache_pool_status_obj_create;
+
+ memcpy(
+ &ucache_obj_handlers,
+ zend_get_std_object_handlers(),
+ sizeof(zend_object_handlers)
+ );
+
+ ucache_obj_handlers.offset = offsetof(ucache_obj, std);
+ ucache_obj_handlers.free_obj = ucache_obj_free;
+ ucache_obj_handlers.clone_obj = NULL;
+ ucache_obj_handlers.compare = zend_objects_not_comparable;
+
+ memcpy(
+ &ucache_status_obj_handlers,
+ zend_get_std_object_handlers(),
+ sizeof(zend_object_handlers)
+ );
+
+ ucache_status_obj_handlers.offset = offsetof(ucache_status_obj, std);
+ ucache_status_obj_handlers.clone_obj = NULL;
+ ucache_status_obj_handlers.compare = zend_objects_not_comparable;
+
+ memcpy(
+ &ucache_pool_status_obj_handlers,
+ zend_get_std_object_handlers(),
+ sizeof(zend_object_handlers)
+ );
+
+ ucache_pool_status_obj_handlers.offset = offsetof(ucache_pool_status_obj, std);
+ ucache_pool_status_obj_handlers.free_obj = ucache_pool_status_obj_free;
+ ucache_pool_status_obj_handlers.clone_obj = NULL;
+ ucache_pool_status_obj_handlers.compare = zend_objects_not_comparable;
+}
+
+static HashTable *ucache_pools(void)
+{
+ if (UC_G(pool_table) == NULL) {
+ ALLOC_HASHTABLE(UC_G(pool_table));
+ zend_hash_init(UC_G(pool_table), 8, NULL, ZVAL_PTR_DTOR, 0);
+ }
+
+ return UC_G(pool_table);
+}
+
+static void ucache_release_pools(void)
+{
+ HashTable *pools = UC_G(pool_table);
+
+ UC_G(pool_table) = NULL;
+
+ if (pools != NULL) {
+ zend_hash_destroy(pools);
+
+ FREE_HASHTABLE(pools);
+ }
+}
+
+static zend_object *ucache_create_pool_obj(zend_string *pool)
+{
+ zend_object *obj = ucache_obj_create(ucache_ce);
+ ucache_obj *cache = ucache_obj_from_obj(obj);
+
+ cache->scope = zend_string_copy(pool);
+ cache->scope_prefix = ucache_build_scope_prefix(pool);
+ cache->key_len_max = (uint32_t) (UCACHE_STORAGE_KEY_MAX - ZSTR_LEN(cache->scope_prefix));
+
+ return obj;
+}
+
+static zend_never_inline void ucache_pools_trim(HashTable *pools, uint32_t limit)
+{
+ zend_string **names, *name;
+ zval *pool_zv;
+ uint32_t i,
+ count = 0,
+ drop = zend_hash_num_elements(pools) - MIN(zend_hash_num_elements(pools), limit / 2)
+ ;
+
+ names = safe_emalloc(drop, sizeof(zend_string *), 0);
+ ZEND_HASH_FOREACH_STR_KEY_VAL(pools, name, pool_zv) {
+ if (count == drop) {
+ break;
+ }
+
+ if (Z_REFCOUNT_P(pool_zv) == 1) {
+ names[count++] = zend_string_copy(name);
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ for (i = 0; i < count; i++) {
+ pool_zv = UC_G(pool_table) == pools ? zend_hash_find(pools, names[i]) : NULL;
+ if (pool_zv != NULL && Z_REFCOUNT_P(pool_zv) == 1) {
+ zend_hash_del(pools, names[i]);
+ }
+
+ zend_string_release(names[i]);
+ }
+
+ efree(names);
+
+ if (UC_G(pool_table) == pools) {
+ UC_G(pool_trim_at) = MAX(limit, zend_hash_num_elements(pools) + limit / 2);
+ }
+}
+
+static zend_object *ucache_get_or_create_pool(zend_string *pool)
+{
+ zend_object *obj;
+ zval *existing, pool_zv;
+ HashTable *pools = ucache_pools();
+ uint32_t limit;
+
+ existing = zend_hash_find(pools, pool);
+ if (existing != NULL) {
+ return Z_OBJ_P(existing);
+ }
+
+ limit = ucache_key_record_limit();
+ if (zend_hash_num_elements(pools) >= MAX(limit, UC_G(pool_trim_at))) {
+ ucache_pools_trim(pools, limit);
+
+ pools = ucache_pools();
+
+ existing = zend_hash_find(pools, pool);
+ if (existing != NULL) {
+ return Z_OBJ_P(existing);
+ }
+ }
+
+ obj = ucache_create_pool_obj(pool);
+
+ ZVAL_OBJ(&pool_zv, obj);
+
+ zend_hash_add_new(pools, pool, &pool_zv);
+
+ return obj;
+}
+
+static void ucache_safe_direct_handlers_dtor(zval *zv)
+{
+ pefree(Z_PTR_P(zv), true);
+}
+
+static bool ucache_store_api(ucache_key_record *record, zval *val, zend_long ttl, bool add_only)
+{
+ zend_string *key = record->storage_key;
+ ucache_prepared_val prepared;
+ ucache_store_result result;
+ bool stored;
+
+ if (!ucache_prepare_val(key, val, &prepared)) {
+ ucache_destroy_prepared_val(&prepared);
+
+ return false;
+ }
+
+ if (!add_only && ttl == 0 && prepared.val_type <= UCACHE_VAL_DOUBLE &&
+ ucache_try_store_scalar(record, &prepared)
+ ) {
+ ucache_destroy_prepared_val(&prepared);
+ ucache_release_req_local_slot(key);
+
+ return true;
+ }
+
+ if (!ucache_wlock_for_entry_mutation(key)) {
+ ucache_destroy_prepared_val(&prepared);
+
+ return false;
+ }
+
+ if (add_only && ucache_exists_locked(key)) {
+ ucache_unlock();
+ ucache_destroy_prepared_val(&prepared);
+
+ return false;
+ }
+
+ zend_try {
+ stored = ucache_store_prepared_locked(key, val, &prepared, ttl, false, &result);
+ } zend_catch {
+ ucache_unlock_if_held();
+ ucache_destroy_prepared_val(&prepared);
+
+ zend_bailout();
+ } zend_end_try();
+
+ ucache_unlock();
+ ucache_destroy_prepared_val(&prepared);
+
+ if (stored) {
+ ucache_key_record_stored(record, &result, val);
+ ucache_release_req_local_slot(key);
+ }
+
+ return stored;
+}
+
+static uint32_t ucache_prepare_bulk_store_items(
+ ucache_obj *cache,
+ HashTable *vals,
+ ucache_bulk_store_item *items,
+ zend_string **storage_keys,
+ bool *result)
+{
+ zend_ulong num_key;
+ zend_string *key;
+ zval *val;
+ uint32_t i = 0;
+
+ *result = true;
+
+ ZEND_HASH_FOREACH_KEY_VAL(vals, num_key, key, val) {
+ if (key != NULL) {
+ items[i].record = ucache_key_record_lookup(cache, key);
+ } else {
+ key = zend_long_to_str(num_key);
+ items[i].record = ucache_key_record_lookup(cache, key);
+
+ zend_string_release(key);
+ }
+
+ storage_keys[i] = items[i].record->storage_key;
+
+ ZVAL_COPY_DEREF(&items[i].val, val);
+
+ if (!ucache_validate_root_val(&items[i].val) ||
+ !ucache_prepare_val(storage_keys[i], &items[i].val, &items[i].prepared)
+ ) {
+ ucache_destroy_prepared_val(&items[i].prepared);
+
+ *result = false;
+
+ break;
+ }
+
+ i++;
+ } ZEND_HASH_FOREACH_END();
+
+ return i;
+}
+
+static uint32_t ucache_commit_bulk_store_locked(
+ ucache_bulk_store_item *items,
+ uint32_t prepared_count,
+ zend_long ttl,
+ bool *result)
+{
+ uint32_t i, j, stored_count = 0;
+
+ *result = true;
+
+ UC_G(store_defer_unlock) = true;
+
+ for (i = 0; i < prepared_count; i++) {
+ if (!ucache_store_prepared_locked(
+ items[i].record->storage_key,
+ &items[i].val,
+ &items[i].prepared,
+ ttl,
+ true,
+ &items[i].store_result
+ )
+ ) {
+ *result = false;
+
+ break;
+ }
+
+ items[i].store_result.committed = true;
+ stored_count = i + 1;
+
+ if (i == 0 && prepared_count > 1 && UCACHE_DEBUG_FAULT("FORCE_BULK_COMMIT_BAILOUT")) {
+ zend_bailout();
+ }
+ }
+
+ UC_G(store_defer_unlock) = false;
+
+ if (*result) {
+ for (i = 0; i < stored_count; i++) {
+ ucache_discard_replaced_entry_locked(&items[i].store_result.replaced_entry);
+
+ items[i].store_result.committed = false;
+ }
+ } else {
+ for (j = stored_count; j > 0; j--) {
+ ucache_rollback_replaced_entry_locked(
+ items[j - 1].record->storage_key,
+ &items[j - 1].store_result.replaced_entry
+ );
+
+ items[j - 1].store_result.committed = false;
+ }
+
+ stored_count = 0;
+ }
+
+ return stored_count;
+}
+
+static void ucache_abort_bulk_store_on_bailout(
+ ucache_bulk_store_item *items,
+ uint32_t prepared_count)
+{
+ uint32_t j;
+
+ UC_G(store_defer_unlock) = false;
+
+ ZEND_ASSERT(UC_G(lock_held));
+
+ for (j = prepared_count; j > 0; j--) {
+ if (items[j - 1].store_result.committed) {
+ ucache_rollback_replaced_entry_locked(
+ items[j - 1].record->storage_key,
+ &items[j - 1].store_result.replaced_entry
+ );
+
+ items[j - 1].store_result.committed = false;
+ }
+ }
+
+ ucache_unlock();
+}
+
+static void ucache_finish_bulk_store(ucache_bulk_store_item *items, uint32_t stored_count)
+{
+ uint32_t i;
+
+ for (i = 0; i < stored_count; i++) {
+ ucache_key_record_stored(items[i].record, &items[i].store_result, &items[i].val);
+ ucache_release_req_local_slot(items[i].record->storage_key);
+ }
+}
+
+static bool ucache_instance_store_multiple(
+ ucache_obj *cache,
+ HashTable *vals,
+ zend_long ttl)
+{
+ ucache_bulk_store_item *items;
+ zend_string **storage_keys;
+ uint32_t i, count, prepared_count, stored_count = 0;
+ bool result;
+
+ if (!ucache_available()) {
+ return false;
+ }
+
+ count = zend_hash_num_elements(vals);
+ if (count == 0) {
+ return true;
+ }
+
+ items = ecalloc(count, sizeof(*items));
+ storage_keys = safe_emalloc(count, sizeof(zend_string *), 0);
+
+ prepared_count = ucache_prepare_bulk_store_items(cache, vals, items, storage_keys, &result);
+
+ if (result && ucache_wlock_for_entry_mutations(storage_keys, prepared_count)) {
+ zend_try {
+ stored_count = ucache_commit_bulk_store_locked(items, prepared_count, ttl, &result);
+ } zend_catch {
+ ucache_abort_bulk_store_on_bailout(items, prepared_count);
+
+ zend_bailout();
+ } zend_end_try();
+
+ ucache_unlock();
+ } else {
+ result = false;
+ }
+
+ ucache_finish_bulk_store(items, stored_count);
+
+ for (i = 0; i < prepared_count; i++) {
+ ucache_destroy_prepared_val(&items[i].prepared);
+ }
+
+ for (i = 0; i < count; i++) {
+ zval_ptr_dtor(&items[i].val);
+ }
+
+ efree(storage_keys);
+ efree(items);
+
+ return result;
+}
+
+static bool ucache_instance_delete_multiple(
+ ucache_obj *cache,
+ HashTable *keys)
+{
+ ucache_held_records *held_records;
+ ucache_key_record **records;
+ zend_string **storage_keys;
+ uint64_t epoch = 0;
+ uint32_t i, count = zend_hash_num_elements(keys);
+ bool result = true;
+
+ if (!ucache_key_record_list(cache, keys, &records, NULL)) {
+ return false;
+ }
+
+ held_records = ucache_held_records_begin(records, count);
+
+ if (!ucache_available() || count == 0) {
+ ucache_held_records_end(held_records);
+
+ if (records != NULL) {
+ efree(records);
+ }
+
+ return !EG(exception);
+ }
+
+ storage_keys = safe_emalloc(count, sizeof(zend_string *), 0);
+
+ for (i = 0; i < count; i++) {
+ storage_keys[i] = records[i]->storage_key;
+ }
+
+ if (ucache_wlock_for_entry_mutations(storage_keys, count)) {
+ zend_try {
+ for (i = 0; i < count; i++) {
+ epoch = ucache_delete_locked(storage_keys[i]);
+ }
+ } zend_catch {
+ ucache_unlock_if_held();
+
+ zend_bailout();
+ } zend_end_try();
+
+ ucache_unlock();
+ } else {
+ result = false;
+ }
+
+ if (result) {
+ for (i = 0; i < count; i++) {
+ ucache_key_record_deleted(records[i], epoch);
+ ucache_release_req_local_slot(storage_keys[i]);
+ }
+ }
+
+ ucache_held_records_end(held_records);
+
+ efree(records);
+ efree(storage_keys);
+
+ return result;
+}
+
+static void ucache_safe_direct_handlers_init(void)
+{
+ if (ucache_safe_direct_handlers_initialized) {
+ return;
+ }
+
+ zend_hash_init(
+ &ucache_safe_direct_handler_table,
+ 8,
+ NULL,
+ ucache_safe_direct_handlers_dtor,
+ true
+ );
+
+ ucache_safe_direct_handlers_initialized = true;
+}
+
+static void ucache_safe_direct_handlers_destroy(void)
+{
+ if (!ucache_safe_direct_handlers_initialized) {
+ return;
+ }
+
+ zend_hash_destroy(&ucache_safe_direct_handler_table);
+
+ ucache_safe_direct_handlers_initialized = false;
+}
+
+#ifdef ZTS
+static void ucache_globals_ctor(void *storage)
+{
+ ucache_globals *user_cache_globals = storage;
+
+ memset(user_cache_globals, 0, sizeof(ucache_globals));
+
+ user_cache_globals->persistent_exec = ucache_runtime_opted_in &&
+ ucache_registered_mode == PHP_UCACHE_MODE_PERSISTENT
+ ;
+}
+
+static void ucache_globals_dtor(void *storage)
+{
+ ucache_globals *user_cache_globals = storage;
+
+ ucache_release_thread_reader_claims(user_cache_globals);
+ ucache_release_thread_graph_pin_claims(user_cache_globals);
+
+ if (ucache_boundary_partitions_mutex != NULL) {
+ ucache_orphan_thread_deferred_entry_lock_releases(user_cache_globals);
+ } else {
+ ucache_free_thread_deferred_entry_lock_releases(user_cache_globals);
+ }
+}
+#endif /* ZTS */
+
+static void ucache_minit(void)
+{
+ ucache_ctx *prev_ctx;
+
+#ifdef ZTS
+ if (ucache_boundary_partitions_mutex == NULL) {
+ ucache_boundary_partitions_mutex = tsrm_mutex_alloc();
+ }
+#endif
+
+#if defined(ZTS) && !defined(ZEND_WIN32)
+ if (!ucache_zts_atfork_registered &&
+ pthread_atfork(ucache_zts_atfork_prepare, ucache_zts_atfork_release, ucache_zts_atfork_child) == 0
+ ) {
+ ucache_zts_atfork_registered = true;
+ }
+#endif
+
+#ifndef ZEND_WIN32
+ if (!ucache_pid_atfork_registered &&
+ !ucache_self_pid_uncached &&
+ pthread_atfork(NULL, NULL, ucache_pid_atfork_child) != 0
+ ) {
+ ucache_self_pid_uncached = true;
+ }
+
+ ucache_pid_atfork_registered = !ucache_self_pid_uncached;
+ ucache_self_pid = ucache_self_pid_uncached ? 0 : ucache_cur_pid();
+#endif /* ZEND_WIN32 */
+ ucache_runtime_opted_in = false;
+ ucache_registered_mode = PHP_UCACHE_MODE_REQ;
+
+ atomic_store(&ucache_registration_closed, false);
+
+ ucache_register_classes();
+
+ ucache_safe_direct_handlers_init();
+
+ ucache_classify_sapi();
+
+ prev_ctx = ucache_activate_ctx(ucache_owning_ctx());
+
+ ucache_reset_storage();
+
+ ucache_restore_ctx(prev_ctx);
+}
+
+static void ucache_mshutdown(void)
+{
+ ucache_ctx *prev_ctx;
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_BEFORE_CLAIM_RELEASE");
+
+ ucache_release_thread_reader_claims(UCACHE_GLOBALS_PTR());
+ ucache_release_thread_graph_pin_claims(UCACHE_GLOBALS_PTR());
+ ucache_free_thread_deferred_entry_lock_releases(UCACHE_GLOBALS_PTR());
+#ifdef ZTS
+ ucache_free_orphaned_entry_lock_releases();
+#endif
+
+ ucache_partitions_shutdown();
+ ucache_boundary_partitions_shutdown();
+
+#ifdef ZTS
+ if (ucache_boundary_partitions_mutex != NULL) {
+ tsrm_mutex_free(ucache_boundary_partitions_mutex);
+ ucache_boundary_partitions_mutex = NULL;
+ }
+#endif
+
+ UC_G(active_partition) = NULL;
+ UC_G(active_ctx_ptr) = NULL;
+ UC_G(exec_prepared) = false;
+ UC_G(exec_prev_partition) = NULL;
+ UC_G(exec_prev_ctx) = NULL;
+ UC_G(exec_partition) = NULL;
+
+ prev_ctx = ucache_activate_ctx(ucache_owning_ctx());
+
+ ucache_shutdown_storage();
+ ucache_reset_runtime();
+ ucache_restore_ctx(prev_ctx);
+
+ ucache_runtime_opted_in = false;
+ ucache_registered_mode = PHP_UCACHE_MODE_REQ;
+
+ ucache_safe_direct_handlers_destroy();
+ ucache_reset_class_entries();
+}
+
+static zend_result ucache_rinit(void)
+{
+ if (!php_during_module_startup()) {
+ atomic_store(&ucache_registration_closed, true);
+ }
+
+ if (!UC_G(exec_prepared)) {
+ UC_G(persistent_exec) = ucache_runtime_opted_in &&
+ ucache_registered_mode == PHP_UCACHE_MODE_PERSISTENT
+ ;
+ }
+
+ UC_G(exec_active) = true;
+ UC_G(exec_partition) = UC_G(active_partition);
+ UC_G(runtime_resolved) = false;
+
+ return SUCCESS;
+}
+
+static zend_result ucache_rshutdown(void)
+{
+ UC_G(in_req_shutdown) = true;
+
+ ucache_unlock_if_held();
+
+ UC_G(req_unavailable_reason) = PHP_UCACHE_REASON_NONE;
+ UC_G(runtime_resolved) = false;
+
+ ucache_release_pool_status_snapshots();
+ ucache_release_pools();
+
+ ucache_release_req_entry_locks();
+ ucache_release_req_local_slots();
+
+ if (UC_G(req_local_slot_may_cycle)) {
+ UC_G(req_local_slot_may_cycle) = false;
+
+ if (!CG(unclean_shutdown)) {
+ gc_collect_cycles();
+ }
+ }
+
+ ucache_decode_payload_addr_caches_release();
+ ucache_decode_maps_teardown();
+ ucache_sgraph_obj_route_memo_release();
+
+ return SUCCESS;
+}
+
+static zend_result ucache_post_deactivate(void)
+{
+ ucache_release_op_leases();
+ ucache_release_req_sgraph_refs();
+ ucache_abandon_graph_pin_claims();
+ ucache_retry_deferred_entry_lock_releases();
+ ucache_expunge_expired_at_req_end();
+
+ UC_G(in_req_shutdown) = false;
+ UC_G(runtime_resolved) = false;
+ UC_G(access_now) = 0;
+ UC_G(access_now_touches) = 0;
+ UC_G(reader_claim_failed_hdr) = NULL;
+ UC_G(key_records_trim_due) = false;
+ UC_G(key_record_count) = 0;
+ UC_G(key_record_trim_at) = 0;
+ UC_G(key_record_bytes) = 0;
+ UC_G(key_record_bytes_trim_at) = 0;
+ UC_G(record_val_bytes) = 0;
+ UC_G(req_local_slot_bytes) = 0;
+ UC_G(pool_trim_at) = 0;
+ UC_G(held_records) = NULL;
+ UC_G(live_pools) = NULL;
+ UC_G(exec_active) = false;
+ UC_G(exec_poisoned) = false;
+ UC_G(logical_req_ending) = false;
+ UC_G(logical_scope_token) = 0;
+ UC_G(op_depth) = 0;
+
+ ucache_restore_exec_preparation();
+
+ return SUCCESS;
+}
+
+static void ucache_store_method(INTERNAL_FUNCTION_PARAMETERS, bool add_only)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_long ttl = 0;
+ zend_string *key;
+ zval *val;
+
+ ZEND_PARSE_PARAMETERS_START(2, 3)
+ Z_PARAM_STR(key)
+ Z_PARAM_ZVAL(val)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_LONG(ttl)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_root_val(val)) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_non_negative(ttl, 3)) {
+ RETURN_THROWS();
+ }
+
+ if (!(
+ ucache_available() &&
+ ucache_store_api(
+ record,
+ val,
+ ttl,
+ add_only
+ )
+ )
+ ) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_FALSE;
+ }
+
+ RETURN_TRUE;
+}
+
+static void ucache_atomic_update_method(INTERNAL_FUNCTION_PARAMETERS, bool decrement)
+{
+ ucache_obj *cache;
+ ucache_atomic_update_result result;
+ ucache_key_record *record;
+ zend_long step = 1, ttl = 0;
+ zend_string *key;
+ bool updated;
+
+ ZEND_PARSE_PARAMETERS_START(1, 3)
+ Z_PARAM_STR(key)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_LONG(step)
+ Z_PARAM_LONG(ttl)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_non_negative(step, 2)) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_non_negative(ttl, 3)) {
+ RETURN_THROWS();
+ }
+
+ updated = ucache_atomic_update_api(record, step, ttl, decrement, &result);
+
+ if (result.is_type_err) {
+ zend_value_error(
+ "%s of user cache key \"%s\" requires the stored value to be an integer",
+ decrement ? "Decrement" : "Increment",
+ ZSTR_VAL(key)
+ );
+
+ RETURN_THROWS();
+ }
+
+ if (result.is_overflow) {
+ zend_throw_error(
+ zend_ce_arithmetic_error,
+ "%s of user cache key \"%s\" would exceed the range of a PHP integer",
+ decrement ? "Decrement" : "Increment",
+ ZSTR_VAL(key)
+ );
+
+ RETURN_THROWS();
+ }
+
+ if (!updated) {
+ RETURN_NULL();
+ }
+
+ RETURN_LONG(result.new_val);
+}
+
+static bool ucache_invoke_remember_cb(
+ zend_string *key,
+ ucache_key_record *record,
+ zend_fcall_info *fci,
+ zend_fcall_info_cache *fcc,
+ zend_long ttl,
+ zval *result)
+{
+ zval key_zv;
+
+ ZVAL_UNDEF(result);
+
+ fci->retval = result;
+
+ ZVAL_STR(&key_zv, key);
+
+ fci->param_count = 1;
+ fci->params = &key_zv;
+ fci->named_params = NULL;
+
+ if (zend_call_function(fci, fcc) != SUCCESS || EG(exception)) {
+ return true;
+ }
+
+ if (Z_TYPE_P(result) == IS_UNDEF) {
+ return true;
+ }
+
+ if (Z_ISREF_P(result)) {
+ zend_unwrap_reference(result);
+ }
+
+ if (!ucache_validate_root_val(result)) {
+ return false;
+ }
+
+ if (ucache_available() &&
+ ucache_store_api(
+ record,
+ result,
+ ttl,
+ false
+ )
+ ) {
+ return true;
+ }
+
+ return !EG(exception);
+}
+
+UCACHE_METHOD_BODY(__construct)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_throw_error(
+ NULL,
+ "%s instances must be obtained via %s::getPool()",
+ ZSTR_VAL(ucache_ce->name),
+ ZSTR_VAL(ucache_ce->name)
+ );
+}
+
+UCACHE_METHOD_BODY(hasPool)
+{
+ zend_string *pool;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(pool)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ucache_validate_pool_name(pool)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_BOOL(UC_G(pool_table) != NULL &&
+ zend_hash_exists(UC_G(pool_table), pool)
+ );
+}
+
+UCACHE_METHOD_BODY(getPool)
+{
+ zend_string *pool;
+ zend_object *obj;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(pool)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ucache_validate_pool_name(pool)) {
+ RETURN_THROWS();
+ }
+
+ if (UC_G(in_req_shutdown)) {
+ RETURN_OBJ(ucache_create_pool_obj(pool));
+ }
+
+ obj = ucache_get_or_create_pool(pool);
+
+ GC_ADDREF(obj);
+
+ RETURN_OBJ(obj);
+}
+
+UCACHE_METHOD_BODY(getPools)
+{
+ zval *instance;
+ zend_string *pool;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ array_init(return_value);
+
+ if (UC_G(pool_table) == NULL) {
+ return;
+ }
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(UC_G(pool_table), pool, instance) {
+ ZEND_ASSERT(pool != NULL);
+
+ Z_ADDREF_P(instance);
+
+ zend_symtable_update(Z_ARRVAL_P(return_value), pool, instance);
+ } ZEND_HASH_FOREACH_END();
+}
+
+UCACHE_METHOD_BODY(deletePool)
+{
+ zend_string *pool, *scope_prefix;
+ zend_object *pool_obj;
+ zval *instance;
+ bool cleared = true;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(pool)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ucache_validate_pool_name(pool)) {
+ RETURN_THROWS();
+ }
+
+ if (ucache_available()) {
+ scope_prefix = ucache_build_scope_prefix(pool);
+ cleared = ucache_clear_api(scope_prefix);
+
+ zend_string_release(scope_prefix);
+ } else if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ if (!cleared) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_FALSE;
+ }
+
+ if (UC_G(pool_table) != NULL) {
+ instance = zend_hash_find(UC_G(pool_table), pool);
+ if (instance != NULL) {
+ pool_obj = Z_OBJ_P(instance);
+
+ GC_ADDREF(pool_obj);
+
+ ucache_key_records_reset(ucache_obj_from_obj(pool_obj));
+
+ instance = zend_hash_find(UC_G(pool_table), pool);
+ if (instance != NULL && Z_OBJ_P(instance) == pool_obj) {
+ zend_hash_del(UC_G(pool_table), pool);
+ }
+
+ OBJ_RELEASE(pool_obj);
+ }
+ }
+
+ RETURN_TRUE;
+}
+
+UCACHE_METHOD_BODY(getStatus)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ ucache_return_status(return_value);
+
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+}
+
+UCACHE_METHOD_BODY(store)
+{
+ ucache_store_method(INTERNAL_FUNCTION_PARAM_PASSTHRU, false);
+}
+
+UCACHE_METHOD_BODY(add)
+{
+ ucache_store_method(INTERNAL_FUNCTION_PARAM_PASSTHRU, true);
+}
+
+UCACHE_METHOD_BODY(storeMultiple)
+{
+ ucache_obj *cache;
+ zend_long ttl = 0;
+ HashTable *vals;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_ARRAY_HT(vals)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_LONG(ttl)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_store_arr(vals, ucache_key_len_max(cache))) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_non_negative(ttl, 2)) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_instance_store_multiple(cache, vals, ttl)) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_FALSE;
+ }
+
+ RETURN_TRUE;
+}
+
+UCACHE_METHOD_BODY(increment)
+{
+ ucache_atomic_update_method(INTERNAL_FUNCTION_PARAM_PASSTHRU, false);
+}
+
+UCACHE_METHOD_BODY(decrement)
+{
+ ucache_atomic_update_method(INTERNAL_FUNCTION_PARAM_PASSTHRU, true);
+}
+
+UCACHE_METHOD_BODY(fetch)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_string *key;
+ zval *default_val = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR(key)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_ZVAL(default_val)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ ucache_fetch_api(record, default_val, return_value);
+}
+
+UCACHE_METHOD_BODY(fetchMultiple)
+{
+ ucache_obj *cache;
+ zval *default_val = NULL, default_null;
+ HashTable *keys;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_ARRAY_HT(keys)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_ZVAL(default_val)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (default_val == NULL) {
+ ZVAL_NULL(&default_null);
+
+ default_val = &default_null;
+ }
+
+ if (ucache_fetch_multiple_api(cache, keys, default_val, return_value) == FAILURE) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_EMPTY_ARRAY();
+ }
+}
+
+UCACHE_METHOD_BODY(has)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_string *key;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(key)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ RETURN_BOOL(ucache_exists_api(record));
+}
+
+UCACHE_METHOD_BODY(delete)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_string *key;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(key)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_available()) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_TRUE;
+ }
+
+ if (!ucache_delete_api(record)) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_FALSE;
+ }
+
+ RETURN_TRUE;
+}
+
+UCACHE_METHOD_BODY(deleteMultiple)
+{
+ ucache_obj *cache;
+ HashTable *keys;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_ARRAY_HT(keys)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_instance_delete_multiple(cache, keys)) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_FALSE;
+ }
+
+ RETURN_TRUE;
+}
+
+UCACHE_METHOD_BODY(clear)
+{
+ ucache_obj *cache;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_available()) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_TRUE;
+ }
+
+ if (!ucache_clear_api(cache->scope_prefix)) {
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_FALSE;
+ }
+
+ ucache_key_records_reset(cache);
+
+ RETURN_TRUE;
+}
+
+UCACHE_METHOD_BODY(lock)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_long lease = 0;
+ zend_string *key;
+ bool locked;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR(key)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_LONG(lease)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (!ucache_validate_non_negative(lease, 2)) {
+ RETURN_THROWS();
+ }
+
+ locked = ucache_lock_api(record->storage_key, lease);
+
+ RETURN_BOOL(locked);
+}
+
+UCACHE_METHOD_BODY(unlock)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_string *key;
+ bool unlocked;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(key)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ unlocked = ucache_unlock_api(record->storage_key);
+
+ RETURN_BOOL(unlocked);
+}
+
+UCACHE_METHOD_BODY(remember)
+{
+ ucache_obj *cache;
+ ucache_key_record *record;
+ zend_long ttl = 0;
+ zend_string *key, *storage_key;
+ zend_fcall_info fci;
+ zend_fcall_info_cache fcc;
+ zval result;
+ bool found = false, preheld = false,
+ locked = false, cb_failed = false
+ ;
+
+ ZEND_PARSE_PARAMETERS_START(2, 3)
+ Z_PARAM_STR(key)
+ Z_PARAM_FUNC(fci, fcc)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_LONG(ttl)
+ ZEND_PARSE_PARAMETERS_END();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ record = ucache_validated_key_record(cache, key);
+ if (record == NULL) {
+ RETURN_THROWS();
+ }
+
+ storage_key = record->storage_key;
+
+ if (!ucache_validate_non_negative(ttl, 3)) {
+ RETURN_THROWS();
+ }
+
+ found = ucache_fetch_if_present_api(record, return_value);
+ if (found) {
+ return;
+ }
+
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+
+ preheld = ucache_available() &&
+ ucache_req_owns_entry_lock(storage_key)
+ ;
+ locked = !preheld &&
+ ucache_available() &&
+ ucache_acquire_entry_lock(storage_key)
+ ;
+
+ found = ucache_fetch_if_present_api(record, return_value);
+ if (found) {
+ if (locked) {
+ ucache_release_remember_lock(storage_key);
+ }
+
+ return;
+ }
+
+ if (EG(exception)) {
+ if (locked) {
+ ucache_release_remember_lock(storage_key);
+ }
+
+ RETURN_THROWS();
+ }
+
+ zend_try {
+ cb_failed = !ucache_invoke_remember_cb(
+ key, record, &fci, &fcc, ttl, &result
+ );
+ } zend_catch {
+ if (locked) {
+ ucache_release_remember_lock(storage_key);
+ }
+
+ zend_bailout();
+ } zend_end_try();
+
+ if (cb_failed) {
+ zval_ptr_dtor(&result);
+
+ if (locked) {
+ ucache_release_remember_lock(storage_key);
+ }
+
+ RETURN_THROWS();
+ }
+
+ if (locked) {
+ ucache_release_remember_lock(storage_key);
+ }
+
+ if (EG(exception)) {
+ if (Z_TYPE(result) != IS_UNDEF) {
+ zval_ptr_dtor(&result);
+ }
+
+ RETURN_THROWS();
+ }
+
+ if (Z_TYPE(result) == IS_UNDEF) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY_VALUE(&result);
+}
+
+UCACHE_METHOD_BODY(getPoolStatus)
+{
+ ucache_obj *cache;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ cache = ucache_obj_from_this(ZEND_THIS);
+ if (cache == NULL) {
+ RETURN_THROWS();
+ }
+
+ ucache_return_pool_status(cache, return_value);
+
+ if (EG(exception)) {
+ RETURN_THROWS();
+ }
+}
+
+static zend_long ucache_ini_parse_quantity(const zend_ini_entry *entry, const zend_string *new_value, int stage)
+{
+ zend_string *errstr;
+ zend_long val = zend_ini_parse_quantity(new_value, &errstr);
+
+ if (errstr != NULL) {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("Invalid \"%s\" setting. %s", ZSTR_VAL(entry->name), ZSTR_VAL(errstr));
+ }
+
+ zend_string_release(errstr);
+ }
+
+ return val;
+}
+
+static ZEND_INI_MH(OnUpdateUserCacheShmSize)
+{
+ zend_long *p, size;
+
+ p = (zend_long *) ZEND_INI_GET_ADDR();
+ size = ucache_ini_parse_quantity(entry, new_value, stage);
+
+ if (size < 0) {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.shm_size must be greater than or equal to 0, " ZEND_LONG_FMT " given", size);
+ }
+
+ return FAILURE;
+ }
+
+ if ((uint64_t) size > UCACHE_SHM_SIZE_MAX) {
+ size = (zend_long) UCACHE_SHM_SIZE_MAX;
+
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.shm_size is limited to " ZEND_LONG_FMT " bytes; clamping", size);
+ }
+ }
+
+ if (size != 0 && (size_t) size < ucache_shm_size_min() && stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn(
+ "user_cache.shm_size (" ZEND_LONG_FMT ") is below the minimum cache layout (%zu bytes); the cache will be unavailable",
+ size,
+ ucache_shm_size_min()
+ );
+ }
+
+ *p = size;
+
+ return SUCCESS;
+}
+
+static ZEND_INI_MH(OnUpdateUserCacheEvictionPolicy)
+{
+ zend_long *p = (zend_long *) ZEND_INI_GET_ADDR();
+
+ if (zend_string_equals_literal_ci(new_value, "lru")) {
+ *p = UCACHE_EVICTION_POLICY_LRU;
+ } else if (zend_string_equals_literal_ci(new_value, "clear")) {
+ *p = UCACHE_EVICTION_POLICY_CLEAR;
+ } else if (ZSTR_LEN(new_value) == 0 || zend_string_equals_literal_ci(new_value, "none")) {
+ *p = UCACHE_EVICTION_POLICY_NONE;
+ } else {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.eviction_policy must be one of \"lru\", \"clear\" or \"none\"");
+ }
+
+ return FAILURE;
+ }
+
+ return SUCCESS;
+}
+
+static ZEND_INI_MH(OnUpdateUserCacheLockfilePath)
+{
+ if (new_value == NULL) {
+ return FAILURE;
+ }
+
+#ifndef ZEND_WIN32
+ if (!IS_ABSOLUTE_PATH(ZSTR_VAL(new_value), ZSTR_LEN(new_value))) {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.lockfile_path must be an absolute path, \"%s\" given", ZSTR_VAL(new_value));
+ }
+
+ return FAILURE;
+ }
+#endif
+
+ return OnUpdateString(entry, new_value, mh_arg1, mh_arg2, mh_arg3, stage);
+}
+
+static ZEND_INI_MH(OnUpdateUserCachePreferredMemoryModel)
+{
+ char **p = (char **) ZEND_INI_GET_ADDR();
+
+ if (new_value == NULL || ZSTR_LEN(new_value) == 0) {
+ *p = NULL;
+
+ return SUCCESS;
+ }
+
+ if (!ucache_mem_model_is_available(ZSTR_VAL(new_value))) {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn(
+ "user_cache.preferred_memory_model \"%s\" is not a memory model available on this platform",
+ ZSTR_VAL(new_value)
+ );
+ }
+
+ return FAILURE;
+ }
+
+ *p = ZSTR_VAL(new_value);
+
+ return SUCCESS;
+}
+
+static ZEND_INI_MH(OnUpdateUserCacheEntriesHint)
+{
+ zend_long *p, hint;
+
+ p = (zend_long *) ZEND_INI_GET_ADDR();
+ hint = ucache_ini_parse_quantity(entry, new_value, stage);
+
+ if (hint < 0) {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.entries_hint must be greater than or equal to 0, " ZEND_LONG_FMT " given", hint);
+ }
+
+ return FAILURE;
+ }
+
+ if (hint > UCACHE_ENTRIES_HINT_MAX) {
+ hint = UCACHE_ENTRIES_HINT_MAX;
+
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.entries_hint is limited to %d; clamping", UCACHE_ENTRIES_HINT_MAX);
+ }
+ }
+
+ *p = hint;
+
+ return SUCCESS;
+}
+
+static ZEND_INI_MH(OnUpdateUserCacheLockLeaseMax)
+{
+ zend_long *p, lease_max;
+
+ p = (zend_long *) ZEND_INI_GET_ADDR();
+ lease_max = ucache_ini_parse_quantity(entry, new_value, stage);
+
+ if (lease_max < 0) {
+ if (stage != ZEND_INI_STAGE_DEACTIVATE) {
+ ucache_warn("user_cache.lock_lease_max must be greater than or equal to 0, " ZEND_LONG_FMT " given", lease_max);
+ }
+
+ return FAILURE;
+ }
+
+ *p = lease_max;
+
+ return SUCCESS;
+}
+
+static PHP_MINIT_FUNCTION(user_cache)
+{
+#ifdef ZTS
+ if (!ucache_globals_allocated()) {
+ php_ucache_globals_startup();
+ }
+#endif
+
+ REGISTER_INI_ENTRIES();
+
+ ucache_minit();
+
+ return SUCCESS;
+}
+
+static PHP_MSHUTDOWN_FUNCTION(user_cache)
+{
+ ucache_mshutdown();
+
+ UNREGISTER_INI_ENTRIES();
+
+ return SUCCESS;
+}
+
+static PHP_RSHUTDOWN_FUNCTION(user_cache)
+{
+ return ucache_rshutdown();
+}
+
+static PHP_RINIT_FUNCTION(user_cache)
+{
+ return ucache_rinit();
+}
+
+static PHP_MINFO_FUNCTION(user_cache)
+{
+ const ucache_storage *storage = &ucache_active_ctx()->storage;
+ bool started = ucache_storage_startup_is_complete(storage);
+
+ php_info_print_table_start();
+ php_info_print_table_row(2, "UserCache support", "enabled");
+ php_info_print_table_row(
+ 2,
+ "Active memory model",
+ started && storage->handler_name != NULL
+ ? storage->handler_name
+ : "none"
+ );
+ php_info_print_table_row(2, "Active lock model", started ? ucache_lock_model_name(storage) : "none");
+ php_info_print_table_end();
+
+ DISPLAY_INI_ENTRIES();
+}
+
+void ucache_use_req_method_handlers(void)
+{
+ static const struct {
+ const char *name;
+ size_t name_len;
+ zif_handler handler;
+ } methods[] = {
+ UCACHE_REQ_HANDLER("__construct", __construct),
+ UCACHE_REQ_HANDLER("haspool", hasPool),
+ UCACHE_REQ_HANDLER("getpool", getPool),
+ UCACHE_REQ_HANDLER("getpools", getPools),
+ UCACHE_REQ_HANDLER("deletepool", deletePool),
+ UCACHE_REQ_HANDLER("getstatus", getStatus),
+ UCACHE_REQ_HANDLER("store", store),
+ UCACHE_REQ_HANDLER("add", add),
+ UCACHE_REQ_HANDLER("storemultiple", storeMultiple),
+ UCACHE_REQ_HANDLER("increment", increment),
+ UCACHE_REQ_HANDLER("decrement", decrement),
+ UCACHE_REQ_HANDLER("fetch", fetch),
+ UCACHE_REQ_HANDLER("fetchmultiple", fetchMultiple),
+ UCACHE_REQ_HANDLER("has", has),
+ UCACHE_REQ_HANDLER("delete", delete),
+ UCACHE_REQ_HANDLER("deletemultiple", deleteMultiple),
+ UCACHE_REQ_HANDLER("clear", clear),
+ UCACHE_REQ_HANDLER("lock", lock),
+ UCACHE_REQ_HANDLER("unlock", unlock),
+ UCACHE_REQ_HANDLER("remember", remember),
+ UCACHE_REQ_HANDLER("getpoolstatus", getPoolStatus),
+ };
+ zend_function *func;
+ uint32_t i;
+
+ for (i = 0; i < sizeof(methods) / sizeof(methods[0]); i++) {
+ func = zend_hash_str_find_ptr(&ucache_ce->function_table, methods[i].name, methods[i].name_len);
+
+ ZEND_ASSERT(func != NULL && func->type == ZEND_INTERNAL_FUNCTION);
+
+ func->internal_function.handler = methods[i].handler;
+ }
+}
+
+const php_ucache_safe_direct_handlers *ucache_safe_direct_find_handlers(
+ zend_class_entry *ce,
+ zend_class_entry **base_ce_ptr)
+{
+ const php_ucache_safe_direct_handlers *handlers;
+
+ if (!ucache_safe_direct_handlers_initialized) {
+ return NULL;
+ }
+
+ while (ce != NULL) {
+ handlers = zend_hash_index_find_ptr(
+ &ucache_safe_direct_handler_table,
+ (zend_ulong) (uintptr_t) ce
+ );
+ if (handlers != NULL) {
+ if (base_ce_ptr != NULL) {
+ *base_ce_ptr = ce;
+ }
+
+ return handlers;
+ }
+
+ ce = ce->parent;
+ }
+
+ return NULL;
+}
+
+uint64_t ucache_resolve_pid(void)
+{
+ uint64_t pid = ucache_cur_pid();
+
+#ifndef ZEND_WIN32
+ if (UNEXPECTED(ucache_self_pid_uncached)) {
+ return pid;
+ }
+#endif
+
+ ucache_self_pid = pid;
+
+ return pid;
+}
+
+ZEND_API void php_ucache_safe_direct_register_class(
+ zend_class_entry *ce,
+ const php_ucache_safe_direct_handlers *handlers)
+{
+ php_ucache_safe_direct_handlers handlers_copy;
+
+ if (ce == NULL ||
+ handlers == NULL ||
+ handlers->copy == NULL ||
+ handlers->state_serialize == NULL ||
+ handlers->state_unserialize == NULL
+ ) {
+ return;
+ }
+
+ ucache_safe_direct_handlers_init();
+
+ handlers_copy = *handlers;
+
+ zend_hash_index_add_mem(
+ &ucache_safe_direct_handler_table,
+ (zend_ulong) (uintptr_t) ce,
+ &handlers_copy,
+ sizeof(handlers_copy)
+ );
+}
+
+UCACHE_DEFINE_SAFE_DIRECT_HANDLER_GETTER(copy)
+
+#ifdef ZTS
+size_t php_ucache_globals_size(void)
+{
+ return sizeof(ucache_globals);
+}
+
+void php_ucache_globals_startup(void)
+{
+ user_cache_globals_id = ts_allocate_fast_id(
+ &user_cache_globals_id,
+ &user_cache_globals_offset,
+ sizeof(ucache_globals),
+ ucache_globals_ctor,
+ ucache_globals_dtor
+ );
+}
+#endif /* ZTS */
+
+UCACHE_METHOD_ENTRY(__construct)
+
+UCACHE_METHOD_ENTRY(hasPool)
+
+UCACHE_METHOD_ENTRY(getPool)
+
+UCACHE_METHOD_ENTRY(getPools)
+
+UCACHE_METHOD_ENTRY(deletePool)
+
+UCACHE_METHOD_ENTRY(getStatus)
+
+UCACHE_METHOD_ENTRY(store)
+
+UCACHE_METHOD_ENTRY(add)
+
+UCACHE_METHOD_ENTRY(storeMultiple)
+
+UCACHE_METHOD_ENTRY(increment)
+
+UCACHE_METHOD_ENTRY(decrement)
+
+UCACHE_METHOD_ENTRY(fetch)
+
+UCACHE_METHOD_ENTRY(fetchMultiple)
+
+UCACHE_METHOD_ENTRY(has)
+
+UCACHE_METHOD_ENTRY(delete)
+
+UCACHE_METHOD_ENTRY(deleteMultiple)
+
+UCACHE_METHOD_ENTRY(clear)
+
+UCACHE_METHOD_ENTRY(lock)
+
+UCACHE_METHOD_ENTRY(unlock)
+
+UCACHE_METHOD_ENTRY(remember)
+
+UCACHE_METHOD_ENTRY(getPoolStatus)
diff --git a/ext/user_cache/user_cache.stub.php b/ext/user_cache/user_cache.stub.php
new file mode 100644
index 000000000000..f944f922ef4e
--- /dev/null
+++ b/ext/user_cache/user_cache.stub.php
@@ -0,0 +1,140 @@
+ */
+ public function getEntryKeys(): array {}
+
+ public function getUsedMemory(): int {}
+}
+
+/**
+ * @strict-properties
+ * @not-serializable
+ */
+final class Cache
+{
+ public static function hasPool(string $pool): bool {}
+
+ public static function getPool(string $pool): Cache {}
+
+ public static function deletePool(string $pool): bool {}
+
+ /** @return array */
+ public static function getPools(): array {}
+
+ public static function getStatus(): CacheStatus {}
+
+ private function __construct() {}
+
+ public function store(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool {}
+
+ public function add(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool {}
+
+ /** @param array $values */
+ public function storeMultiple(array $values, int $ttl = 0): bool {}
+
+ public function increment(string $key, int $step = 1, int $ttl = 0): ?int {}
+
+ public function decrement(string $key, int $step = 1, int $ttl = 0): ?int {}
+
+ public function fetch(string $key, mixed $default = null): mixed {}
+
+ /** @param list $keys */
+ public function fetchMultiple(array $keys, mixed $default = null): array {}
+
+ public function delete(string $key): bool {}
+
+ /** @param list $keys */
+ public function deleteMultiple(array $keys): bool {}
+
+ public function clear(): bool {}
+
+ public function has(string $key): bool {}
+
+ public function lock(string $key, int $lease = 0): bool {}
+
+ public function unlock(string $key): bool {}
+
+ public function remember(string $key, callable $callback, int $ttl = 0): mixed {}
+
+ public function getPoolStatus(): CachePoolStatus {}
+}
+
+}
diff --git a/ext/user_cache/user_cache_alloc_posix.c b/ext/user_cache/user_cache_alloc_posix.c
new file mode 100644
index 000000000000..d3eaf9e3f8a4
--- /dev/null
+++ b/ext/user_cache/user_cache_alloc_posix.c
@@ -0,0 +1,183 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_shm.h"
+
+#ifdef UCACHE_USE_SHM_OPEN
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "ext/random/php_random_csprng.h"
+
+#define UCACHE_POSIX_SHM_NAME_PREFIX "/php_uc."
+#define UCACHE_POSIX_SHM_NAME_BYTES 10
+#define UCACHE_POSIX_SHM_NAME_ATTEMPTS 5
+
+static int ucache_alloc_posix_create_seg(size_t requested_size, ucache_shm_seg **shared_seg_p, const char **err_in);
+static void ucache_alloc_posix_detach_seg(ucache_shm_seg *shared_seg);
+
+const ucache_shm_handlers ucache_alloc_posix_handlers = {
+ ucache_alloc_posix_create_seg,
+ ucache_alloc_posix_detach_seg
+};
+
+static bool ucache_alloc_posix_seg_name(char *buf, size_t buf_size)
+{
+ static const char hexits[] = "0123456789abcdef";
+ unsigned char random_bytes[UCACHE_POSIX_SHM_NAME_BYTES];
+ char hex[sizeof(random_bytes) * 2 + 1];
+ size_t i;
+
+ if (php_random_bytes_silent(random_bytes, sizeof(random_bytes)) == FAILURE) {
+ return false;
+ }
+
+ for (i = 0; i < sizeof(random_bytes); i++) {
+ hex[i * 2] = hexits[random_bytes[i] >> 4];
+ hex[(i * 2) + 1] = hexits[random_bytes[i] & 0xf];
+ }
+
+ hex[sizeof(hex) - 1] = '\0';
+
+ snprintf(buf, buf_size, UCACHE_POSIX_SHM_NAME_PREFIX "%s", hex);
+
+ return true;
+}
+
+static int ucache_alloc_posix_create_seg(size_t requested_size, ucache_shm_seg **shared_seg_p, const char **err_in)
+{
+ ucache_shm_seg *shared_seg;
+ mode_t shared_seg_mode = 0600;
+ uint32_t shared_seg_attempt;
+ int shared_seg_flags = O_RDWR | O_CREAT | O_EXCL,
+ shared_seg_fd = -1;
+ char shared_seg_name[sizeof(UCACHE_POSIX_SHM_NAME_PREFIX) + (UCACHE_POSIX_SHM_NAME_BYTES * 2)];
+ void *mapping;
+
+#if defined(HAVE_SHM_CREATE_LARGEPAGE)
+ size_t shared_seg_largest_idx = 0, shared_seg_sindexes[3] = {0};
+ const size_t entries = sizeof(shared_seg_sindexes) / sizeof(shared_seg_sindexes[0]);
+ int i, shared_seg_sizes;
+
+ shared_seg_sizes = getpagesizes(shared_seg_sindexes, entries);
+
+ if (shared_seg_sizes > 0) {
+ for (i = shared_seg_sizes; i-- > 0; ) {
+ if (shared_seg_sindexes[i] != 0 &&
+ !(requested_size % shared_seg_sindexes[i])
+ ) {
+ shared_seg_largest_idx = i;
+
+ break;
+ }
+ }
+ }
+#endif /* HAVE_SHM_CREATE_LARGEPAGE */
+
+ for (shared_seg_attempt = 0; shared_seg_attempt < UCACHE_POSIX_SHM_NAME_ATTEMPTS; shared_seg_attempt++) {
+ if (!ucache_alloc_posix_seg_name(shared_seg_name, sizeof(shared_seg_name))) {
+ *err_in = "php_random_bytes";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+#if defined(HAVE_SHM_CREATE_LARGEPAGE)
+ if (shared_seg_largest_idx > 0) {
+ shared_seg_fd = shm_create_largepage(shared_seg_name, shared_seg_flags, shared_seg_largest_idx, SHM_LARGEPAGE_ALLOC_DEFAULT, shared_seg_mode);
+ if (shared_seg_fd != -1) {
+ break;
+ }
+ }
+#endif /* HAVE_SHM_CREATE_LARGEPAGE */
+
+ shared_seg_fd = shm_open(shared_seg_name, shared_seg_flags, shared_seg_mode);
+ if (shared_seg_fd != -1) {
+ break;
+ }
+
+ if (errno != EEXIST) {
+ *err_in = "shm_open";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+ }
+
+ if (shared_seg_fd == -1) {
+ *err_in = "shm_open";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (shm_unlink(shared_seg_name) != 0) {
+ close(shared_seg_fd);
+
+ *err_in = "shm_unlink";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (ftruncate(shared_seg_fd, requested_size) != 0) {
+ close(shared_seg_fd);
+
+ *err_in = "ftruncate";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (!ucache_shm_fd_reserve(shared_seg_fd, requested_size)) {
+ close(shared_seg_fd);
+
+ *err_in = "posix_fallocate";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ mapping = mmap(NULL, requested_size, PROT_READ | PROT_WRITE, MAP_SHARED, shared_seg_fd, 0);
+ close(shared_seg_fd);
+ if (mapping == MAP_FAILED) {
+ *err_in = "mmap";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ shared_seg = (ucache_shm_seg *) calloc(1, sizeof(*shared_seg));
+ if (shared_seg == NULL) {
+ munmap(mapping, requested_size);
+
+ *err_in = "calloc";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ shared_seg->p = mapping;
+ shared_seg->size = requested_size;
+ shared_seg->zero_filled = true;
+ *shared_seg_p = shared_seg;
+
+ return UCACHE_ALLOC_SUCCESS;
+}
+
+static void ucache_alloc_posix_detach_seg(ucache_shm_seg *shared_seg)
+{
+ munmap(shared_seg->p, shared_seg->size);
+}
+
+#endif /* UCACHE_USE_SHM_OPEN */
diff --git a/ext/user_cache/user_cache_alloc_shm.c b/ext/user_cache/user_cache_alloc_shm.c
new file mode 100644
index 000000000000..32b70cfdde2f
--- /dev/null
+++ b/ext/user_cache/user_cache_alloc_shm.c
@@ -0,0 +1,83 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_shm.h"
+
+#ifdef UCACHE_USE_SHM
+
+#if defined(__FreeBSD__)
+# include
+#endif
+#include
+#include
+#include
+#include
+
+static int ucache_alloc_shm_create_seg(size_t requested_size, ucache_shm_seg **shared_seg_p, const char **err_in);
+static void ucache_alloc_shm_detach_seg(ucache_shm_seg *shared_seg);
+
+const ucache_shm_handlers ucache_alloc_shm_handlers = {
+ ucache_alloc_shm_create_seg,
+ ucache_alloc_shm_detach_seg
+};
+
+static int ucache_alloc_shm_create_seg(size_t requested_size, ucache_shm_seg **shared_seg_p, const char **err_in)
+{
+ struct shmid_ds sds;
+ ucache_shm_seg *shared_seg;
+ int shmget_flags, seg_id;
+
+ shmget_flags = IPC_CREAT | SHM_R | SHM_W | IPC_EXCL;
+
+ seg_id = shmget(IPC_PRIVATE, requested_size, shmget_flags);
+ if (seg_id == -1) {
+ *err_in = "shmget";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ shared_seg = (ucache_shm_seg *) calloc(1, sizeof(*shared_seg));
+ if (shared_seg == NULL) {
+ shmctl(seg_id, IPC_RMID, &sds);
+
+ *err_in = "calloc";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ shared_seg->p = shmat(seg_id, NULL, 0);
+ if (shared_seg->p == (void *) -1) {
+ shmctl(seg_id, IPC_RMID, &sds);
+ free(shared_seg);
+
+ *err_in = "shmat";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ shmctl(seg_id, IPC_RMID, &sds);
+
+ shared_seg->size = requested_size;
+ shared_seg->zero_filled = true;
+ *shared_seg_p = shared_seg;
+
+ return UCACHE_ALLOC_SUCCESS;
+}
+
+static void ucache_alloc_shm_detach_seg(ucache_shm_seg *shared_seg)
+{
+ shmdt(shared_seg->p);
+}
+
+#endif /* UCACHE_USE_SHM */
diff --git a/ext/user_cache/user_cache_allocator.c b/ext/user_cache/user_cache_allocator.c
new file mode 100644
index 000000000000..4f38b853258d
--- /dev/null
+++ b/ext/user_cache/user_cache_allocator.c
@@ -0,0 +1,670 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_storage.h"
+
+#if ZEND_DEBUG
+# define UCACHE_DEBUG_RESERVED_DATA_END (((uint64_t) 1 << 32) - 64 * 1024)
+#endif
+
+#define UCACHE_BLOCK_MIN_UNITS (UCACHE_BLOCK_MIN_SIZE / UCACHE_OFFSET_UNIT)
+
+#ifdef ZEND_WIN32
+static bool ucache_win32_ensure_committed_locked(ucache_hdr *hdr, uint64_t end_bytes);
+static void ucache_win32_reset_released_tail_locked(ucache_hdr *hdr);
+#endif /* ZEND_WIN32 */
+
+static zend_always_inline void *ucache_block_payload(ucache_block *block)
+{
+ return block + 1;
+}
+
+static zend_always_inline ucache_free_links *ucache_block_links(ucache_block *block)
+{
+ return (ucache_free_links *) ucache_block_payload(block);
+}
+
+static zend_always_inline ucache_free_links *ucache_free_links_at(
+ const ucache_hdr *hdr,
+ uint32_t block_offset)
+{
+ return ucache_block_links(ucache_block_ptr_in_hdr(hdr, block_offset));
+}
+
+static zend_always_inline uint32_t ucache_prev_block_size(const ucache_block *block)
+{
+ uint32_t prev_size;
+
+ memcpy(&prev_size, (const uint8_t *) block - sizeof(prev_size), sizeof(prev_size));
+
+ return prev_size;
+}
+
+static zend_always_inline uint32_t ucache_data_start_offset(const ucache_hdr *hdr)
+{
+ return ucache_offset_from_bytes(hdr->data_offset);
+}
+
+static zend_always_inline bool ucache_block_offset_in_used(
+ uint32_t block_offset,
+ uint32_t data_start,
+ uint32_t used_end)
+{
+ return block_offset >= data_start &&
+ (uint64_t) block_offset + UCACHE_BLOCK_MIN_UNITS <= used_end
+ ;
+}
+
+static zend_always_inline bool ucache_block_size_fits_used(
+ uint32_t block_offset,
+ uint32_t block_size,
+ uint32_t used_end)
+{
+ return block_size >= UCACHE_BLOCK_MIN_SIZE &&
+ block_size % UCACHE_OFFSET_UNIT == 0 &&
+ (uint64_t) block_offset + block_size / UCACHE_OFFSET_UNIT <= used_end
+ ;
+}
+
+static zend_always_inline bool ucache_prev_block_reachable(
+ uint32_t block_offset,
+ uint32_t prev_size,
+ uint32_t data_start)
+{
+ return prev_size >= UCACHE_BLOCK_MIN_SIZE &&
+ prev_size % UCACHE_OFFSET_UNIT == 0 &&
+ block_offset >= data_start &&
+ block_offset - data_start >= prev_size / UCACHE_OFFSET_UNIT
+ ;
+}
+
+static zend_always_inline void ucache_block_set_prev_free_locked(
+ ucache_hdr *hdr,
+ uint32_t block_offset,
+ bool prev_free)
+{
+ ucache_block *block;
+
+ if (block_offset >= ucache_used_end_offset_locked(hdr)) {
+ return;
+ }
+
+ block = ucache_block_ptr_in_hdr(hdr, block_offset);
+
+ if (prev_free) {
+ block->size |= UCACHE_BLOCK_PREV_FREE;
+ } else {
+ block->size &= ~UCACHE_BLOCK_PREV_FREE;
+ }
+}
+
+static zend_always_inline bool ucache_ensure_committed_locked(ucache_hdr *hdr, uint64_t end_bytes)
+{
+#ifdef ZEND_WIN32
+ return ucache_win32_ensure_committed_locked(hdr, end_bytes);
+#else
+ (void) hdr;
+ (void) end_bytes;
+
+ return true;
+#endif
+}
+
+static zend_always_inline void ucache_tail_grown_locked(ucache_hdr *hdr)
+{
+#ifdef ZEND_WIN32
+ hdr->stale_tail_end = MAX(hdr->stale_tail_end, (uint64_t) hdr->data_offset + hdr->next_free);
+#else
+ (void) hdr;
+#endif
+}
+
+static zend_always_inline void ucache_tail_released_locked(ucache_hdr *hdr)
+{
+#ifdef ZEND_WIN32
+ ucache_win32_reset_released_tail_locked(hdr);
+#else
+ (void) hdr;
+#endif
+}
+
+#ifdef ZEND_WIN32
+static bool ucache_win32_ensure_committed_locked(ucache_hdr *hdr, uint64_t end_bytes)
+{
+ ucache_win32_seg *seg =
+ (ucache_win32_seg *) ucache_active_ctx()->storage.seg
+ ;
+
+ if (end_bytes <= seg->committed_bytes) {
+ return true;
+ }
+
+ if (!ucache_win32_commit_to(seg, (size_t) end_bytes)) {
+ hdr->commit_failure_count++;
+ hdr->commit_ceiling = seg->committed_bytes;
+
+ return false;
+ }
+
+ ucache_atomic_store_64(&hdr->committed_end, MAX(hdr->committed_end, seg->committed_bytes));
+
+ hdr->commit_ceiling = 0;
+
+ return true;
+}
+
+static void ucache_win32_reset_released_tail_locked(ucache_hdr *hdr)
+{
+ ucache_win32_seg *seg =
+ (ucache_win32_seg *) ucache_active_ctx()->storage.seg
+ ;
+ uint64_t reset_start, reset_end;
+
+ reset_start = ZEND_MM_ALIGNED_SIZE_EX((uint64_t) hdr->data_offset + hdr->next_free, UCACHE_WIN32_COMMIT_CHUNK);
+ reset_end = MIN(ZEND_MM_ALIGNED_SIZE_EX(hdr->stale_tail_end, UCACHE_WIN32_COMMIT_CHUNK), seg->seg.size);
+
+ if (reset_end < reset_start + UCACHE_WIN32_COMMIT_CHUNK) {
+ return;
+ }
+
+ ucache_win32_reset_range(seg, (size_t) reset_start, (size_t) reset_end);
+
+ hdr->stale_tail_end = reset_start;
+}
+#endif /* ZEND_WIN32 */
+
+static uint32_t ucache_alloc_from_tail_locked(
+ ucache_hdr *hdr,
+ size_t size,
+ uint32_t total_size,
+ const void *src,
+ uint32_t owner)
+{
+ ucache_block *block;
+ uint32_t block_offset;
+
+ if (hdr->next_free > hdr->data_size || total_size > hdr->data_size - hdr->next_free) {
+ return 0;
+ }
+
+ if (!ucache_ensure_committed_locked(hdr, (uint64_t) hdr->data_offset + hdr->next_free + total_size)) {
+ return 0;
+ }
+
+ block_offset = ucache_used_end_offset_locked(hdr);
+ block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ block->size = total_size;
+ block->owner = owner;
+
+ if (src != NULL) {
+ memcpy(ucache_block_payload(block), src, size);
+ }
+
+ hdr->next_free += total_size;
+
+ ucache_tail_grown_locked(hdr);
+
+ return ucache_block_payload_offset(block_offset);
+}
+
+static void ucache_free_list_remove_locked(ucache_hdr *hdr, uint32_t block_offset)
+{
+ ucache_block *block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ ucache_free_links *links = ucache_block_links(block);
+ uint32_t size = ucache_block_size(block), bin = ucache_size_class(size),
+ prev_free = links->prev_free, next_free = links->next_free,
+ data_start = ucache_data_start_offset(hdr), used_end = ucache_used_end_offset_locked(hdr),
+ *bins = ucache_free_bins_ptr(hdr)
+ ;
+
+ ZEND_ASSERT(hdr->free_list_bytes >= size && bin < hdr->free_bin_count);
+ ZEND_ASSERT(prev_free == 0 || ucache_block_offset_in_used(prev_free, data_start, used_end));
+ ZEND_ASSERT(next_free == 0 || ucache_block_offset_in_used(next_free, data_start, used_end));
+
+ hdr->free_list_bytes = hdr->free_list_bytes >= size ? hdr->free_list_bytes - size : 0;
+
+ if (prev_free != 0) {
+ if (ucache_block_offset_in_used(prev_free, data_start, used_end)) {
+ ucache_free_links_at(hdr, prev_free)->next_free = next_free;
+ }
+ } else if (bin < hdr->free_bin_count) {
+ bins[bin] = next_free;
+ }
+
+ if (next_free != 0 && ucache_block_offset_in_used(next_free, data_start, used_end)) {
+ ucache_free_links_at(hdr, next_free)->prev_free = prev_free;
+ }
+
+ if (bin < hdr->free_bin_count && bins[bin] == 0) {
+ ucache_free_bin_mask_ptr(hdr)[bin / 32U] &= ~(1U << (bin % 32U));
+ }
+
+ block->size &= ~UCACHE_BLOCK_FREE;
+}
+
+static void ucache_free_list_insert_locked(
+ ucache_hdr *hdr,
+ uint32_t block_offset,
+ uint32_t size,
+ bool prev_free)
+{
+ ucache_block *block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ ucache_free_links *links = ucache_block_links(block);
+ uint32_t bin = ucache_size_class(size), next_free,
+ data_start = ucache_data_start_offset(hdr), used_end = ucache_used_end_offset_locked(hdr),
+ *bins = ucache_free_bins_ptr(hdr)
+ ;
+
+ ZEND_ASSERT((uint64_t) hdr->free_list_bytes + size <= hdr->next_free && bin < hdr->free_bin_count);
+ ZEND_ASSERT(ucache_block_size_fits_used(block_offset, size, used_end));
+
+ if (bin >= hdr->free_bin_count || !ucache_block_size_fits_used(block_offset, size, used_end)) {
+ return;
+ }
+
+ next_free = bins[bin];
+
+ ZEND_ASSERT(next_free == 0 || ucache_block_offset_in_used(next_free, data_start, used_end));
+
+ if (next_free != 0 && !ucache_block_offset_in_used(next_free, data_start, used_end)) {
+ next_free = 0;
+ }
+
+ hdr->free_list_bytes += size;
+
+ block->size = size | UCACHE_BLOCK_FREE | (prev_free ? UCACHE_BLOCK_PREV_FREE : 0);
+ block->owner = UCACHE_BLOCK_OWNER_NONE;
+
+ memcpy((uint8_t *) block + size - sizeof(uint32_t), &size, sizeof(size));
+
+ links->prev_free = 0;
+ links->next_free = next_free;
+ if (next_free != 0) {
+ ucache_free_links_at(hdr, next_free)->prev_free = block_offset;
+ }
+
+ bins[bin] = block_offset;
+ ucache_free_bin_mask_ptr(hdr)[bin / 32U] |= 1U << (bin % 32U);
+
+ ucache_block_set_prev_free_locked(hdr, ucache_offset_after(block_offset, size), true);
+}
+
+static void ucache_trim_tail_locked(ucache_hdr *hdr, uint32_t block_offset)
+{
+ ucache_block *block = ucache_block_ptr_in_hdr(hdr, block_offset), *prev;
+ uint32_t data_start = ucache_data_start_offset(hdr), prev_size, prev_offset;
+
+ while (ucache_block_prev_is_free(block)) {
+ prev_size = ucache_prev_block_size(block);
+
+ ZEND_ASSERT(ucache_prev_block_reachable(block_offset, prev_size, data_start));
+
+ if (!ucache_prev_block_reachable(block_offset, prev_size, data_start)) {
+ break;
+ }
+
+ prev_offset = block_offset - ucache_offset_from_bytes(prev_size);
+ prev = ucache_block_ptr_in_hdr(hdr, prev_offset);
+
+ ZEND_ASSERT(ucache_block_is_free(prev) && ucache_block_size(prev) == prev_size);
+
+ if (!ucache_block_is_free(prev) || ucache_block_size(prev) != prev_size) {
+ break;
+ }
+
+ block_offset = prev_offset;
+ block = prev;
+
+ ucache_free_list_remove_locked(hdr, block_offset);
+ }
+
+ hdr->next_free = ucache_offset_bytes(block_offset) - hdr->data_offset;
+
+ ucache_tail_released_locked(hdr);
+}
+
+static uint32_t ucache_free_bin_from(const ucache_hdr *hdr, uint32_t bin)
+{
+ const uint32_t *mask = ucache_free_bin_mask_ptr(hdr);
+ uint32_t word = bin / 32U, words = ucache_free_bin_words(hdr->free_bin_count), bits;
+
+ if (bin >= hdr->free_bin_count) {
+ return UINT32_MAX;
+ }
+
+ bits = mask[word] & (~0U << (bin % 32U));
+
+ while (bits == 0) {
+ if (++word == words) {
+ return UINT32_MAX;
+ }
+
+ bits = mask[word];
+ }
+
+ return word * 32U + (uint32_t) zend_ulong_ntz((zend_ulong) bits);
+}
+
+static uint32_t ucache_free_block_at_least_locked(
+ const ucache_hdr *hdr,
+ uint32_t total_size)
+{
+ uint32_t bin;
+
+ ZEND_ASSERT(total_size == ucache_size_class_round_up(total_size));
+
+ if (hdr->free_list_bytes < total_size) {
+ return 0;
+ }
+
+ bin = ucache_free_bin_from(hdr, ucache_size_class(total_size));
+ if (bin == UINT32_MAX) {
+ return 0;
+ }
+
+ ZEND_ASSERT(ucache_free_bins_ptr(hdr)[bin] != 0);
+
+ return ucache_free_bins_ptr(hdr)[bin];
+}
+
+static uint32_t ucache_alloc_from_free_list_locked(
+ ucache_hdr *hdr,
+ size_t size,
+ uint32_t total_size,
+ const void *src,
+ uint32_t owner)
+{
+ ucache_block *block, *next;
+ uint32_t block_offset, block_size, remainder, prev_flag, next_offset, next_size,
+ data_start = ucache_data_start_offset(hdr), used_end = ucache_used_end_offset_locked(hdr)
+ ;
+
+ block_offset = ucache_free_block_at_least_locked(hdr, total_size);
+ if (block_offset == 0) {
+ return 0;
+ }
+
+ ZEND_ASSERT(ucache_block_offset_in_used(block_offset, data_start, used_end));
+
+ if (!ucache_block_offset_in_used(block_offset, data_start, used_end)) {
+ return 0;
+ }
+
+ block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ block_size = ucache_block_size(block);
+ prev_flag = block->size & UCACHE_BLOCK_PREV_FREE;
+
+ ZEND_ASSERT(ucache_block_is_free(block) && block_size >= total_size);
+ ZEND_ASSERT(ucache_block_size_fits_used(block_offset, block_size, used_end));
+
+ if (!ucache_block_is_free(block) ||
+ block_size < total_size ||
+ !ucache_block_size_fits_used(block_offset, block_size, used_end)
+ ) {
+ return 0;
+ }
+
+ ucache_free_list_remove_locked(hdr, block_offset);
+
+ remainder = block_size - total_size;
+ if (remainder >= UCACHE_BLOCK_MIN_SIZE) {
+ next_offset = ucache_offset_after(block_offset, block_size);
+
+ ZEND_ASSERT(next_offset != used_end);
+
+ if (next_offset < used_end) {
+ next = ucache_block_ptr_in_hdr(hdr, next_offset);
+ next_size = ucache_block_size(next);
+
+ if (ucache_block_is_free(next) &&
+ (uint64_t) remainder + next_size <= ucache_block_merge_limit() &&
+ ucache_block_size_fits_used(next_offset, next_size, used_end)
+ ) {
+ remainder += next_size;
+
+ ucache_free_list_remove_locked(hdr, next_offset);
+ }
+ }
+
+ ucache_free_list_insert_locked(hdr, ucache_offset_after(block_offset, total_size), remainder, false);
+
+ block_size = total_size;
+ } else {
+ ucache_block_set_prev_free_locked(hdr, ucache_offset_after(block_offset, block_size), false);
+ }
+
+ block->size = block_size | prev_flag;
+ block->owner = owner;
+
+ if (src != NULL) {
+ memcpy(ucache_block_payload(block), src, size);
+ }
+
+ return ucache_block_payload_offset(block_offset);
+}
+
+#if ZEND_DEBUG
+void ucache_debug_reserve_data_below_4g_locked(ucache_hdr *hdr)
+{
+ uint64_t remaining;
+ uint32_t block_size;
+
+ if (!UCACHE_DEBUG_FAULT("RESERVE_DATA_BELOW_4G") ||
+ UCACHE_DEBUG_RESERVED_DATA_END - hdr->data_offset >= hdr->data_size
+ ) {
+ return;
+ }
+
+ remaining = UCACHE_DEBUG_RESERVED_DATA_END - hdr->data_offset - hdr->next_free;
+ while (remaining >= UCACHE_BLOCK_MIN_SIZE) {
+ block_size = (uint32_t) MIN(remaining, UCACHE_BLOCK_SIZE_MAX) & ~(UCACHE_PLATFORM_ALIGNMENT - 1);
+
+ ucache_alloc_from_tail_locked(hdr, 0, block_size, NULL, UCACHE_BLOCK_OWNER_NONE);
+
+ remaining -= block_size;
+ }
+}
+#endif
+
+uint64_t ucache_committed_bytes_locked(const ucache_hdr *hdr)
+{
+ return MAX(hdr->committed_end, ucache_view_committed_bytes(&ucache_active_ctx()->storage));
+}
+
+uint32_t ucache_free_locked(uint32_t payload_offset)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_block *block, *next, *prev;
+ uint32_t block_offset, size, next_offset, next_size, prev_offset, prev_size, data_start, used_end,
+ merge_limit = ucache_block_merge_limit()
+ ;
+
+ if (hdr == NULL || payload_offset < UCACHE_BLOCK_HDR_UNITS) {
+ return 0;
+ }
+
+ block_offset = ucache_payload_block_offset(payload_offset);
+ data_start = ucache_data_start_offset(hdr);
+ used_end = ucache_used_end_offset_locked(hdr);
+
+ ZEND_ASSERT(ucache_block_offset_in_used(block_offset, data_start, used_end));
+
+ if (!ucache_block_offset_in_used(block_offset, data_start, used_end)) {
+ return 0;
+ }
+
+ block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ if (ucache_block_is_free(block)) {
+ return 0;
+ }
+
+ size = ucache_block_size(block);
+
+ ZEND_ASSERT(ucache_block_size_fits_used(block_offset, size, used_end));
+
+ if (!ucache_block_size_fits_used(block_offset, size, used_end)) {
+ return 0;
+ }
+
+ next_offset = ucache_offset_after(block_offset, size);
+ next = (ucache_block *) ((uint8_t *) block + size);
+ if (next_offset < used_end && ucache_block_is_free(next)) {
+ next_size = ucache_block_size(next);
+
+ ZEND_ASSERT(ucache_block_size_fits_used(next_offset, next_size, used_end));
+
+ if ((uint64_t) size + next_size <= merge_limit &&
+ ucache_block_size_fits_used(next_offset, next_size, used_end)
+ ) {
+ size += next_size;
+
+ ucache_free_list_remove_locked(hdr, next_offset);
+ }
+ }
+
+ if (ucache_offset_after(block_offset, size) == used_end) {
+ ucache_trim_tail_locked(hdr, block_offset);
+
+ return 0;
+ }
+
+ if (ucache_block_prev_is_free(block)) {
+ prev_size = ucache_prev_block_size(block);
+
+ ZEND_ASSERT(ucache_prev_block_reachable(block_offset, prev_size, data_start));
+
+ if ((uint64_t) size + prev_size <= merge_limit &&
+ ucache_prev_block_reachable(block_offset, prev_size, data_start)
+ ) {
+ prev_offset = block_offset - ucache_offset_from_bytes(prev_size);
+ prev = ucache_block_ptr_in_hdr(hdr, prev_offset);
+
+ ZEND_ASSERT(ucache_block_is_free(prev) && ucache_block_size(prev) == prev_size);
+
+ if (ucache_block_is_free(prev) && ucache_block_size(prev) == prev_size) {
+ block_offset = prev_offset;
+ block = prev;
+ size += prev_size;
+
+ ucache_free_list_remove_locked(hdr, block_offset);
+ }
+ }
+ }
+
+ ucache_free_list_insert_locked(hdr, block_offset, size, ucache_block_prev_is_free(block));
+
+ return block_offset;
+}
+
+void ucache_shrink_locked(uint32_t payload_offset, size_t payload_size)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_block *block, *tail;
+ uint32_t block_offset, tail_offset, block_size, used_end;
+ size_t total_size = ucache_block_total_size(payload_size);
+
+ if (hdr == NULL || payload_offset < UCACHE_BLOCK_HDR_UNITS) {
+ return;
+ }
+
+ block_offset = ucache_payload_block_offset(payload_offset);
+ used_end = ucache_used_end_offset_locked(hdr);
+
+ ZEND_ASSERT(ucache_block_offset_in_used(block_offset, ucache_data_start_offset(hdr), used_end));
+
+ if (!ucache_block_offset_in_used(block_offset, ucache_data_start_offset(hdr), used_end)) {
+ return;
+ }
+
+ block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ block_size = ucache_block_size(block);
+
+ ZEND_ASSERT(!ucache_block_is_free(block) && ucache_block_size_fits_used(block_offset, block_size, used_end));
+
+ if (ucache_block_is_free(block) ||
+ !ucache_block_size_fits_used(block_offset, block_size, used_end) ||
+ total_size >= block_size ||
+ block_size - total_size < UCACHE_BLOCK_MIN_SIZE
+ ) {
+ return;
+ }
+
+ block->size = (uint32_t) total_size | (block->size & UCACHE_BLOCK_PREV_FREE);
+
+ tail_offset = ucache_offset_after(block_offset, total_size);
+ tail = ucache_block_ptr_in_hdr(hdr, tail_offset);
+ tail->size = block_size - (uint32_t) total_size;
+ tail->owner = UCACHE_BLOCK_OWNER_NONE;
+
+ (void) ucache_free_locked(ucache_block_payload_offset(tail_offset));
+}
+
+uint32_t ucache_alloc_locked(size_t size, const void *src, uint32_t owner)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ uint32_t total_size, offset;
+ size_t aligned_size;
+
+ if (hdr == NULL || size == 0 || size > UCACHE_BLOCK_SIZE_MAX - sizeof(ucache_block)) {
+ return 0;
+ }
+
+ aligned_size = ucache_block_total_size(size);
+ if (aligned_size > UCACHE_BLOCK_SIZE_MAX) {
+ return 0;
+ }
+
+ total_size = (uint32_t) aligned_size;
+
+ offset = ucache_alloc_from_free_list_locked(
+ hdr,
+ size,
+ total_size,
+ src,
+ owner
+ );
+ if (offset != 0) {
+ return offset;
+ }
+
+ return ucache_alloc_from_tail_locked(
+ hdr,
+ size,
+ total_size,
+ src,
+ owner
+ );
+}
+
+bool ucache_alloc_can_satisfy_locked(size_t size)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ size_t total, tail;
+
+ if (hdr == NULL || size == 0 || size > UCACHE_BLOCK_SIZE_MAX - sizeof(ucache_block)) {
+ return false;
+ }
+
+ total = ucache_block_total_size(size);
+ if (total > UCACHE_BLOCK_SIZE_MAX) {
+ return false;
+ }
+
+ tail = hdr->next_free <= ucache_data_tail_limit_locked(hdr)
+ ? ucache_data_tail_limit_locked(hdr) - hdr->next_free
+ : 0
+ ;
+
+ return tail >= total || ucache_free_block_at_least_locked(hdr, (uint32_t) total) != 0;
+}
diff --git a/ext/user_cache/user_cache_arginfo.h b/ext/user_cache/user_cache_arginfo.h
new file mode 100644
index 000000000000..d9097585b754
--- /dev/null
+++ b/ext/user_cache/user_cache_arginfo.h
@@ -0,0 +1,295 @@
+/* This is a generated file, edit user_cache.stub.php instead.
+ * Stub hash: 0380fb7333e2574f068b563e7117a1ceb0eaf478
+ * Has decl header: yes */
+
+#include "zend_enum.h"
+
+ZEND_BEGIN_ARG_INFO_EX(arginfo_class_UserCache_CacheStatus___construct, 0, 0, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_UserCache_CacheStatus_getAvailability, 0, 0, UserCache\\CacheAvailability, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_CacheStatus_getConfiguredMemory, 0, 0, IS_LONG, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_CacheStatus_getSharedMemorySize arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getUsedMemory arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getFreeMemory arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getWastedMemory arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getEntryCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getEntryCapacity arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getTombstoneCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getExpungeCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getEvictionCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getStoreFailureCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getGraphPinSlotsInUse arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getGraphPinnedReferences arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getDeadPinOwnersReclaimed arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getDeadPinsStripped arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getCommittedMemory arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CacheStatus_getCommitFailureCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+#define arginfo_class_UserCache_CachePoolStatus___construct arginfo_class_UserCache_CacheStatus___construct
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_CachePoolStatus_getPoolName, 0, 0, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_CachePoolStatus_getEntryCount arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_CachePoolStatus_getEntryKeys, 0, 0, IS_ARRAY, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_CachePoolStatus_getUsedMemory arginfo_class_UserCache_CacheStatus_getConfiguredMemory
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_hasPool, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, pool, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_UserCache_Cache_getPool, 0, 1, UserCache\\Cache, 0)
+ ZEND_ARG_TYPE_INFO(0, pool, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_Cache_deletePool arginfo_class_UserCache_Cache_hasPool
+
+#define arginfo_class_UserCache_Cache_getPools arginfo_class_UserCache_CachePoolStatus_getEntryKeys
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_UserCache_Cache_getStatus, 0, 0, UserCache\\CacheStatus, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_Cache___construct arginfo_class_UserCache_CacheStatus___construct
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_store, 0, 2, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
+ ZEND_ARG_TYPE_MASK(0, value, MAY_BE_NULL|MAY_BE_BOOL|MAY_BE_LONG|MAY_BE_DOUBLE|MAY_BE_STRING|MAY_BE_ARRAY|MAY_BE_OBJECT, NULL)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, ttl, IS_LONG, 0, "0")
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_Cache_add arginfo_class_UserCache_Cache_store
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_storeMultiple, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, values, IS_ARRAY, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, ttl, IS_LONG, 0, "0")
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_increment, 0, 1, IS_LONG, 1)
+ ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, step, IS_LONG, 0, "1")
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, ttl, IS_LONG, 0, "0")
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_Cache_decrement arginfo_class_UserCache_Cache_increment
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_fetch, 0, 1, IS_MIXED, 0)
+ ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, default, IS_MIXED, 0, "null")
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_fetchMultiple, 0, 1, IS_ARRAY, 0)
+ ZEND_ARG_TYPE_INFO(0, keys, IS_ARRAY, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, default, IS_MIXED, 0, "null")
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_delete, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_deleteMultiple, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, keys, IS_ARRAY, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_clear, 0, 0, _IS_BOOL, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_Cache_has arginfo_class_UserCache_Cache_delete
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_lock, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, lease, IS_LONG, 0, "0")
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_UserCache_Cache_unlock arginfo_class_UserCache_Cache_delete
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_UserCache_Cache_remember, 0, 2, IS_MIXED, 0)
+ ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, ttl, IS_LONG, 0, "0")
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_UserCache_Cache_getPoolStatus, 0, 0, UserCache\\CachePoolStatus, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_METHOD(UserCache_CacheStatus, __construct);
+ZEND_METHOD(UserCache_CacheStatus, getAvailability);
+ZEND_METHOD(UserCache_CacheStatus, getConfiguredMemory);
+ZEND_METHOD(UserCache_CacheStatus, getSharedMemorySize);
+ZEND_METHOD(UserCache_CacheStatus, getUsedMemory);
+ZEND_METHOD(UserCache_CacheStatus, getFreeMemory);
+ZEND_METHOD(UserCache_CacheStatus, getWastedMemory);
+ZEND_METHOD(UserCache_CacheStatus, getEntryCount);
+ZEND_METHOD(UserCache_CacheStatus, getEntryCapacity);
+ZEND_METHOD(UserCache_CacheStatus, getTombstoneCount);
+ZEND_METHOD(UserCache_CacheStatus, getExpungeCount);
+ZEND_METHOD(UserCache_CacheStatus, getEvictionCount);
+ZEND_METHOD(UserCache_CacheStatus, getStoreFailureCount);
+ZEND_METHOD(UserCache_CacheStatus, getGraphPinSlotsInUse);
+ZEND_METHOD(UserCache_CacheStatus, getGraphPinnedReferences);
+ZEND_METHOD(UserCache_CacheStatus, getDeadPinOwnersReclaimed);
+ZEND_METHOD(UserCache_CacheStatus, getDeadPinsStripped);
+ZEND_METHOD(UserCache_CacheStatus, getCommittedMemory);
+ZEND_METHOD(UserCache_CacheStatus, getCommitFailureCount);
+ZEND_METHOD(UserCache_CachePoolStatus, __construct);
+ZEND_METHOD(UserCache_CachePoolStatus, getPoolName);
+ZEND_METHOD(UserCache_CachePoolStatus, getEntryCount);
+ZEND_METHOD(UserCache_CachePoolStatus, getEntryKeys);
+ZEND_METHOD(UserCache_CachePoolStatus, getUsedMemory);
+ZEND_METHOD(UserCache_Cache, hasPool);
+ZEND_METHOD(UserCache_Cache, getPool);
+ZEND_METHOD(UserCache_Cache, deletePool);
+ZEND_METHOD(UserCache_Cache, getPools);
+ZEND_METHOD(UserCache_Cache, getStatus);
+ZEND_METHOD(UserCache_Cache, __construct);
+ZEND_METHOD(UserCache_Cache, store);
+ZEND_METHOD(UserCache_Cache, add);
+ZEND_METHOD(UserCache_Cache, storeMultiple);
+ZEND_METHOD(UserCache_Cache, increment);
+ZEND_METHOD(UserCache_Cache, decrement);
+ZEND_METHOD(UserCache_Cache, fetch);
+ZEND_METHOD(UserCache_Cache, fetchMultiple);
+ZEND_METHOD(UserCache_Cache, delete);
+ZEND_METHOD(UserCache_Cache, deleteMultiple);
+ZEND_METHOD(UserCache_Cache, clear);
+ZEND_METHOD(UserCache_Cache, has);
+ZEND_METHOD(UserCache_Cache, lock);
+ZEND_METHOD(UserCache_Cache, unlock);
+ZEND_METHOD(UserCache_Cache, remember);
+ZEND_METHOD(UserCache_Cache, getPoolStatus);
+
+static const zend_function_entry class_UserCache_CacheStatus_methods[] = {
+ ZEND_ME(UserCache_CacheStatus, __construct, arginfo_class_UserCache_CacheStatus___construct, ZEND_ACC_PRIVATE)
+ ZEND_ME(UserCache_CacheStatus, getAvailability, arginfo_class_UserCache_CacheStatus_getAvailability, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getConfiguredMemory, arginfo_class_UserCache_CacheStatus_getConfiguredMemory, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getSharedMemorySize, arginfo_class_UserCache_CacheStatus_getSharedMemorySize, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getUsedMemory, arginfo_class_UserCache_CacheStatus_getUsedMemory, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getFreeMemory, arginfo_class_UserCache_CacheStatus_getFreeMemory, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getWastedMemory, arginfo_class_UserCache_CacheStatus_getWastedMemory, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getEntryCount, arginfo_class_UserCache_CacheStatus_getEntryCount, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getEntryCapacity, arginfo_class_UserCache_CacheStatus_getEntryCapacity, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getTombstoneCount, arginfo_class_UserCache_CacheStatus_getTombstoneCount, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getExpungeCount, arginfo_class_UserCache_CacheStatus_getExpungeCount, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getEvictionCount, arginfo_class_UserCache_CacheStatus_getEvictionCount, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getStoreFailureCount, arginfo_class_UserCache_CacheStatus_getStoreFailureCount, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getGraphPinSlotsInUse, arginfo_class_UserCache_CacheStatus_getGraphPinSlotsInUse, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getGraphPinnedReferences, arginfo_class_UserCache_CacheStatus_getGraphPinnedReferences, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getDeadPinOwnersReclaimed, arginfo_class_UserCache_CacheStatus_getDeadPinOwnersReclaimed, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getDeadPinsStripped, arginfo_class_UserCache_CacheStatus_getDeadPinsStripped, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getCommittedMemory, arginfo_class_UserCache_CacheStatus_getCommittedMemory, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CacheStatus, getCommitFailureCount, arginfo_class_UserCache_CacheStatus_getCommitFailureCount, ZEND_ACC_PUBLIC)
+ ZEND_FE_END
+};
+
+static const zend_function_entry class_UserCache_CachePoolStatus_methods[] = {
+ ZEND_ME(UserCache_CachePoolStatus, __construct, arginfo_class_UserCache_CachePoolStatus___construct, ZEND_ACC_PRIVATE)
+ ZEND_ME(UserCache_CachePoolStatus, getPoolName, arginfo_class_UserCache_CachePoolStatus_getPoolName, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CachePoolStatus, getEntryCount, arginfo_class_UserCache_CachePoolStatus_getEntryCount, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CachePoolStatus, getEntryKeys, arginfo_class_UserCache_CachePoolStatus_getEntryKeys, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_CachePoolStatus, getUsedMemory, arginfo_class_UserCache_CachePoolStatus_getUsedMemory, ZEND_ACC_PUBLIC)
+ ZEND_FE_END
+};
+
+static const zend_function_entry class_UserCache_Cache_methods[] = {
+ ZEND_ME(UserCache_Cache, hasPool, arginfo_class_UserCache_Cache_hasPool, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
+ ZEND_ME(UserCache_Cache, getPool, arginfo_class_UserCache_Cache_getPool, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
+ ZEND_ME(UserCache_Cache, deletePool, arginfo_class_UserCache_Cache_deletePool, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
+ ZEND_ME(UserCache_Cache, getPools, arginfo_class_UserCache_Cache_getPools, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
+ ZEND_ME(UserCache_Cache, getStatus, arginfo_class_UserCache_Cache_getStatus, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
+ ZEND_ME(UserCache_Cache, __construct, arginfo_class_UserCache_Cache___construct, ZEND_ACC_PRIVATE)
+ ZEND_ME(UserCache_Cache, store, arginfo_class_UserCache_Cache_store, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, add, arginfo_class_UserCache_Cache_add, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, storeMultiple, arginfo_class_UserCache_Cache_storeMultiple, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, increment, arginfo_class_UserCache_Cache_increment, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, decrement, arginfo_class_UserCache_Cache_decrement, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, fetch, arginfo_class_UserCache_Cache_fetch, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, fetchMultiple, arginfo_class_UserCache_Cache_fetchMultiple, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, delete, arginfo_class_UserCache_Cache_delete, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, deleteMultiple, arginfo_class_UserCache_Cache_deleteMultiple, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, clear, arginfo_class_UserCache_Cache_clear, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, has, arginfo_class_UserCache_Cache_has, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, lock, arginfo_class_UserCache_Cache_lock, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, unlock, arginfo_class_UserCache_Cache_unlock, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, remember, arginfo_class_UserCache_Cache_remember, ZEND_ACC_PUBLIC)
+ ZEND_ME(UserCache_Cache, getPoolStatus, arginfo_class_UserCache_Cache_getPoolStatus, ZEND_ACC_PUBLIC)
+ ZEND_FE_END
+};
+
+static zend_class_entry *register_class_UserCache_CacheAvailability(void)
+{
+ zend_class_entry *class_entry = zend_register_internal_enum("UserCache\\CacheAvailability", IS_UNDEF, NULL);
+
+ zend_enum_add_case_cstr(class_entry, "Available", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "DisabledByIni", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "DisabledBySapi", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableBySharedMemoryInitializationFailed", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableByBackendNotInitializedBeforeWorkerStartup", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableByBackendInitializedAfterWorkerStartup", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableByCgiFastCgiBoundary", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableByApacheBoundary", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableByLsapiBoundary", NULL);
+
+ zend_enum_add_case_cstr(class_entry, "UnavailableByUnknownReason", NULL);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_UserCache_CacheStatus(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "UserCache", "CacheStatus", class_UserCache_CacheStatus_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_READONLY_CLASS);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_UserCache_CachePoolStatus(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "UserCache", "CachePoolStatus", class_UserCache_CachePoolStatus_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_READONLY_CLASS);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_UserCache_Cache(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "UserCache", "Cache", class_UserCache_Cache_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE);
+
+ return class_entry;
+}
diff --git a/ext/user_cache/user_cache_decl.h b/ext/user_cache/user_cache_decl.h
new file mode 100644
index 000000000000..90912c46bf97
--- /dev/null
+++ b/ext/user_cache/user_cache_decl.h
@@ -0,0 +1,20 @@
+/* This is a generated file, edit user_cache.stub.php instead.
+ * Stub hash: 0380fb7333e2574f068b563e7117a1ceb0eaf478 */
+
+#ifndef ZEND_USER_CACHE_DECL_0380fb7333e2574f068b563e7117a1ceb0eaf478_H
+#define ZEND_USER_CACHE_DECL_0380fb7333e2574f068b563e7117a1ceb0eaf478_H
+
+typedef enum zend_enum_UserCache_CacheAvailability {
+ ZEND_ENUM_UserCache_CacheAvailability_Available = 1,
+ ZEND_ENUM_UserCache_CacheAvailability_DisabledByIni = 2,
+ ZEND_ENUM_UserCache_CacheAvailability_DisabledBySapi = 3,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableBySharedMemoryInitializationFailed = 4,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableByBackendNotInitializedBeforeWorkerStartup = 5,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableByBackendInitializedAfterWorkerStartup = 6,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableByCgiFastCgiBoundary = 7,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableByApacheBoundary = 8,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableByLsapiBoundary = 9,
+ ZEND_ENUM_UserCache_CacheAvailability_UnavailableByUnknownReason = 10,
+} zend_enum_UserCache_CacheAvailability;
+
+#endif /* ZEND_USER_CACHE_DECL_0380fb7333e2574f068b563e7117a1ceb0eaf478_H */
diff --git a/ext/user_cache/user_cache_entries.c b/ext/user_cache/user_cache_entries.c
new file mode 100644
index 000000000000..afa6a6d5a1ea
--- /dev/null
+++ b/ext/user_cache/user_cache_entries.c
@@ -0,0 +1,3692 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_entries.h"
+
+#include "Zend/zend_closures.h"
+#include "Zend/zend_gc.h"
+
+#define UCACHE_DIRECT_STR_MIN_LEN 4096U
+
+#define UCACHE_EXPUNGE_WRITE_OP_INTERVAL 64U
+#define UCACHE_REHASH_TOMBSTONE_DIVISOR 4U
+#define UCACHE_REHASH_EMPTY_SLOT_DIVISOR 16U
+#define UCACHE_REHASH_RETRY_SKIPS 64U
+
+#define UCACHE_ACCESS_NOW_REFRESH_INTERVAL 1024U
+#define UCACHE_ACCESS_SAMPLE_INTERVAL 64U
+
+#define UCACHE_RECORD_STORE_STR_MIN_LEN 256U
+
+#define UCACHE_MAX_REQ_LOCAL_SLOTS 4096U
+
+#define UCACHE_REQ_PIN_BUDGET_DIVISOR 4U
+
+#define UCACHE_TRY_UNLOCK_ON_BAILOUT(stmt) \
+ do { \
+ zend_try { \
+ stmt \
+ } zend_catch { \
+ ucache_unlock_if_held(); \
+ zend_bailout(); \
+ } zend_end_try(); \
+ } while (0)
+
+typedef enum {
+ UCACHE_FIND_SLOT_IGNORE_EXPIRY = 0,
+ UCACHE_FIND_SLOT_SKIP_EXPIRED,
+ UCACHE_FIND_SLOT_DELETE_EXPIRED
+} ucache_find_slot_expiry_mode;
+
+enum {
+ UCACHE_FETCH_FINISH_USE_REQ_LOCAL_SLOT = 1 << 0,
+ UCACHE_FETCH_FINISH_NO_ALIASES = 1 << 1,
+ UCACHE_FETCH_FINISH_RECORD_COPY = 1 << 2,
+ UCACHE_FETCH_FINISH_RESTORE_HOOKS_RAN = 1 << 3
+};
+
+typedef enum {
+ UCACHE_FETCH_LOCATE_MISS,
+ UCACHE_FETCH_LOCATE_VAL_HIT,
+ UCACHE_FETCH_LOCATE_SLOT,
+ UCACHE_FETCH_LOCATE_UNCACHED
+} ucache_fetch_locate_result;
+
+typedef enum {
+ UCACHE_STORE_ATTEMPT_STORED,
+ UCACHE_STORE_ATTEMPT_RETRY,
+ UCACHE_STORE_ATTEMPT_FAILED
+} ucache_store_attempt_result;
+
+static zend_never_inline uint64_t ucache_clock_now_noinline(void);
+static zend_never_inline void ucache_key_record_release_refcounted_val(ucache_key_record *record, zval *detached);
+static zend_never_inline ucache_optimistic_result ucache_optimistic_locate_fallback(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ ucache_key_record *record,
+ uint64_t seq,
+ uint64_t epoch,
+ bool stamp_access,
+ ucache_entry *snapshot);
+static bool ucache_rehash_locked(ucache_hdr *hdr);
+
+static bool ucache_find_slot_in_hdr_locked(
+ ucache_hdr *hdr,
+ zend_string *key,
+ zend_ulong hash,
+ ucache_find_slot_expiry_mode expiry_mode,
+ uint32_t *slot_idx,
+ bool *found);
+
+static zend_always_inline uint8_t ucache_entry_state(const ucache_entry *entry)
+{
+ return (uint8_t) MIN(ucache_entry_kind(entry), UCACHE_ENTRY_USED);
+}
+
+static zend_always_inline bool ucache_entry_is_empty(const ucache_entry *entry)
+{
+ return ucache_entry_kind(entry) == UCACHE_ENTRY_EMPTY;
+}
+
+static zend_always_inline void ucache_entry_set_val_type(ucache_entry *entry, uint8_t val_type)
+{
+ ucache_entry_set_kind(entry, UCACHE_ENTRY_USED + val_type);
+}
+
+static zend_always_inline uint32_t ucache_pool_bucket_for_key(const char *key, size_t key_len)
+{
+ const char *delim = memchr(key, UCACHE_KEY_DELIM_CHAR, key_len);
+ size_t prefix_len = delim != NULL ? (size_t) (delim - key) + 1 : key_len;
+
+ return (uint32_t) (zend_inline_hash_func(key, prefix_len) & (UCACHE_POOL_BUCKETS - 1));
+}
+
+static zend_always_inline uint32_t ucache_pool_link_ref(uint32_t slot)
+{
+ return slot + 1;
+}
+
+static zend_always_inline void ucache_pool_idx_link_locked(
+ ucache_hdr *hdr,
+ ucache_entry *entry,
+ uint32_t slot)
+{
+ ucache_pool_links *links = ucache_pool_links_ptr(hdr);
+ ucache_pool_links *link = &links[slot];
+ uint32_t bucket = ucache_entry_pool_bucket(entry), ref = ucache_pool_link_ref(slot),
+ head = hdr->pool_bucket_heads[bucket]
+ ;
+
+ ZEND_ASSERT(ucache_pool_link_ref_fits(hdr, head));
+
+ if (!ucache_pool_link_ref_fits(hdr, head)) {
+ head = 0;
+ }
+
+ link->prev = 0;
+ link->next = head;
+ if (head != 0) {
+ links[ucache_pool_link_ref_slot(head)].prev = ref;
+ }
+
+ hdr->pool_bucket_heads[bucket] = ref;
+}
+
+static zend_always_inline uint64_t ucache_load_seq(const ucache_hdr *hdr, zend_string *key)
+{
+ uint64_t seq = ucache_atomic_load_64(&hdr->write_seq);
+#ifdef UCACHE_HAVE_SCALAR_WRITE
+ uint64_t stripe_seq;
+
+ /* Relaxed: stripes are enabled in a write section, ordered by the write_seq acquire load above. */
+ if (UNEXPECTED(UCACHE_ATOMIC_LOAD_32_RELAXED(&hdr->scalar_write_enabled) != 0)) {
+ stripe_seq = ucache_atomic_load_64(
+ &hdr->scalar_write_stripes[ucache_scalar_write_idx(zend_string_hash_val(key))].seq
+ );
+
+ if (((seq | stripe_seq) & 1) != 0) {
+ return UINT64_MAX;
+ }
+
+ seq += stripe_seq;
+ }
+#else
+ (void) key;
+#endif
+
+ return seq;
+}
+
+static zend_always_inline uint64_t ucache_read_seq(const ucache_hdr *hdr, zend_string *key)
+{
+ ucache_atomic_fence_acquire();
+
+ return ucache_load_seq(hdr, key);
+}
+
+static zend_always_inline bool ucache_val_uses_offset(uint8_t val_type)
+{
+ return
+ val_type == UCACHE_VAL_STR ||
+ val_type == UCACHE_VAL_SGRAPH
+ ;
+}
+
+static zend_always_inline void ucache_entry_set_long_zero_filled(ucache_entry *entry, zend_long lval)
+{
+ entry->double_val = 0;
+ entry->long_val = lval;
+}
+
+static zend_always_inline uint8_t *ucache_ptr_in_hdr(
+ const ucache_hdr *hdr,
+ uint32_t offset)
+{
+ return (uint8_t *) hdr + ucache_offset_bytes(offset);
+}
+
+static zend_always_inline const char *ucache_entry_key_in_hdr(
+ const ucache_hdr *hdr,
+ const ucache_entry *entry)
+{
+ return (const char *) hdr + ucache_entry_key_pos(entry);
+}
+
+static zend_always_inline uint32_t ucache_combined_key_offset(uint32_t val_offset, size_t payload_size)
+{
+ return val_offset + (uint32_t) (payload_size >> UCACHE_OFFSET_SHIFT);
+}
+
+static zend_always_inline uint16_t ucache_combined_key_flags(size_t payload_size)
+{
+ return UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY |
+ (uint16_t) ((payload_size & (UCACHE_OFFSET_UNIT - 1)) << UCACHE_ENTRY_KEY_BYTE_SHIFT)
+ ;
+}
+
+static zend_always_inline bool ucache_key_equals(
+ const ucache_hdr *hdr,
+ const ucache_entry *entry,
+ zend_string *key,
+ zend_ulong hash)
+{
+ if (
+ !ucache_entry_is_used(entry) ||
+ entry->hash != ucache_table_hash(hash) ||
+ entry->key_len != ZSTR_LEN(key)
+ ) {
+ return false;
+ }
+
+ return memcmp(
+ ucache_entry_key_in_hdr(hdr, entry),
+ ZSTR_VAL(key),
+ ZSTR_LEN(key)
+ ) == 0;
+}
+
+static zend_always_inline bool ucache_optimistic_key_matches(
+ const ucache_hdr *hdr,
+ size_t key_pos,
+ const zend_string *key)
+{
+ return ucache_bytes_in_bounds(hdr, key_pos, ZSTR_LEN(key)) &&
+ memcmp((const char *) hdr + key_pos, ZSTR_VAL(key), ZSTR_LEN(key)) == 0
+ ;
+}
+
+static zend_always_inline bool ucache_reuse_block_locked(
+ ucache_hdr *hdr,
+ uint32_t reusable_offset,
+ size_t size,
+ uint32_t owner)
+{
+ uint32_t capacity;
+
+ if (reusable_offset == 0) {
+ return false;
+ }
+
+ capacity = ucache_block_payload_capacity(hdr, reusable_offset);
+ if (capacity < size) {
+ return false;
+ }
+
+ if (capacity - size >= UCACHE_BLOCK_MIN_SIZE) {
+ ucache_shrink_locked(reusable_offset, size);
+
+ ucache_pool_bucket_changed_locked(
+ hdr,
+ ucache_entry_pool_bucket(&ucache_entries_ptr(hdr)[owner])
+ );
+ }
+
+ return true;
+}
+
+static zend_always_inline uint32_t ucache_write_payload_locked(
+ ucache_hdr *hdr,
+ uint32_t reusable_offset,
+ size_t size,
+ const void *src,
+ uint32_t owner)
+{
+ if (ucache_reuse_block_locked(hdr, reusable_offset, size, owner)) {
+ memcpy(ucache_ptr_in_hdr(hdr, reusable_offset), src, size);
+
+ return reusable_offset;
+ }
+
+ return ucache_alloc_locked(size, src, owner);
+}
+
+static zend_always_inline void ucache_entry_set_block_owner(const ucache_entry *entry, uint32_t slot)
+{
+ uint32_t val_offset = ucache_entry_val_offset(entry);
+
+ if (val_offset != 0) {
+ ucache_block_ptr(ucache_payload_block_offset(val_offset))->owner = slot;
+ }
+
+ if ((entry->flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) == 0 &&
+ entry->key_offset != 0
+ ) {
+ ucache_block_ptr(ucache_payload_block_offset(entry->key_offset))->owner = slot;
+ }
+}
+
+static zend_always_inline void ucache_expiry_floor_lower_locked(ucache_hdr *hdr, uint32_t expires_at)
+{
+ if (expires_at != 0 && expires_at < hdr->expiry_floor) {
+ hdr->expiry_floor = expires_at;
+ }
+}
+
+static zend_always_inline uint32_t ucache_access_now(void)
+{
+ uint32_t now = UC_G(access_now);
+
+ if (now == 0 || ++UC_G(access_now_touches) >= UCACHE_ACCESS_NOW_REFRESH_INTERVAL) {
+ now = (uint32_t) (ucache_clock_now_noinline() / UCACHE_CLOCK_TICKS_PER_SEC);
+ if (now == 0) {
+ now = 1;
+ }
+
+ UC_G(access_now) = now;
+ UC_G(access_now_touches) = 0;
+ }
+
+ return now;
+}
+
+static zend_always_inline void ucache_record_entry_access(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ uint32_t slot_idx,
+ uint32_t now)
+{
+ uint32_t *stamp, prev;
+
+ stamp = &((uint32_t *) (ucache_entries_ptr(hdr) + capacity))[slot_idx];
+
+ prev = UCACHE_ATOMIC_LOAD_32_RELAXED(stamp);
+ while (prev < now) {
+ if (ucache_atomic_cas_32(stamp, prev, now)) {
+ break;
+ }
+
+ prev = UCACHE_ATOMIC_LOAD_32_RELAXED(stamp);
+ }
+}
+
+static zend_always_inline void ucache_touch_entry_access(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ uint32_t slot_idx)
+{
+ ucache_record_entry_access(hdr, capacity, slot_idx, ucache_access_now());
+}
+
+static zend_always_inline void ucache_touch_cached_entry_access(
+ ucache_hdr *hdr,
+ uint32_t slot_idx,
+ uint16_t *touches)
+{
+ const ucache_storage *storage;
+ uint64_t now;
+
+ if (EXPECTED(++*touches < UCACHE_ACCESS_SAMPLE_INTERVAL)) {
+ return;
+ }
+
+ *touches = 0;
+
+ now = ucache_clock_now_noinline();
+
+ ucache_access_note_time(now);
+
+ storage = &ucache_active_ctx()->storage;
+ if (storage->layout_memo_valid && slot_idx < storage->capacity_memo) {
+ ucache_record_entry_access(
+ hdr,
+ storage->capacity_memo,
+ slot_idx,
+ UC_G(access_now)
+ );
+ }
+}
+
+static zend_always_inline bool ucache_is_expired_now(
+ const ucache_hdr *hdr,
+ const ucache_entry *entry,
+ uint64_t *now)
+{
+ if (!ucache_entry_is_used(entry) || entry->expires_at == 0) {
+ return false;
+ }
+
+ if (*now == 0) {
+ *now = ucache_clock_now_noinline();
+
+ ucache_access_note_time(*now);
+ }
+
+ return ucache_is_expired(entry, ucache_time_rel(hdr, *now));
+}
+
+static zend_always_inline void ucache_note_expired_read(void)
+{
+ UC_G(expired_read_observations)++;
+}
+
+static zend_always_inline bool ucache_scalar_to_zval(
+ uint8_t val_type,
+ const zend_long *lval,
+ const double *dval,
+ zval *return_value)
+{
+ switch (val_type) {
+ case UCACHE_VAL_NULL:
+ ZVAL_NULL(return_value);
+
+ return true;
+ case UCACHE_VAL_TRUE:
+ ZVAL_TRUE(return_value);
+
+ return true;
+ case UCACHE_VAL_FALSE:
+ ZVAL_FALSE(return_value);
+
+ return true;
+ case UCACHE_VAL_LONG:
+ ZVAL_LONG(return_value, *lval);
+
+ return true;
+ case UCACHE_VAL_DOUBLE:
+ ZVAL_DOUBLE(return_value, *dval);
+
+ return true;
+ default:
+ return false;
+ }
+}
+
+static zend_always_inline bool ucache_can_run_userland(void)
+{
+ return !UC_G(lock_held) && UC_G(scalar_write_hdr) == NULL;
+}
+
+static zend_always_inline void ucache_key_record_release_val(ucache_key_record *record, zval *detached)
+{
+ uint8_t kind = record->val_kind;
+
+ if (kind == UCACHE_RECORD_VAL_NONE) {
+ return;
+ }
+
+ record->val_kind = UCACHE_RECORD_VAL_NONE;
+
+ if (Z_REFCOUNTED(record->val)) {
+ ZEND_ASSERT(kind == UCACHE_RECORD_VAL_COPY || kind == UCACHE_RECORD_VAL_PINNED_COPY);
+
+ ucache_key_record_release_refcounted_val(record, detached);
+
+ return;
+ }
+
+ ZVAL_UNDEF(&record->val);
+}
+
+static zend_always_inline bool ucache_entry_written_after(const ucache_entry *entry, uint64_t epoch)
+{
+ return entry->gen > epoch;
+}
+
+static zend_always_inline void ucache_key_record_mark_miss(
+ ucache_key_record *record,
+ uint64_t epoch,
+ zval *detached)
+{
+ ucache_key_record_release_val(record, detached);
+
+ record->mutation_epoch = epoch;
+ record->state = UCACHE_RECORD_MISS;
+ record->access_touches = 0;
+}
+
+static zend_always_inline void ucache_key_record_mark_hit(
+ ucache_key_record *record,
+ uint64_t epoch,
+ uint32_t slot_idx,
+ const ucache_entry *entry,
+ zval *detached)
+{
+ if (record->state != UCACHE_RECORD_HIT || ucache_entry_written_after(entry, record->mutation_epoch)) {
+ ucache_key_record_release_val(record, detached);
+ }
+
+ record->mutation_epoch = epoch;
+ record->slot_idx = slot_idx;
+ record->expires_at = entry->expires_at;
+ record->state = UCACHE_RECORD_HIT;
+ record->access_touches = 0;
+
+ if (record->val_kind == UCACHE_RECORD_VAL_NONE &&
+ ucache_scalar_to_zval(
+ ucache_entry_val_type(entry),
+ &entry->long_val,
+ &entry->double_val,
+ &record->val
+ )
+ ) {
+ record->val_kind = UCACHE_RECORD_VAL_COPY;
+ }
+}
+
+static zend_always_inline void ucache_key_record_mark_stored(
+ ucache_key_record *record,
+ uint64_t gen,
+ uint32_t slot_idx,
+ uint32_t expires_at)
+{
+ ucache_key_record_release_val(record, NULL);
+
+ record->mutation_epoch = gen;
+ record->slot_idx = slot_idx;
+ record->expires_at = expires_at;
+ record->state = UCACHE_RECORD_HIT;
+ record->access_touches = 0;
+}
+
+static zend_always_inline void ucache_key_record_reset_impl(ucache_key_record *record, zval *detached)
+{
+ ucache_key_record_release_val(record, detached);
+
+ record->mutation_epoch = 0;
+ record->state = UCACHE_RECORD_EMPTY;
+}
+
+static zend_always_inline bool ucache_key_record_charge_copy(ucache_key_record *record, size_t bytes)
+{
+ if (!ucache_record_val_fits(bytes)) {
+ return false;
+ }
+
+ UC_G(record_val_bytes) += bytes;
+
+ record->copy_charged_bytes = (uint32_t) bytes;
+
+ return true;
+}
+
+static zend_always_inline void ucache_key_record_cache_copy(
+ ucache_key_record *record,
+ zval *val,
+ size_t bytes)
+{
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE) {
+ return;
+ }
+
+ if (Z_REFCOUNTED_P(val) && !ucache_key_record_charge_copy(record, bytes)) {
+ return;
+ }
+
+ ZVAL_COPY(&record->val, val);
+
+ record->val_kind = UCACHE_RECORD_VAL_COPY;
+}
+
+static zend_always_inline void ucache_key_record_cache_pinned_copy(
+ ucache_key_record *record,
+ zval *val,
+ uint32_t payload_offset,
+ uint32_t val_len)
+{
+ ZEND_ASSERT(Z_REFCOUNTED_P(val));
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE ||
+ !ucache_key_record_charge_copy(record, val_len)
+ ) {
+ return;
+ }
+
+ ZVAL_COPY(&record->val, val);
+
+ Z_EXTRA(record->val) = payload_offset;
+
+ record->val_kind = UCACHE_RECORD_VAL_PINNED_COPY;
+}
+
+static zend_always_inline void ucache_key_record_cache_pinned(
+ ucache_key_record *record,
+ zval *val,
+ uint32_t payload_offset,
+ uint32_t ref_idx)
+{
+ ZEND_ASSERT(!Z_REFCOUNTED_P(val));
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE) {
+ return;
+ }
+
+ ZVAL_COPY_VALUE(&record->val, val);
+
+ Z_EXTRA(record->val) = ref_idx;
+
+ record->pinned_payload_offset = payload_offset;
+ record->val_kind = UCACHE_RECORD_VAL_PINNED;
+}
+
+static zend_always_inline bool ucache_req_holds_sgraph_ref(uint32_t idx, uint32_t payload_offset)
+{
+ return idx < UC_G(sgraph_ref_count) &&
+ UC_G(sgraph_ref_owner_pid) == ucache_cached_pid() &&
+ UC_G(sgraph_refs)[idx].payload_offset == payload_offset &&
+ UC_G(sgraph_refs)[idx].ctx == ucache_active_ctx()
+ ;
+}
+
+static zend_always_inline bool ucache_key_record_val_retained(const ucache_key_record *record)
+{
+ ZEND_ASSERT(record->val_kind != UCACHE_RECORD_VAL_NONE);
+
+ if (!(record->val_kind & UCACHE_RECORD_VAL_PINNED)) {
+ return true;
+ }
+
+ if (!(record->val_kind & UCACHE_RECORD_VAL_COPY)) {
+ return ucache_req_holds_sgraph_ref(
+ Z_EXTRA(record->val),
+ record->pinned_payload_offset
+ );
+ }
+
+ return ucache_req_sgraph_ref_idx(Z_EXTRA(record->val)) != UINT32_MAX;
+}
+
+static zend_always_inline bool ucache_key_record_val_usable(
+ const ucache_hdr *hdr,
+ const ucache_key_record *record)
+{
+ uint64_t now;
+
+ if (record->expires_at != 0) {
+ now = ucache_clock_now_noinline();
+
+ ucache_access_note_time(now);
+
+ if ((uint64_t) record->expires_at <= ucache_time_rel(hdr, now)) {
+ return false;
+ }
+ }
+
+ return ucache_key_record_val_retained(record);
+}
+
+static zend_always_inline void ucache_init_prepared_val(ucache_prepared_val *prepared)
+{
+ memset(prepared, 0, sizeof(*prepared));
+
+ prepared->val_type = UCACHE_VAL_NULL;
+}
+
+static zend_always_inline bool ucache_long_add_overflow(
+ zend_long lhs,
+ zend_long rhs,
+ zend_long *result)
+{
+ *result = (zend_long) ((zend_ulong) lhs + (zend_ulong) rhs);
+
+ return (rhs > 0 && lhs > ZEND_LONG_MAX - rhs) ||
+ (rhs < 0 && lhs < ZEND_LONG_MIN - rhs)
+ ;
+}
+
+static zend_always_inline bool ucache_long_sub_overflow(
+ zend_long lhs,
+ zend_long rhs,
+ zend_long *result)
+{
+ *result = (zend_long) ((zend_ulong) lhs - (zend_ulong) rhs);
+
+ return (rhs > 0 && lhs < ZEND_LONG_MIN + rhs) ||
+ (rhs < 0 && lhs > ZEND_LONG_MAX + rhs)
+ ;
+}
+
+static zend_always_inline bool ucache_rehash_due(const ucache_hdr *hdr)
+{
+ uint32_t empty_slots = hdr->capacity - hdr->count - hdr->tombstone_count;
+
+ return hdr->tombstone_count > hdr->capacity / UCACHE_REHASH_TOMBSTONE_DIVISOR || (
+ hdr->tombstone_count != 0 &&
+ empty_slots < hdr->capacity / UCACHE_REHASH_EMPTY_SLOT_DIVISOR
+ );
+}
+
+static zend_always_inline void ucache_maybe_rehash_locked(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ if (hdr == NULL ||
+ !ucache_hdr_is_initialized_locked() ||
+ !ucache_rehash_due(hdr)
+ ) {
+ return;
+ }
+
+ if (UC_G(rehash_retry_skips) != 0) {
+ UC_G(rehash_retry_skips)--;
+
+ return;
+ }
+
+ if (!ucache_rehash_locked(hdr)) {
+ UC_G(rehash_retry_skips) = UCACHE_REHASH_RETRY_SKIPS;
+ }
+}
+
+static zend_always_inline bool ucache_expunge_due_on_this_write(void)
+{
+ return UC_G(expunge_write_ops) >= UCACHE_EXPUNGE_WRITE_OP_INTERVAL - 1 ||
+ UC_G(expired_read_observations) >= UCACHE_EXPIRED_READ_EXPUNGE_THRESHOLD
+ ;
+}
+
+static zend_always_inline void ucache_maybe_expunge_expired_locked(void)
+{
+ if (EXPECTED(!ucache_expunge_due_on_this_write())) {
+ UC_G(expunge_write_ops)++;
+
+ return;
+ }
+
+ UC_G(expired_read_observations) = 0;
+ UC_G(expunge_write_ops) = 0;
+
+ ucache_expunge_expired_bounded_locked();
+}
+
+static zend_always_inline uint32_t ucache_optimistic_capacity(const ucache_hdr *hdr)
+{
+ const ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (hdr->magic != UCACHE_MAGIC ||
+ !storage->layout_memo_valid ||
+ hdr->capacity != storage->capacity_memo ||
+ hdr->data_offset != storage->data_offset_memo
+ ) {
+ return 0;
+ }
+
+ return storage->capacity_memo;
+}
+
+static zend_always_inline bool ucache_req_pin_fits_budget(const ucache_hdr *hdr, uint32_t payload_len)
+{
+ return (uint64_t) UC_G(sgraph_ref_bytes) + payload_len <= hdr->data_size / UCACHE_REQ_PIN_BUDGET_DIVISOR;
+}
+
+static zend_always_inline uint32_t ucache_fetch_finish_flags_after_decode(uint32_t flags, uint32_t hook_calls_before)
+{
+ if (UNEXPECTED(UC_G(restore_hook_calls) != hook_calls_before)) {
+ flags |= UCACHE_FETCH_FINISH_RESTORE_HOOKS_RAN;
+ }
+
+ return flags;
+}
+
+static zend_always_inline ucache_optimistic_result ucache_optimistic_unrestorable(
+ const ucache_entry *snapshot)
+{
+ UC_G(unrestorable_gen) = snapshot->gen;
+
+ return UCACHE_OPTIMISTIC_UNRESTORABLE;
+}
+
+static zend_always_inline uint32_t ucache_insert_cap(uint32_t capacity)
+{
+ return capacity - capacity / 8;
+}
+
+static zend_always_inline bool ucache_scalar_write_locate(
+ ucache_hdr *hdr,
+ const ucache_key_record *record,
+ uint32_t *slot_idx)
+{
+ zend_string *key = record->storage_key;
+ bool found;
+
+ if (record->state == UCACHE_RECORD_HIT &&
+ record->slot_idx < hdr->capacity &&
+ (
+ record->mutation_epoch == ucache_atomic_load_64(&hdr->mutation_epoch) ||
+ ucache_key_equals(
+ hdr,
+ &ucache_entries_ptr(hdr)[record->slot_idx],
+ key,
+ ZSTR_H(key)
+ )
+ )
+ ) {
+ *slot_idx = record->slot_idx;
+
+ return true;
+ }
+
+ return ucache_find_slot_in_hdr_locked(
+ hdr,
+ key,
+ ZSTR_H(key),
+ UCACHE_FIND_SLOT_IGNORE_EXPIRY,
+ slot_idx,
+ &found
+ ) && found
+ ;
+}
+
+static zend_always_inline bool ucache_key_record_is_cur_miss(const ucache_key_record *record)
+{
+ ucache_hdr *hdr;
+
+ if (record->state != UCACHE_RECORD_MISS) {
+ return false;
+ }
+
+ hdr = ucache_hdr_ptr();
+
+ return hdr != NULL && record->mutation_epoch == ucache_atomic_load_64(&hdr->mutation_epoch);
+}
+
+static zend_always_inline void ucache_scalar_write_release_record_val(ucache_key_record *record)
+{
+ while (UNEXPECTED(Z_REFCOUNTED(record->val))) {
+ ucache_key_record_release_val(record, NULL);
+ }
+}
+
+static zend_always_inline bool ucache_scalar_write_can_skip_sweep(void)
+{
+ if (ucache_expunge_due_on_this_write()) {
+ return false;
+ }
+
+ UC_G(expunge_write_ops)++;
+
+ return true;
+}
+
+static zend_always_inline bool ucache_optimistic_key_record_slot(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ const ucache_key_record *record,
+ bool cur,
+ ucache_entry *snapshot)
+{
+ const ucache_entry *entry;
+ zend_string *key = record->storage_key;
+ uint64_t now;
+
+ if (record->slot_idx >= capacity) {
+ return false;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[record->slot_idx];
+
+ if (!ucache_entry_is_used(entry) ||
+ (
+ !cur &&
+ (
+ entry->hash != ucache_table_hash(ZSTR_H(key)) ||
+ entry->key_len != ZSTR_LEN(key) ||
+ !ucache_optimistic_key_matches(hdr, ucache_entry_key_pos(entry), key)
+ )
+ )
+ ) {
+ return false;
+ }
+
+ if (entry->expires_at != 0) {
+ now = ucache_clock_now_noinline();
+
+ ucache_access_note_time(now);
+
+ if ((uint64_t) entry->expires_at <= ucache_time_rel(hdr, now)) {
+ return false;
+ }
+ }
+
+ *snapshot = *entry;
+
+ return true;
+}
+
+static zend_always_inline ucache_optimistic_result ucache_optimistic_locate(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ ucache_key_record *record,
+ uint64_t seq,
+ uint64_t epoch,
+ bool stamp_access,
+ ucache_entry *snapshot)
+{
+ if (record->state != UCACHE_RECORD_HIT ||
+ record->mutation_epoch != epoch ||
+ !ucache_optimistic_key_record_slot(hdr, capacity, record, true, snapshot)
+ ) {
+ return ucache_optimistic_locate_fallback(hdr, capacity, record, seq, epoch, stamp_access, snapshot);
+ }
+
+ if (ucache_read_seq(hdr, record->storage_key) != seq) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (stamp_access) {
+ ucache_touch_cached_entry_access(hdr, record->slot_idx, &record->access_touches);
+ }
+
+ return UCACHE_OPTIMISTIC_FOUND;
+}
+
+/* Out of line: clock_gettime()'s &local would add a stack protector to every hot caller. */
+static zend_never_inline uint64_t ucache_clock_now_noinline(void)
+{
+ return ucache_clock_now();
+}
+
+static zend_never_inline void ucache_key_record_release_refcounted_val(ucache_key_record *record, zval *detached)
+{
+ zend_refcounted *counted;
+
+ UC_G(record_val_bytes) -= record->copy_charged_bytes;
+
+ if (detached != NULL && Z_COLLECTABLE(record->val)) {
+ ZEND_ASSERT(Z_ISUNDEF_P(detached));
+
+ ZVAL_COPY_VALUE(detached, &record->val);
+ ZVAL_UNDEF(&record->val);
+
+ return;
+ }
+
+ ZEND_ASSERT(!Z_COLLECTABLE(record->val) || ucache_can_run_userland());
+
+ counted = Z_COUNTED(record->val);
+
+ ZVAL_UNDEF(&record->val);
+
+ GC_DTOR(counted);
+}
+
+static bool ucache_optimistic_probe(
+ const ucache_hdr *hdr,
+ uint32_t capacity,
+ zend_string *key,
+ zend_ulong hash,
+ const ucache_entry *entries,
+ ucache_entry *snapshot,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ const ucache_entry *entry;
+ uint64_t now = 0;
+ uint32_t i, step;
+ size_t key_pos;
+
+ i = ucache_table_hash(hash) % capacity;
+
+ for (step = 0; step < capacity; step++) {
+ entry = &entries[i];
+
+ switch (ucache_entry_state(entry)) {
+ case UCACHE_ENTRY_EMPTY:
+ *found = false;
+
+ return true;
+ case UCACHE_ENTRY_USED:
+ if (entry->hash == ucache_table_hash(hash) && entry->key_len == ZSTR_LEN(key)) {
+ key_pos = ucache_entry_key_pos(entry);
+ if (!ucache_bytes_in_bounds(hdr, key_pos, ZSTR_LEN(key))) {
+ return false;
+ }
+
+ if (memcmp((const char *) hdr + key_pos, ZSTR_VAL(key), ZSTR_LEN(key)) == 0) {
+ if (ucache_is_expired_now(hdr, entry, &now)) {
+ ucache_note_expired_read();
+
+ *found = false;
+
+ return true;
+ }
+
+ *snapshot = *entry;
+ *slot_idx = i;
+ *found = true;
+
+ return true;
+ }
+ }
+
+ break;
+ case UCACHE_ENTRY_TOMBSTONE:
+ break;
+ }
+
+ ++i;
+
+ if (i == capacity) {
+ i = 0;
+ }
+ }
+
+ *found = false;
+
+ return true;
+}
+
+static ucache_op_lease *ucache_op_lease_reserve(uint32_t payload_offset)
+{
+ ucache_op_lease *lease;
+ ucache_ctx *ctx = ucache_active_ctx();
+ uint64_t pid = ucache_cached_pid();
+
+ for (lease = UC_G(op_leases); lease != NULL; lease = lease->next) {
+ if (lease->ctx == ctx && lease->payload_offset == payload_offset && lease->owner_pid == pid) {
+ lease->users++;
+
+ return lease;
+ }
+ }
+
+ if (UC_G(op_lease_free) != NULL) {
+ lease = UC_G(op_lease_free);
+
+ UC_G(op_lease_free) = lease->next;
+ } else if (UC_G(op_lease_inline).ctx == NULL) {
+ lease = &UC_G(op_lease_inline);
+ } else {
+ lease = emalloc(sizeof(*lease));
+ }
+
+ lease->ctx = ctx;
+ lease->payload_offset = payload_offset;
+ lease->owner_pid = pid;
+ lease->users = 1;
+ lease->acquired = false;
+ lease->next = UC_G(op_leases);
+
+ UC_G(op_leases) = lease;
+
+ return lease;
+}
+
+static bool ucache_op_lease_acquire(
+ ucache_op_lease *lease,
+ bool *resource_limited)
+{
+ *resource_limited = false;
+
+ if (!lease->acquired) {
+ lease->acquired = ucache_sgraph_acquire_ref(lease->payload_offset, resource_limited);
+ }
+
+ return lease->acquired;
+}
+
+static void ucache_op_lease_release(ucache_op_lease *lease)
+{
+ ucache_op_lease **link;
+ ucache_ctx *prev;
+ bool released;
+
+ ZEND_ASSERT(lease->users == 0);
+
+ if (lease->acquired && lease->owner_pid == ucache_cached_pid()) {
+ prev = ucache_activate_ctx(lease->ctx);
+ released = ucache_sgraph_release_op_ref(lease->payload_offset);
+
+ ucache_restore_ctx(prev);
+
+ if (!released) {
+ return;
+ }
+ }
+
+ link = &UC_G(op_leases);
+ while (*link != lease) {
+ link = &(*link)->next;
+ }
+
+ *link = lease->next;
+ lease->ctx = NULL;
+
+ if (lease != &UC_G(op_lease_inline)) {
+ lease->next = UC_G(op_lease_free);
+
+ UC_G(op_lease_free) = lease;
+ }
+}
+
+static void ucache_op_lease_end(ucache_op_lease *lease)
+{
+ ZEND_ASSERT(lease->users > 0);
+
+ if (--lease->users == 0) {
+ ucache_op_lease_release(lease);
+ }
+}
+
+static bool ucache_materialize_sgraph_locked(
+ const ucache_hdr *hdr,
+ uint32_t val_offset,
+ uint32_t val_len,
+ uint64_t gen,
+ zval *return_value,
+ bool *lock_held,
+ bool *private_copy)
+{
+ ucache_sgraph_snapshot *snapshot = NULL;
+ ucache_op_lease *lease = NULL;
+ bool result, pinned = true, resource_limited = false;
+
+ ZEND_ASSERT(*lock_held && UC_G(lock_held));
+
+ if (UNEXPECTED(UC_G(persistent_exec))) {
+ UCACHE_TRY_UNLOCK_ON_BAILOUT(
+ lease = ucache_op_lease_reserve(val_offset);
+ );
+
+ if (!ucache_op_lease_acquire(lease, &resource_limited)) {
+ ucache_op_lease_end(lease);
+
+ lease = NULL;
+ pinned = false;
+ }
+ } else if (ucache_req_sgraph_ref_idx(val_offset) == UINT32_MAX) {
+ if (ucache_req_pin_fits_budget(hdr, val_len)) {
+ UCACHE_TRY_UNLOCK_ON_BAILOUT(
+ ucache_sgraph_ref_reserve();
+ );
+
+ pinned = ucache_sgraph_acquire_ref(val_offset, &resource_limited);
+ if (pinned) {
+ ucache_register_sgraph_ref(val_offset, val_len);
+ }
+ } else {
+ pinned = false;
+ resource_limited = true;
+ }
+ }
+
+ if (!pinned) {
+ if (!resource_limited) {
+ return false;
+ }
+
+ UCACHE_TRY_UNLOCK_ON_BAILOUT(
+ snapshot = ucache_sgraph_snapshot_create(
+ ucache_ptr_in_hdr(hdr, val_offset),
+ val_len
+ );
+ );
+
+ if (snapshot == NULL) {
+ return false;
+ }
+ }
+
+ ZVAL_UNDEF(return_value);
+
+ ucache_unlock();
+
+ *lock_held = false;
+ *private_copy = snapshot != NULL;
+
+ result = snapshot != NULL
+ ? ucache_sgraph_decode_snapshot(snapshot, gen, return_value)
+ : ucache_sgraph_decode(
+ ucache_ptr_in_hdr(hdr, val_offset),
+ val_len,
+ gen,
+ return_value
+ )
+ ;
+
+ if (!result && Z_TYPE_P(return_value) != IS_UNDEF) {
+ zval_ptr_dtor(return_value);
+
+ ZVAL_UNDEF(return_value);
+ }
+
+ if (lease != NULL) {
+ ucache_op_lease_end(lease);
+ }
+
+ return result;
+}
+
+static bool ucache_fetch_req_local_slot(
+ zend_string *key,
+ uint64_t gen,
+ zval *return_value)
+{
+ ucache_req_local_slot *slot = ucache_find_req_local_slot(key, gen);
+ bool cloned;
+
+ if (slot == NULL || !slot->has_val) {
+ return false;
+ }
+
+ slot->clone_depth++;
+
+ cloned = ucache_clone_req_local_slot_val_known(
+ return_value,
+ &slot->val,
+ slot->has_clone_verdicts ? ucache_req_local_slot_verdicts(slot) : NULL,
+ slot->no_aliases,
+ NULL
+ );
+
+ slot->clone_depth--;
+
+ if (slot->released_while_cloning) {
+ ucache_req_local_slot_free(slot);
+
+ return cloned;
+ }
+
+ if (!cloned) {
+ ucache_release_req_local_slot(key);
+
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_find_slot_in_hdr_locked(
+ ucache_hdr *hdr,
+ zend_string *key,
+ zend_ulong hash,
+ ucache_find_slot_expiry_mode expiry_mode,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ ucache_entry *entries, *entry;
+ uint64_t now = 0;
+ uint32_t i, first_tombstone = UINT32_MAX, step;
+
+ ZEND_ASSERT(
+ UC_G(lock_held) ||
+ (UC_G(scalar_write_hdr) != NULL && expiry_mode == UCACHE_FIND_SLOT_IGNORE_EXPIRY)
+ );
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ i = ucache_table_hash(hash) % hdr->capacity;
+
+ for (step = 0; step < hdr->capacity; step++) {
+ entry = &entries[i];
+
+ if (ucache_entry_is_empty(entry)) {
+ *slot_idx = first_tombstone != UINT32_MAX ? first_tombstone : i;
+ *found = false;
+
+ return true;
+ }
+
+ if (ucache_entry_is_tombstone(entry)) {
+ if (first_tombstone == UINT32_MAX) {
+ first_tombstone = i;
+ }
+ } else if (expiry_mode != UCACHE_FIND_SLOT_IGNORE_EXPIRY &&
+ ucache_is_expired_now(hdr, entry, &now)
+ ) {
+ if (expiry_mode == UCACHE_FIND_SLOT_DELETE_EXPIRED) {
+ ucache_delete_entry_locked(hdr, entry, i);
+ } else {
+ ucache_note_expired_read();
+ }
+
+ if (first_tombstone == UINT32_MAX) {
+ first_tombstone = i;
+ }
+ } else if (ucache_key_equals(hdr, entry, key, hash)) {
+ *slot_idx = i;
+ *found = true;
+
+ return true;
+ }
+
+ ++i;
+
+ if (i == hdr->capacity) {
+ i = 0;
+ }
+ }
+
+ if (first_tombstone != UINT32_MAX) {
+ *slot_idx = first_tombstone;
+ *found = false;
+
+ return true;
+ }
+
+ return false;
+}
+
+static bool ucache_find_slot_locked(
+ zend_string *key,
+ zend_ulong hash,
+ ucache_find_slot_expiry_mode expiry_mode,
+ ucache_hdr **hdr_ptr,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ if (hdr == NULL || !ucache_hdr_adoptable_locked()) {
+ return false;
+ }
+
+ if (hdr_ptr != NULL) {
+ *hdr_ptr = hdr;
+ }
+
+ return ucache_find_slot_in_hdr_locked(
+ hdr,
+ key,
+ hash,
+ expiry_mode,
+ slot_idx,
+ found
+ );
+}
+
+static bool ucache_find_slot_for_write_locked(
+ zend_string *key,
+ zend_ulong hash,
+ ucache_hdr **hdr_ptr,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ ZEND_ASSERT(UC_G(lock_held_is_write));
+
+ if (!ucache_hdr_is_initialized_locked() ||
+ !ucache_find_slot_in_hdr_locked(
+ hdr,
+ key,
+ hash,
+ UCACHE_FIND_SLOT_DELETE_EXPIRED,
+ slot_idx,
+ found
+ )
+ ) {
+ return false;
+ }
+
+ *hdr_ptr = hdr;
+
+ if (!*found && hdr->count >= ucache_insert_cap(hdr->capacity)) {
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_rehash_locked(ucache_hdr *hdr)
+{
+ ucache_entry *entries, *snapshot, *entry, *target;
+ uint32_t i, slot, step, *stamps, *stamp_snapshot;
+
+ entries = ucache_entries_ptr(hdr);
+ stamps = ucache_access_stamps_ptr(hdr);
+
+ snapshot = UCACHE_DEBUG_FAULT("FAIL_REHASH_ALLOC")
+ ? NULL
+ : malloc((size_t) hdr->capacity * (sizeof(*snapshot) + sizeof(*stamp_snapshot)))
+ ;
+ if (snapshot == NULL) {
+ return false;
+ }
+
+ stamp_snapshot = (uint32_t *) (snapshot + hdr->capacity);
+
+ memcpy(snapshot, entries, (size_t) hdr->capacity * sizeof(*snapshot));
+
+ for (i = 0; i < hdr->capacity; i++) {
+ stamp_snapshot[i] = UCACHE_ATOMIC_LOAD_32_RELAXED(&stamps[i]);
+ }
+
+ memset(entries, 0, (size_t) hdr->capacity * sizeof(*entries));
+
+ ucache_pool_idx_reset_locked(hdr);
+ ucache_access_stamps_reset(hdr);
+
+ hdr->count = 0;
+ hdr->tombstone_count = 0;
+
+ for (i = 0; i < hdr->capacity; i++) {
+ entry = &snapshot[i];
+
+ if (!ucache_entry_is_used(entry)) {
+ continue;
+ }
+
+ slot = entry->hash % hdr->capacity;
+ for (step = 0; step < hdr->capacity; step++) {
+ target = &entries[slot];
+
+ if (ucache_entry_is_empty(target)) {
+ *target = *entry;
+
+ ucache_pool_idx_link_locked(hdr, target, slot);
+ ucache_entry_set_block_owner(target, slot);
+
+ UCACHE_ATOMIC_STORE_32_RELAXED(&stamps[slot], stamp_snapshot[i]);
+
+ hdr->count++;
+
+ break;
+ }
+
+ ++slot;
+
+ if (slot == hdr->capacity) {
+ slot = 0;
+ }
+ }
+ }
+
+ free(snapshot);
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ return true;
+}
+
+static uint8_t *ucache_reserve_combined_val_key_locked(
+ ucache_hdr *hdr,
+ uint32_t reusable_offset,
+ zend_string *key,
+ size_t payload_size,
+ uint32_t owner,
+ uint32_t *val_offset)
+{
+ uint32_t base_offset;
+ size_t key_size, total_size;
+
+ key_size = ZSTR_LEN(key) + 1;
+ total_size = payload_size + key_size;
+ if (ucache_reuse_block_locked(hdr, reusable_offset, total_size, owner)) {
+ base_offset = reusable_offset;
+ } else {
+ base_offset = ucache_alloc_locked(total_size, NULL, owner);
+ if (base_offset == 0) {
+ return NULL;
+ }
+ }
+
+ *val_offset = base_offset;
+
+ return ucache_ptr_in_hdr(hdr, base_offset);
+}
+
+static bool ucache_publish_combined_val_key_locked(
+ ucache_hdr *hdr,
+ uint32_t reusable_offset,
+ zend_string *key,
+ size_t payload_size,
+ const void *src,
+ uint32_t owner,
+ uint32_t *val_offset)
+{
+ uint8_t *payload;
+
+ ZEND_ASSERT(src != NULL);
+
+ payload = ucache_reserve_combined_val_key_locked(
+ hdr,
+ reusable_offset,
+ key,
+ payload_size,
+ owner,
+ val_offset
+ );
+ if (payload == NULL) {
+ return false;
+ }
+
+ memcpy(payload, src, payload_size);
+ memcpy(payload + payload_size, ZSTR_VAL(key), ZSTR_LEN(key) + 1);
+
+ return true;
+}
+
+static bool ucache_publish_prepared_sgraph_locked(
+ const ucache_prepared_val *prepared,
+ zval *val,
+ uint8_t *payload)
+{
+ zval pinned_root;
+
+ if (prepared->payload_src != NULL &&
+ ucache_sgraph_publish_copied_payload_locked(
+ payload,
+ prepared->payload_src,
+ prepared->payload_size,
+ prepared->payload_used_size,
+ prepared->has_verbatim_arr,
+ prepared->fixup_offsets,
+ prepared->fixup_count
+ )
+ ) {
+ return true;
+ }
+
+ if (prepared->owner_type == UCACHE_PREPARED_OWNER_STR) {
+ ZVAL_STR(&pinned_root, prepared->owned_str);
+ val = &pinned_root;
+ }
+
+ return ucache_build_sgraph_in_place(
+ val,
+ NULL,
+ NULL,
+ prepared->packed_vals_allowed,
+ payload,
+ prepared->payload_size,
+ NULL,
+ NULL,
+ NULL,
+ NULL
+ );
+}
+
+static void ucache_rollback_partial_store_allocs_locked(
+ uint32_t new_val_offset,
+ uint32_t old_val_offset,
+ uint32_t new_key_offset,
+ uint32_t old_key_offset)
+{
+ if (new_val_offset != 0 && new_val_offset != old_val_offset) {
+ ucache_free_locked(new_val_offset);
+ }
+
+ if (new_key_offset != 0 && new_key_offset != old_key_offset) {
+ ucache_free_locked(new_key_offset);
+ }
+}
+
+static void ucache_delete_overwritten_combined_entry_locked(
+ ucache_hdr *hdr,
+ ucache_entry *entry,
+ uint32_t slot_idx)
+{
+ uint32_t block_offset = entry->val_offset;
+
+ ucache_entry_set_val_type(entry, UCACHE_VAL_NULL);
+ entry->val_offset = 0;
+
+ ucache_delete_entry_locked(hdr, entry, slot_idx);
+
+ ucache_free_locked(block_offset);
+}
+
+static ucache_store_attempt_result ucache_store_attempt_locked(
+ zend_string *key,
+ zval *val,
+ const ucache_prepared_val *prepared,
+ zend_long ttl,
+ bool bulk,
+ ucache_store_result *result,
+ size_t key_size,
+ ucache_store_retries *retries)
+{
+ const bool retry_after_mem_pressure = !bulk;
+ ucache_hdr *hdr;
+ ucache_entry *entries, *entry, replaced_snapshot;
+ zend_long new_lval = 0;
+ double new_dval = 0;
+ uint64_t store_now;
+ uint32_t expires_at, slot_idx, offset = 0, goffset = 0, reusable_offset,
+ old_key_offset = 0, old_val_offset = 0,
+ new_key_offset = 0, new_val_offset = 0, new_val_len = 0,
+ combined_reuse_offset = 0
+ ;
+ uint16_t old_flags = 0, new_flags = 0;
+ uint8_t *combined_payload, old_val_type = UCACHE_VAL_NULL, new_val_type = prepared->val_type;
+ size_t reclaim_size;
+ bool found, old_combined, use_combined_publish, allocates_key, fits_in_empty_cache, capture_replaced = false,
+ published = false, blocks_changed
+ ;
+
+ if (!ucache_find_slot_for_write_locked(
+ key,
+ prepared->hash,
+ &hdr,
+ &slot_idx,
+ &found
+ )
+ ) {
+ if (retry_after_mem_pressure &&
+ ucache_reclaim_space_for_store_locked(
+ true,
+ 0,
+ retries
+ )
+ ) {
+ return UCACHE_STORE_ATTEMPT_RETRY;
+ }
+
+ ucache_hdr_ptr()->store_failure_count++;
+
+ return UCACHE_STORE_ATTEMPT_FAILED;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ entry = &entries[slot_idx];
+
+ if (ttl == 0) {
+ expires_at = 0;
+ if (!found) {
+ ucache_access_note_time(ucache_clock_now());
+ }
+ } else {
+ store_now = ucache_clock_now();
+
+ ucache_access_note_time(store_now);
+
+ expires_at = ucache_expiry_deadline(hdr, store_now, ttl);
+ }
+
+ if (found) {
+ capture_replaced = bulk;
+
+ replaced_snapshot = *entry;
+
+ old_key_offset = entry->key_offset;
+ old_val_type = ucache_entry_val_type(entry);
+ old_val_offset = ucache_entry_val_offset(entry);
+ old_flags = entry->flags;
+ }
+
+ old_combined = found && (old_flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) != 0;
+ reusable_offset = 0;
+
+ if (found && !capture_replaced && old_val_type != UCACHE_VAL_SGRAPH && !old_combined) {
+ reusable_offset = old_val_offset;
+ }
+
+ new_key_offset = found && !capture_replaced ? old_key_offset : 0;
+
+ use_combined_publish = ucache_val_uses_offset(prepared->val_type) &&
+ (!found || old_combined)
+ ;
+
+ if (old_combined && old_val_offset != 0 && !capture_replaced) {
+ if (old_val_type == UCACHE_VAL_SGRAPH) {
+ if (ucache_val_uses_offset(prepared->val_type) &&
+ ucache_sgraph_quiesce_for_overwrite_locked(old_val_offset) &&
+ (
+ prepared->val_type != UCACHE_VAL_SGRAPH ||
+ prepared->payload_src == NULL ||
+ ucache_sgraph_copy_fits_buf(
+ ucache_ptr_in_hdr(hdr, old_val_offset),
+ prepared->payload_src,
+ prepared->payload_size,
+ prepared->payload_used_size
+ )
+ )
+ ) {
+ combined_reuse_offset = old_val_offset;
+ }
+ } else if (hdr->count == 1) {
+ combined_reuse_offset = old_val_offset;
+ }
+ }
+
+ allocates_key = !use_combined_publish && (!found || old_combined || capture_replaced);
+ if (allocates_key) {
+ new_key_offset = ucache_alloc_locked(key_size, ZSTR_VAL(key), slot_idx);
+ if (new_key_offset == 0) {
+ fits_in_empty_cache = ucache_payload_can_fit_locked(key_size);
+
+ goto bailout;
+ }
+ }
+
+ switch (prepared->val_type) {
+ case UCACHE_VAL_NULL:
+ case UCACHE_VAL_TRUE:
+ case UCACHE_VAL_FALSE:
+ break;
+ case UCACHE_VAL_LONG:
+ new_lval = prepared->long_val;
+
+ break;
+ case UCACHE_VAL_DOUBLE:
+ new_dval = prepared->double_val;
+
+ break;
+ case UCACHE_VAL_STR:
+ if (use_combined_publish) {
+ if (!ucache_publish_combined_val_key_locked(
+ hdr,
+ combined_reuse_offset,
+ key,
+ prepared->payload_size,
+ prepared->payload_src,
+ slot_idx,
+ &new_val_offset
+ )
+ ) {
+ fits_in_empty_cache = ucache_payload_can_fit_locked(prepared->payload_size + key_size);
+
+ goto bailout;
+ }
+
+ new_key_offset = ucache_combined_key_offset(new_val_offset, prepared->payload_size);
+ new_flags = ucache_combined_key_flags(prepared->payload_size);
+ } else {
+ offset = ucache_write_payload_locked(
+ hdr,
+ reusable_offset,
+ prepared->payload_size,
+ prepared->payload_src,
+ slot_idx
+ );
+ if (offset == 0) {
+ fits_in_empty_cache = ucache_payload_can_fit_locked(prepared->payload_size);
+
+ goto bailout;
+ }
+
+ new_val_offset = offset;
+ }
+
+ new_val_len = (uint32_t) prepared->payload_size - 1;
+
+ break;
+ case UCACHE_VAL_SGRAPH:
+ if (use_combined_publish) {
+ combined_payload = ucache_reserve_combined_val_key_locked(
+ hdr,
+ combined_reuse_offset,
+ key,
+ prepared->payload_size,
+ slot_idx,
+ &new_val_offset
+ );
+
+ goffset = new_val_offset;
+
+ if (combined_payload != NULL) {
+ zend_try {
+ published = ucache_publish_prepared_sgraph_locked(
+ prepared,
+ val,
+ combined_payload
+ );
+ } zend_catch {
+ if (goffset != combined_reuse_offset) {
+ ucache_free_locked(goffset);
+ } else {
+ ucache_delete_overwritten_combined_entry_locked(hdr, entry, slot_idx);
+ }
+
+ if (!UC_G(store_defer_unlock)) {
+ ucache_unlock_if_held();
+ }
+
+ zend_bailout();
+ } zend_end_try();
+
+ if (published) {
+ memcpy(combined_payload + prepared->payload_size, ZSTR_VAL(key), key_size);
+
+ new_key_offset = ucache_combined_key_offset(goffset, prepared->payload_size);
+ new_flags = ucache_combined_key_flags(prepared->payload_size);
+ new_val_offset = goffset;
+ new_val_len = (uint32_t) prepared->payload_size;
+
+ break;
+ }
+
+ if (goffset != combined_reuse_offset) {
+ ucache_free_locked(goffset);
+ } else {
+ ucache_delete_overwritten_combined_entry_locked(hdr, entry, slot_idx);
+
+ old_val_offset = 0;
+ old_key_offset = 0;
+ found = false;
+ }
+
+ new_val_offset = 0;
+ new_key_offset = found && !capture_replaced ? old_key_offset : 0;
+
+ if (EG(exception)) {
+ return UCACHE_STORE_ATTEMPT_FAILED;
+ }
+ } else if (retry_after_mem_pressure &&
+ ucache_payload_can_fit_locked(prepared->payload_size + key_size) &&
+ ucache_reclaim_space_for_store_locked(
+ true,
+ prepared->payload_size + key_size,
+ retries
+ )
+ ) {
+ return UCACHE_STORE_ATTEMPT_RETRY;
+ }
+ } else {
+ goffset = ucache_alloc_locked(prepared->payload_size, NULL, slot_idx);
+
+ if (goffset != 0) {
+ zend_try {
+ published = ucache_publish_prepared_sgraph_locked(
+ prepared,
+ val,
+ ucache_ptr_in_hdr(hdr, goffset)
+ );
+ } zend_catch {
+ ZEND_ASSERT((new_flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) == 0);
+
+ ucache_rollback_partial_store_allocs_locked(
+ goffset,
+ old_val_offset,
+ new_key_offset,
+ old_key_offset
+ );
+
+ if (!UC_G(store_defer_unlock)) {
+ ucache_unlock_if_held();
+ }
+
+ zend_bailout();
+ } zend_end_try();
+
+ if (published) {
+ new_val_offset = goffset;
+ new_val_len = (uint32_t) prepared->payload_size;
+
+ break;
+ }
+
+ ucache_free_locked(goffset);
+
+ if (EG(exception)) {
+ ZEND_ASSERT((new_flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) == 0);
+
+ ucache_rollback_partial_store_allocs_locked(
+ 0,
+ old_val_offset,
+ new_key_offset,
+ old_key_offset
+ );
+
+ return UCACHE_STORE_ATTEMPT_FAILED;
+ }
+ } else if (retry_after_mem_pressure &&
+ ucache_payload_can_fit_locked(prepared->payload_size) &&
+ ucache_reclaim_space_for_store_locked(
+ true,
+ prepared->payload_size,
+ retries
+ )
+ ) {
+ return UCACHE_STORE_ATTEMPT_RETRY;
+ }
+ }
+
+ fits_in_empty_cache = ucache_payload_can_fit_locked(prepared->payload_size);
+
+ goto bailout;
+ default:
+ ZEND_UNREACHABLE();
+ }
+
+ if (!found && ucache_entry_is_tombstone(entry) && hdr->tombstone_count != 0) {
+ hdr->tombstone_count--;
+ }
+
+ if (found && entry->expires_at != 0) {
+ hdr->expiring_count--;
+ }
+
+ if (expires_at != 0) {
+ hdr->expiring_count++;
+ }
+
+ ZEND_ASSERT(ZSTR_LEN(key) <= UCACHE_STORAGE_KEY_MAX);
+
+ entry->hash = ucache_table_hash(prepared->hash);
+ entry->key_offset = new_key_offset;
+ entry->key_len = (uint16_t) ZSTR_LEN(key);
+ entry->expires_at = expires_at;
+
+ ucache_expiry_floor_lower_locked(hdr, expires_at);
+ entry->flags = new_flags | (found
+ ? old_flags & UCACHE_ENTRY_POOL_BUCKET_MASK
+ : ucache_pool_bucket_for_key(ZSTR_VAL(key), ZSTR_LEN(key))
+ << UCACHE_ENTRY_POOL_BUCKET_SHIFT
+ );
+ ucache_entry_set_val_type(entry, new_val_type);
+
+ if (ucache_val_uses_offset(new_val_type)) {
+ entry->val_offset = new_val_offset;
+ entry->val_len = new_val_len;
+ } else if (new_val_type == UCACHE_VAL_DOUBLE) {
+ entry->double_val = new_dval;
+ } else {
+ ucache_entry_set_long_zero_filled(entry, new_lval);
+ }
+
+ if (found) {
+ ucache_touch_entry_access(hdr, hdr->capacity, slot_idx);
+ } else {
+ UCACHE_ATOMIC_STORE_32_RELAXED(
+ &ucache_access_stamps_ptr(hdr)[slot_idx], ucache_access_now()
+ );
+ }
+
+ if (capture_replaced) {
+ result->replaced_entry = replaced_snapshot;
+ } else {
+ if (found &&
+ old_key_offset != 0 &&
+ old_key_offset != new_key_offset &&
+ !old_combined
+ ) {
+ ucache_free_locked(old_key_offset);
+ }
+
+ if (found &&
+ old_val_offset != 0 &&
+ old_val_offset != new_val_offset
+ ) {
+ ucache_release_val_storage_locked(old_val_type, old_val_offset);
+ }
+ }
+
+ if (!found) {
+ hdr->count++;
+ ucache_pool_idx_link_locked(hdr, entry, slot_idx);
+ }
+
+ blocks_changed = !found || old_val_offset != new_val_offset ||
+ old_combined != ((new_flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) != 0) ||
+ (!old_combined && old_key_offset != new_key_offset)
+ ;
+ if (blocks_changed) {
+ ucache_pool_bucket_changed_locked(hdr, ucache_entry_pool_bucket(entry));
+ }
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ entry->gen = hdr->mutation_epoch;
+
+ if (found && !bulk && expires_at == 0 && replaced_snapshot.expires_at == 0 &&
+ old_val_type <= UCACHE_VAL_DOUBLE && new_val_type <= UCACHE_VAL_DOUBLE
+ ) {
+ ucache_scalar_write_enable_locked(hdr);
+ }
+
+ result->stored_gen = entry->gen;
+ result->stored_slot = slot_idx;
+ result->stored_expires_at = expires_at;
+ result->stored_val_type = prepared->val_type;
+
+ return UCACHE_STORE_ATTEMPT_STORED;
+
+bailout:
+ ZEND_ASSERT((new_flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) == 0);
+
+ ucache_rollback_partial_store_allocs_locked(
+ new_val_offset,
+ old_val_offset,
+ new_key_offset,
+ old_key_offset
+ );
+
+ ZEND_ASSERT(!retry_after_mem_pressure || !allocates_key || !ucache_val_uses_offset(prepared->val_type));
+
+ reclaim_size = use_combined_publish ? prepared->payload_size + key_size : prepared->payload_size;
+ if (reclaim_size == 0) {
+ reclaim_size = key_size;
+ }
+
+ if (retry_after_mem_pressure &&
+ ucache_reclaim_space_for_store_locked(
+ fits_in_empty_cache,
+ reclaim_size,
+ retries
+ )
+ ) {
+ return UCACHE_STORE_ATTEMPT_RETRY;
+ }
+
+ hdr->store_failure_count++;
+
+ return UCACHE_STORE_ATTEMPT_FAILED;
+}
+
+static bool ucache_store_prepared_locked_impl(
+ zend_string *key,
+ zval *val,
+ const ucache_prepared_val *prepared,
+ zend_long ttl,
+ bool bulk,
+ ucache_store_result *result)
+{
+ ucache_store_attempt_result attempt_result;
+ ucache_store_retries retries = {0};
+ size_t key_size, min_payload_size;
+
+ ZVAL_DEREF(val);
+
+ memset(result, 0, sizeof(*result));
+
+ key_size = ZSTR_LEN(key) + 1;
+ min_payload_size = ucache_val_uses_offset(prepared->val_type)
+ ? prepared->payload_size
+ : 0
+ ;
+
+ if (min_payload_size > SIZE_MAX - key_size ||
+ !ucache_payload_can_fit_locked(min_payload_size + key_size)
+ ) {
+ ucache_hdr_ptr()->store_failure_count++;
+
+ return false;
+ }
+
+ ucache_maybe_rehash_locked();
+
+ for (;;) {
+ attempt_result = ucache_store_attempt_locked(
+ key,
+ val,
+ prepared,
+ ttl,
+ bulk,
+ result,
+ key_size,
+ &retries
+ );
+
+ if (attempt_result == UCACHE_STORE_ATTEMPT_RETRY) {
+ continue;
+ }
+
+ return attempt_result == UCACHE_STORE_ATTEMPT_STORED;
+ }
+}
+
+static bool ucache_prepare_direct_val(
+ zval *val,
+ ucache_prepared_val *prepared)
+{
+ switch (Z_TYPE_P(val)) {
+ case IS_NULL:
+ prepared->val_type = UCACHE_VAL_NULL;
+
+ return true;
+ case IS_TRUE:
+ prepared->val_type = UCACHE_VAL_TRUE;
+
+ return true;
+ case IS_FALSE:
+ prepared->val_type = UCACHE_VAL_FALSE;
+
+ return true;
+ case IS_LONG:
+ prepared->val_type = UCACHE_VAL_LONG;
+ prepared->long_val = Z_LVAL_P(val);
+
+ return true;
+ case IS_DOUBLE:
+ prepared->val_type = UCACHE_VAL_DOUBLE;
+ prepared->double_val = Z_DVAL_P(val);
+
+ return true;
+ case IS_STRING:
+ if (Z_STRLEN_P(val) < UCACHE_DIRECT_STR_MIN_LEN) {
+ prepared->val_type = UCACHE_VAL_STR;
+ prepared->payload_size = Z_STRLEN_P(val) + 1;
+ prepared->payload_used_size = prepared->payload_size;
+ prepared->payload_src = (const uint8_t *) Z_STRVAL_P(val);
+
+ return true;
+ }
+
+ return false;
+ default:
+ return false;
+ }
+}
+
+static void ucache_throw_if_pass_overflowed_stack(void)
+{
+ if (!UC_G(stack_overflowed)) {
+ return;
+ }
+
+ UC_G(stack_overflowed) = false;
+
+ if (!EG(exception)) {
+ zend_type_error(UCACHE_MSG_NESTED_TOO_DEEPLY);
+ }
+}
+
+static bool ucache_build_prepared_sgraph(
+ zval *val,
+ HashTable *verbatim_verdicts,
+ size_t glen,
+ ucache_prepared_val *prepared)
+{
+ return ucache_build_sgraph_in_place(
+ val,
+ prepared->state_memo,
+ zend_hash_num_elements(prepared->state_memo) == 0 ? verbatim_verdicts : NULL,
+ prepared->packed_vals_allowed,
+ prepared->owned_buf,
+ glen,
+ &prepared->payload_used_size,
+ &prepared->has_verbatim_arr,
+ &prepared->fixup_offsets,
+ &prepared->fixup_count
+ );
+}
+
+static bool ucache_rebuild_prepared_sgraph_after_hooks(
+ zval *val,
+ ucache_prepared_val *prepared,
+ uint32_t calc_memo_count)
+{
+ size_t glen;
+
+ if (EG(exception) ||
+ UC_G(stack_overflowed) ||
+ calc_memo_count == 0 ||
+ zend_hash_num_elements(prepared->state_memo) != calc_memo_count ||
+ !ucache_calc_sgraph_size(
+ val,
+ prepared->state_memo,
+ NULL,
+ &glen,
+ &prepared->packed_vals_allowed
+ ) ||
+ glen > UINT32_MAX
+ ) {
+ return false;
+ }
+
+ prepared->owned_buf = erealloc(prepared->owned_buf, glen);
+ prepared->payload_size = glen;
+
+ return ucache_build_prepared_sgraph(val, NULL, glen, prepared);
+}
+
+static bool ucache_prepare_sgraph_val(
+ zval *val,
+ HashTable *verbatim_verdicts,
+ size_t verbatim_glen,
+ ucache_prepared_val *prepared)
+{
+ size_t glen = 0;
+ uint32_t calc_memo_count;
+ bool build_at_publish = Z_TYPE_P(val) == IS_STRING;
+
+ if (!build_at_publish && prepared->state_memo == NULL) {
+ prepared->state_memo = emalloc(sizeof(HashTable));
+
+ zend_hash_init(prepared->state_memo, 8, NULL, ZVAL_PTR_DTOR, 0);
+ }
+
+ UC_G(stack_overflowed) = false;
+
+ if (verbatim_glen != 0) {
+ glen = verbatim_glen;
+ } else if (!ucache_calc_sgraph_size(
+ val,
+ prepared->state_memo,
+ verbatim_verdicts,
+ &glen,
+ &prepared->packed_vals_allowed
+ )
+ ) {
+ ucache_throw_if_pass_overflowed_stack();
+
+ return false;
+ }
+
+ if (glen > UINT32_MAX) {
+ return false;
+ }
+
+ prepared->val_type = UCACHE_VAL_SGRAPH;
+ prepared->payload_size = glen;
+
+ if (build_at_publish) {
+ prepared->owner_type = UCACHE_PREPARED_OWNER_STR;
+ prepared->owned_str = zend_string_copy(Z_STR_P(val));
+
+ return true;
+ }
+
+ prepared->owner_type = UCACHE_PREPARED_OWNER_BUF;
+ prepared->owned_buf = emalloc(glen);
+
+ calc_memo_count = zend_hash_num_elements(prepared->state_memo);
+
+ if (!ucache_build_prepared_sgraph(val, verbatim_verdicts, glen, prepared) &&
+ !ucache_rebuild_prepared_sgraph_after_hooks(val, prepared, calc_memo_count)
+ ) {
+ ucache_throw_if_pass_overflowed_stack();
+
+ return false;
+ }
+
+ prepared->payload_src = prepared->owned_buf;
+ if (prepared->packed_vals_allowed) {
+ if (UNEXPECTED(prepared->payload_used_size > prepared->payload_size - UCACHE_SGRAPH_ALIGNMENT_SLACK)) {
+ return false;
+ }
+
+ prepared->payload_size = prepared->payload_used_size + UCACHE_SGRAPH_ALIGNMENT_SLACK;
+ }
+
+ return true;
+}
+
+static uint32_t ucache_fetch_finish_flags(uint32_t gflags, bool owned_copy)
+{
+ uint32_t flags = 0;
+
+ if ((UC_G(persistent_exec) || owned_copy) &&
+ !(gflags & (
+ UCACHE_SGRAPH_FLAG_HAS_OBJ |
+ UCACHE_SGRAPH_FLAG_HAS_SHARED_IDENTITY
+ ))
+ ) {
+ return UCACHE_FETCH_FINISH_RECORD_COPY;
+ }
+
+ if (UCACHE_OPTIMISTIC_ENABLED &&
+ (
+ (gflags & UCACHE_SGRAPH_FLAG_PREFERS_PROTO) ||
+ (
+ UC_G(persistent_exec) &&
+ !(gflags & UCACHE_SGRAPH_FLAG_HAS_OBJ)
+ )
+ )
+ ) {
+ flags |= UCACHE_FETCH_FINISH_USE_REQ_LOCAL_SLOT;
+ }
+
+ if (!(gflags & UCACHE_SGRAPH_FLAG_HAS_SHARED_IDENTITY)) {
+ flags |= UCACHE_FETCH_FINISH_NO_ALIASES;
+ }
+
+ return flags;
+}
+
+static PHP_UCACHE_HOT void ucache_fetch_finish_impl(
+ ucache_key_record *record,
+ uint64_t gen,
+ zval *return_value,
+ uint32_t flags,
+ uint32_t val_len)
+{
+ ucache_req_local_slot *slot;
+
+ if (flags & UCACHE_FETCH_FINISH_RECORD_COPY) {
+ if (record->state == UCACHE_RECORD_HIT &&
+ record->val_kind == UCACHE_RECORD_VAL_NONE
+ ) {
+ ucache_key_record_cache_copy(record, return_value, val_len);
+ }
+
+ return;
+ }
+
+ if (!(flags & UCACHE_FETCH_FINISH_USE_REQ_LOCAL_SLOT)) {
+ return;
+ }
+
+ slot = ucache_find_req_local_slot(record->storage_key, gen);
+ if (slot == NULL) {
+ if (UC_G(req_local_slot_table) != NULL &&
+ zend_hash_num_elements(UC_G(req_local_slot_table)) >= UCACHE_MAX_REQ_LOCAL_SLOTS
+ ) {
+ ucache_req_local_slots_drop_oldest_half();
+ }
+
+ ucache_mark_req_local_slot(record->storage_key, gen);
+ } else if (!slot->has_val && !slot->proto_rejected) {
+ if (UNEXPECTED(flags & UCACHE_FETCH_FINISH_RESTORE_HOOKS_RAN)) {
+ slot->proto_rejected = true;
+
+ return;
+ }
+
+ ucache_store_req_local_slot(
+ record->storage_key,
+ gen,
+ return_value,
+ (flags & UCACHE_FETCH_FINISH_NO_ALIASES) != 0,
+ val_len
+ );
+ }
+}
+
+static bool ucache_fetch_emit_val_locked(
+ const ucache_hdr *hdr,
+ ucache_entry *entry,
+ uint64_t epoch,
+ zval *return_value,
+ ucache_fetch_pending_seed *pending_seed,
+ bool *lock_held)
+{
+ uint64_t gen = entry->gen;
+ uint32_t gflags, hook_calls_before, val_len = entry->val_len;
+ bool private_copy = false;
+
+ pending_seed->should_seed = false;
+ pending_seed->mutation_epoch = epoch;
+ pending_seed->gen = gen;
+
+ if (ucache_scalar_to_zval(
+ ucache_entry_val_type(entry),
+ &entry->long_val,
+ &entry->double_val,
+ return_value
+ )
+ ) {
+ return true;
+ }
+
+ switch (ucache_entry_val_type(entry)) {
+ case UCACHE_VAL_STR:
+ UCACHE_TRY_UNLOCK_ON_BAILOUT(
+ ZVAL_STRINGL(
+ return_value,
+ (const char *) ucache_ptr_in_hdr(
+ hdr,
+ entry->val_offset
+ ),
+ val_len
+ );
+ );
+
+ pending_seed->flags = UCACHE_FETCH_FINISH_RECORD_COPY;
+ pending_seed->val_len = val_len;
+ pending_seed->should_seed = true;
+
+ return true;
+ case UCACHE_VAL_SGRAPH:
+ gflags = ucache_sgraph_payload_flags(entry->val_offset);
+ hook_calls_before = UC_G(restore_hook_calls);
+
+ if (!ucache_materialize_sgraph_locked(
+ hdr,
+ entry->val_offset,
+ val_len,
+ entry->gen,
+ return_value,
+ lock_held,
+ &private_copy
+ )
+ ) {
+ return false;
+ }
+
+ pending_seed->flags = ucache_fetch_finish_flags_after_decode(
+ ucache_fetch_finish_flags(gflags, private_copy),
+ hook_calls_before
+ );
+ pending_seed->val_len = val_len;
+ pending_seed->should_seed = true;
+
+ return true;
+ default:
+ return false;
+ }
+}
+
+static ucache_fetch_locate_result ucache_fetch_probe_key_record_locked(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ ucache_entry *entries,
+ uint64_t epoch,
+ zval *return_value,
+ uint32_t *slot_idx,
+ zval *detached)
+{
+ ucache_entry *entry;
+ uint64_t now = 0;
+ bool cur = record->mutation_epoch == epoch;
+
+ if (record->state == UCACHE_RECORD_EMPTY) {
+ return UCACHE_FETCH_LOCATE_UNCACHED;
+ }
+
+ if (cur) {
+ if (record->state == UCACHE_RECORD_MISS) {
+ return UCACHE_FETCH_LOCATE_MISS;
+ }
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE &&
+ ucache_key_record_val_usable(hdr, record)
+ ) {
+ ZVAL_COPY(return_value, &record->val);
+
+ ucache_touch_cached_entry_access(hdr, record->slot_idx, &record->access_touches);
+
+ return UCACHE_FETCH_LOCATE_VAL_HIT;
+ }
+ } else if (record->state != UCACHE_RECORD_HIT) {
+ ucache_key_record_reset_impl(record, detached);
+
+ return UCACHE_FETCH_LOCATE_UNCACHED;
+ }
+
+ if (record->slot_idx >= hdr->capacity ||
+ (
+ !cur &&
+ !ucache_key_equals(
+ hdr,
+ &entries[record->slot_idx],
+ record->storage_key,
+ ZSTR_H(record->storage_key)
+ )
+ )
+ ) {
+ ucache_key_record_reset_impl(record, detached);
+
+ return UCACHE_FETCH_LOCATE_UNCACHED;
+ }
+
+ entry = &entries[record->slot_idx];
+ if (ucache_is_expired_now(hdr, entry, &now)) {
+ ucache_note_expired_read();
+
+ ucache_key_record_mark_miss(record, epoch, detached);
+
+ return UCACHE_FETCH_LOCATE_MISS;
+ }
+
+ *slot_idx = record->slot_idx;
+
+ if (!cur) {
+ ucache_key_record_mark_hit(record, epoch, *slot_idx, entry, detached);
+ }
+
+ return UCACHE_FETCH_LOCATE_SLOT;
+}
+
+static ucache_fetch_locate_result ucache_fetch_probe_entry_table_locked(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ ucache_entry *entries,
+ uint64_t epoch,
+ uint32_t *slot_idx,
+ zval *detached)
+{
+ bool found;
+
+ if (!ucache_find_slot_in_hdr_locked(
+ hdr,
+ record->storage_key,
+ ZSTR_H(record->storage_key),
+ UCACHE_FIND_SLOT_SKIP_EXPIRED,
+ slot_idx,
+ &found
+ ) ||
+ !found
+ ) {
+ ucache_key_record_mark_miss(record, epoch, detached);
+
+ return UCACHE_FETCH_LOCATE_MISS;
+ }
+
+ ucache_key_record_mark_hit(record, epoch, *slot_idx, &entries[*slot_idx], detached);
+
+ return UCACHE_FETCH_LOCATE_SLOT;
+}
+
+static bool ucache_atomic_insert_missing_locked(
+ zend_string *key,
+ zend_long step,
+ zend_long ttl,
+ bool decrement,
+ ucache_atomic_update_result *result)
+{
+ ucache_prepared_val prepared;
+ ucache_store_result store_result;
+ zend_long updated;
+ zval init_val = {0};
+ bool is_overflow, stored;
+
+ is_overflow = decrement
+ ? ucache_long_sub_overflow(0, step, &updated)
+ : ucache_long_add_overflow(0, step, &updated)
+ ;
+ if (is_overflow) {
+ result->is_overflow = true;
+
+ return false;
+ }
+
+ ZVAL_LONG(&init_val, updated);
+
+ if (!ucache_prepare_val(key, &init_val, &prepared)) {
+ ucache_destroy_prepared_val(&prepared);
+
+ return false;
+ }
+
+ UCACHE_TRY_UNLOCK_ON_BAILOUT(
+ stored = ucache_store_prepared_locked_impl(key, &init_val, &prepared, ttl, false, &store_result);
+ );
+
+ ucache_destroy_prepared_val(&prepared);
+
+ if (stored) {
+ result->new_val = Z_LVAL(init_val);
+
+ result->stored_gen = store_result.stored_gen;
+ result->stored_slot = store_result.stored_slot;
+ result->stored_expires_at = store_result.stored_expires_at;
+
+ return true;
+ }
+
+ return false;
+}
+
+static zend_never_inline ucache_optimistic_result ucache_optimistic_locate_fallback(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ ucache_key_record *record,
+ uint64_t seq,
+ uint64_t epoch,
+ bool stamp_access,
+ ucache_entry *snapshot)
+{
+ zend_string *key = record->storage_key;
+ uint32_t slot_idx = 0;
+ bool cur = record->mutation_epoch == epoch, found = true;
+
+ if (record->state == UCACHE_RECORD_HIT &&
+ ucache_optimistic_key_record_slot(hdr, capacity, record, cur, snapshot)
+ ) {
+ slot_idx = record->slot_idx;
+ } else {
+ cur = false;
+
+ if (!ucache_optimistic_probe(
+ hdr,
+ capacity,
+ key,
+ ZSTR_H(key),
+ ucache_entries_ptr(hdr),
+ snapshot,
+ &slot_idx,
+ &found
+ )
+ ) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+ }
+
+ if (ucache_read_seq(hdr, key) != seq) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (!found) {
+ ucache_key_record_mark_miss(record, epoch, NULL);
+
+ return UCACHE_OPTIMISTIC_MISS;
+ }
+
+ if (cur) {
+ if (stamp_access) {
+ ucache_touch_cached_entry_access(hdr, slot_idx, &record->access_touches);
+ }
+
+ return UCACHE_OPTIMISTIC_FOUND;
+ }
+
+ if (stamp_access) {
+ ucache_touch_entry_access(hdr, capacity, slot_idx);
+ }
+
+ ucache_key_record_mark_hit(record, epoch, slot_idx, snapshot, NULL);
+ if (!stamp_access) {
+ record->access_touches = UCACHE_ACCESS_SAMPLE_INTERVAL - 1;
+ }
+
+ return UCACHE_OPTIMISTIC_FOUND;
+}
+
+static ucache_optimistic_result ucache_optimistic_emit_str(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ uint64_t seq,
+ const ucache_entry *snapshot,
+ zval *return_value)
+{
+ zend_string *key = record->storage_key, *str;
+
+ if (!ucache_payload_in_bounds(hdr, snapshot->val_offset, snapshot->val_len)) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ str = zend_string_init(
+ (const char *) ucache_ptr_in_hdr(hdr, snapshot->val_offset),
+ snapshot->val_len,
+ 0
+ );
+
+ if (ucache_read_seq(hdr, key) != seq) {
+ zend_string_release(str);
+
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ ZVAL_STR(return_value, str);
+
+ ucache_key_record_cache_copy(record, return_value, snapshot->val_len);
+
+ return UCACHE_OPTIMISTIC_FOUND;
+}
+
+static zend_never_inline ucache_optimistic_result ucache_optimistic_decode_owned_graph(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ uint64_t seq,
+ const ucache_entry *snapshot,
+ zval *return_value,
+ uint32_t gflags)
+{
+ zend_string *key = record->storage_key;
+ ucache_op_lease *lease = ucache_op_lease_reserve(snapshot->val_offset);
+ uint32_t reader_slot, hook_calls_before;
+ bool decoded, resource_limited;
+
+ if (!lease->acquired) {
+ if (!ucache_optimistic_reader_begin(hdr, &reader_slot)) {
+ ucache_op_lease_end(lease);
+
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (ucache_read_seq(hdr, key) != seq) {
+ ucache_optimistic_reader_end(hdr, reader_slot);
+ ucache_op_lease_end(lease);
+
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ decoded = ucache_op_lease_acquire(lease, &resource_limited);
+
+ ucache_optimistic_reader_end(hdr, reader_slot);
+
+ if (!decoded) {
+ ucache_op_lease_end(lease);
+
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+ }
+
+ ZVAL_UNDEF(return_value);
+
+ hook_calls_before = UC_G(restore_hook_calls);
+ decoded = ucache_sgraph_decode(
+ ucache_ptr_in_hdr(hdr, snapshot->val_offset),
+ snapshot->val_len,
+ snapshot->gen,
+ return_value
+ );
+ if (!decoded && Z_TYPE_P(return_value) != IS_UNDEF) {
+ zval_ptr_dtor(return_value);
+
+ ZVAL_UNDEF(return_value);
+ }
+
+ ucache_op_lease_end(lease);
+
+ if (!decoded) {
+ return ucache_optimistic_unrestorable(snapshot);
+ }
+
+ ucache_fetch_finish_impl(
+ record,
+ snapshot->gen,
+ return_value,
+ ucache_fetch_finish_flags_after_decode(ucache_fetch_finish_flags(gflags, true), hook_calls_before),
+ snapshot->val_len
+ );
+
+ return UCACHE_OPTIMISTIC_FOUND;
+}
+
+static ucache_optimistic_result ucache_optimistic_emit_sgraph(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ uint64_t seq,
+ const ucache_entry *snapshot,
+ zval *return_value,
+ bool allow_decode)
+{
+ zend_string *key = record->storage_key;
+ uint32_t reader_slot = 0, gflags, ref_idx, hook_calls_before;
+
+ if (!ucache_payload_in_bounds(
+ hdr,
+ snapshot->val_offset,
+ UCACHE_SGRAPH_HDR_SIZE(0) + UCACHE_SGRAPH_ALIGNMENT_SLACK
+ ) ||
+ !ucache_payload_in_bounds(
+ hdr,
+ snapshot->val_offset,
+ snapshot->val_len
+ )
+ ) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ gflags = ucache_sgraph_payload_flags(snapshot->val_offset);
+
+ if (ucache_read_seq(hdr, key) != seq) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (((gflags & UCACHE_SGRAPH_FLAG_PREFERS_PROTO) || UC_G(persistent_exec)) &&
+ ucache_fetch_req_local_slot(key, snapshot->gen, return_value)
+ ) {
+ return UCACHE_OPTIMISTIC_FOUND;
+ }
+
+ if (!allow_decode && (gflags & UCACHE_SGRAPH_FLAG_HAS_OBJ)) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (UNEXPECTED(UC_G(persistent_exec))) {
+ return ucache_optimistic_decode_owned_graph(
+ hdr,
+ record,
+ seq,
+ snapshot,
+ return_value,
+ gflags
+ );
+ }
+
+ ref_idx = ucache_req_sgraph_ref_idx(snapshot->val_offset);
+ if (ref_idx == UINT32_MAX) {
+ if (!ucache_req_pin_fits_budget(hdr, snapshot->val_len)) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ ucache_sgraph_ref_reserve();
+
+ if (!ucache_optimistic_reader_begin(hdr, &reader_slot)) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (ucache_read_seq(hdr, key) != seq) {
+ ucache_optimistic_reader_end(hdr, reader_slot);
+
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ if (!ucache_sgraph_acquire_ref(snapshot->val_offset, NULL)) {
+ ucache_optimistic_reader_end(hdr, reader_slot);
+
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ ref_idx = ucache_register_sgraph_ref(snapshot->val_offset, snapshot->val_len);
+
+ ucache_optimistic_reader_end(hdr, reader_slot);
+ }
+
+ ZVAL_UNDEF(return_value);
+
+ hook_calls_before = UC_G(restore_hook_calls);
+
+ if (!ucache_sgraph_decode(
+ ucache_ptr_in_hdr(hdr, snapshot->val_offset),
+ snapshot->val_len,
+ snapshot->gen,
+ return_value
+ )
+ ) {
+ if (Z_TYPE_P(return_value) != IS_UNDEF) {
+ zval_ptr_dtor(return_value);
+
+ ZVAL_UNDEF(return_value);
+ }
+
+ return ucache_optimistic_unrestorable(snapshot);
+ }
+
+ if (gflags & UCACHE_SGRAPH_FLAG_PREFERS_PROTO) {
+ ucache_fetch_finish_impl(
+ record,
+ snapshot->gen,
+ return_value,
+ ucache_fetch_finish_flags_after_decode(ucache_fetch_finish_flags(gflags, false), hook_calls_before),
+ snapshot->val_len
+ );
+ } else if (!Z_REFCOUNTED_P(return_value)) {
+ ucache_key_record_cache_pinned(record, return_value, snapshot->val_offset, ref_idx);
+ } else if (!(gflags & (
+ UCACHE_SGRAPH_FLAG_HAS_OBJ |
+ UCACHE_SGRAPH_FLAG_HAS_SHARED_IDENTITY
+ ))
+ ) {
+ ucache_key_record_cache_pinned_copy(record, return_value, snapshot->val_offset, snapshot->val_len);
+ }
+
+ return UCACHE_OPTIMISTIC_FOUND;
+}
+
+static zend_never_inline ucache_optimistic_result ucache_exists_optimistic_entry(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ uint64_t seq,
+ uint64_t epoch)
+{
+ ucache_entry snapshot;
+ uint32_t capacity;
+
+ capacity = ucache_optimistic_capacity(hdr);
+ if (capacity == 0) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ return ucache_optimistic_locate(hdr, capacity, record, seq, epoch, false, &snapshot);
+}
+
+static zend_never_inline ucache_optimistic_result ucache_fetch_optimistic_entry(
+ ucache_hdr *hdr,
+ ucache_key_record *record,
+ uint64_t seq,
+ uint64_t epoch,
+ zval *return_value,
+ bool allow_decode)
+{
+ ucache_entry snapshot;
+ ucache_optimistic_result result;
+ uint32_t capacity;
+
+ capacity = ucache_optimistic_capacity(hdr);
+ if (capacity == 0) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ result = ucache_optimistic_locate(hdr, capacity, record, seq, epoch, true, &snapshot);
+ if (result != UCACHE_OPTIMISTIC_FOUND) {
+ return result;
+ }
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE) {
+ if (ucache_key_record_val_retained(record)) {
+ ZVAL_COPY(return_value, &record->val);
+
+ return UCACHE_OPTIMISTIC_FOUND;
+ }
+
+ ucache_key_record_release_val(record, NULL);
+ }
+
+ if (ucache_scalar_to_zval(
+ ucache_entry_val_type(&snapshot),
+ &snapshot.long_val,
+ &snapshot.double_val,
+ return_value
+ )
+ ) {
+ return UCACHE_OPTIMISTIC_FOUND;
+ }
+
+ switch (ucache_entry_val_type(&snapshot)) {
+ case UCACHE_VAL_STR:
+ return ucache_optimistic_emit_str(hdr, record, seq, &snapshot, return_value);
+ case UCACHE_VAL_SGRAPH:
+ return ucache_optimistic_emit_sgraph(hdr, record, seq, &snapshot, return_value, allow_decode);
+ default:
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+}
+
+ucache_req_local_slot *ucache_find_req_local_slot(
+ zend_string *key,
+ uint64_t gen)
+{
+ ucache_req_local_slot *slot;
+ HashTable **slots_ptr = &UC_G(req_local_slot_table);
+
+ ucache_req_local_slots_check_fork();
+
+ if (*slots_ptr == NULL) {
+ return NULL;
+ }
+
+ slot = zend_hash_find_ptr(*slots_ptr, key);
+ if (slot == NULL) {
+ return NULL;
+ }
+
+ if (slot->gen != gen || slot->ctx != ucache_active_ctx()) {
+ ucache_release_req_local_slot(key);
+
+ return NULL;
+ }
+
+ return slot;
+}
+
+void ucache_retry_op_lease_releases(void)
+{
+ ucache_op_lease *lease, *next;
+
+ for (lease = UC_G(op_leases); lease != NULL; lease = next) {
+ next = lease->next;
+
+ if (lease->users == 0) {
+ ucache_op_lease_release(lease);
+ }
+ }
+}
+
+void ucache_release_op_leases(void)
+{
+ ucache_op_lease *lease, *next;
+ ucache_ctx *prev;
+ uint64_t pid = ucache_cached_pid();
+
+ lease = UC_G(op_leases);
+
+ UC_G(op_leases) = NULL;
+
+ while (lease != NULL) {
+ next = lease->next;
+ if (lease->acquired && lease->owner_pid == pid) {
+ prev = ucache_activate_ctx(lease->ctx);
+ (void) ucache_sgraph_release_op_ref(lease->payload_offset);
+ ucache_restore_ctx(prev);
+ }
+
+ if (lease != &UC_G(op_lease_inline)) {
+ efree(lease);
+ }
+
+ lease = next;
+ }
+
+ lease = UC_G(op_lease_free);
+
+ UC_G(op_lease_free) = NULL;
+
+ while (lease != NULL) {
+ next = lease->next;
+
+ efree(lease);
+
+ lease = next;
+ }
+
+ memset(&UC_G(op_lease_inline), 0, sizeof(UC_G(op_lease_inline)));
+}
+
+bool ucache_prepare_val(
+ zend_string *key,
+ zval *val,
+ ucache_prepared_val *prepared)
+{
+ HashTable verbatim_verdicts;
+ size_t verbatim_glen = 0;
+ bool has_verbatim_verdicts = false, result, outer_pass_overflowed = UC_G(stack_overflowed);
+
+ ucache_init_prepared_val(prepared);
+
+ ZVAL_DEREF(val);
+
+ prepared->hash = zend_string_hash_val(key);
+
+ switch (Z_TYPE_P(val)) {
+ case IS_RESOURCE:
+ return false;
+ case IS_OBJECT:
+ if (Z_OBJCE_P(val) == zend_ce_closure) {
+ return false;
+ }
+
+ break;
+ case IS_ARRAY:
+ if (EXPECTED(!EG(exception))) {
+ zend_hash_init(&verbatim_verdicts, 8, NULL, NULL, 0);
+
+ has_verbatim_verdicts = true;
+
+ ucache_sgraph_calc_verbatim_root(val, &verbatim_verdicts, &verbatim_glen);
+ }
+
+ break;
+ default:
+ break;
+ }
+
+ if (ucache_prepare_direct_val(val, prepared)) {
+ result = true;
+
+ goto done;
+ }
+
+ if (!has_verbatim_verdicts) {
+ zend_hash_init(&verbatim_verdicts, 8, NULL, NULL, 0);
+
+ has_verbatim_verdicts = true;
+ }
+
+ result = ucache_prepare_sgraph_val(val, &verbatim_verdicts, verbatim_glen, prepared);
+
+done:
+ if (has_verbatim_verdicts) {
+ zend_hash_destroy(&verbatim_verdicts);
+ }
+
+ if (outer_pass_overflowed) {
+ UC_G(stack_overflowed) = true;
+ }
+
+ return result;
+}
+
+void ucache_destroy_prepared_val(ucache_prepared_val *prepared)
+{
+ zend_ulong memo_key;
+ zval *memo_val;
+
+ if (prepared->owner_type == UCACHE_PREPARED_OWNER_BUF) {
+ efree(prepared->owned_buf);
+ } else if (prepared->owner_type == UCACHE_PREPARED_OWNER_STR) {
+ zend_string_release(prepared->owned_str);
+ }
+
+ if (prepared->fixup_offsets != NULL) {
+ efree(prepared->fixup_offsets);
+ }
+
+ if (prepared->state_memo != NULL) {
+ ZEND_HASH_FOREACH_NUM_KEY_VAL(prepared->state_memo, memo_key, memo_val) {
+ if (Z_TYPE_P(memo_val) == IS_ARRAY || Z_TYPE_P(memo_val) == IS_STRING) {
+ OBJ_RELEASE((zend_object *) (uintptr_t) memo_key);
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ zend_hash_destroy(prepared->state_memo);
+
+ efree(prepared->state_memo);
+ }
+}
+
+bool ucache_store_prepared_locked(
+ zend_string *key,
+ zval *val,
+ const ucache_prepared_val *prepared,
+ zend_long ttl,
+ bool bulk,
+ ucache_store_result *result)
+{
+ ucache_maybe_expunge_expired_locked();
+
+ return ucache_store_prepared_locked_impl(key, val, prepared, ttl, bulk, result);
+}
+
+void ucache_key_record_reset(ucache_key_record *record, zval *detached)
+{
+ ucache_key_record_reset_impl(record, detached);
+}
+
+void ucache_key_record_stored(
+ ucache_key_record *record,
+ const ucache_store_result *result,
+ zval *val)
+{
+ ucache_key_record_mark_stored(record, result->stored_gen, result->stored_slot, result->stored_expires_at);
+
+ ZVAL_DEREF(val);
+
+ if (result->stored_val_type < UCACHE_VAL_STR) {
+ ucache_key_record_cache_copy(record, val, 0);
+ } else if (result->stored_val_type == UCACHE_VAL_STR &&
+ Z_REFCOUNTED_P(val) &&
+ Z_STRLEN_P(val) >= UCACHE_RECORD_STORE_STR_MIN_LEN
+ ) {
+ ucache_key_record_cache_copy(record, val, Z_STRLEN_P(val));
+ }
+}
+
+void ucache_key_record_deleted(ucache_key_record *record, uint64_t epoch)
+{
+ if (epoch == 0) {
+ ucache_key_record_reset_impl(record, NULL);
+
+ return;
+ }
+
+ ucache_key_record_mark_miss(record, epoch, NULL);
+}
+
+void ucache_key_record_atomic_updated(
+ ucache_key_record *record,
+ const ucache_atomic_update_result *result)
+{
+ if (result->stored_gen == 0) {
+ return;
+ }
+
+ ucache_key_record_mark_stored(record, result->stored_gen, result->stored_slot, result->stored_expires_at);
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE) {
+ return;
+ }
+
+ ZVAL_LONG(&record->val, result->new_val);
+
+ record->val_kind = UCACHE_RECORD_VAL_COPY;
+}
+
+void ucache_fetch_finish(
+ ucache_key_record *record,
+ ucache_fetch_pending_seed *pending_seed,
+ zval *return_value)
+{
+ if (return_value != NULL &&
+ pending_seed->should_seed &&
+ (
+ !(pending_seed->flags & UCACHE_FETCH_FINISH_RECORD_COPY) ||
+ record->mutation_epoch == pending_seed->mutation_epoch
+ )
+ ) {
+ ucache_fetch_finish_impl(
+ record,
+ pending_seed->gen,
+ return_value,
+ pending_seed->flags,
+ pending_seed->val_len
+ );
+ }
+
+ zval_ptr_dtor(&pending_seed->detached_val);
+}
+
+bool ucache_fetch_locked(
+ ucache_key_record *record,
+ zval *return_value,
+ bool *found,
+ ucache_fetch_pending_seed *pending_seed,
+ bool *lock_held)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entries, *entry;
+ ucache_fetch_locate_result locate;
+ uint64_t epoch;
+ uint32_t slot_idx = 0;
+
+ ZEND_ASSERT(*lock_held && UC_G(lock_held));
+
+ *found = false;
+
+ pending_seed->should_seed = false;
+ pending_seed->gen = 0;
+
+ ZVAL_UNDEF(&pending_seed->detached_val);
+
+ hdr = ucache_hdr_ptr();
+ if (hdr == NULL || !ucache_hdr_adoptable_locked()) {
+ return false;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ epoch = hdr->mutation_epoch;
+
+ locate = ucache_fetch_probe_key_record_locked(
+ hdr,
+ record,
+ entries,
+ epoch,
+ return_value,
+ &slot_idx,
+ &pending_seed->detached_val
+ );
+ if (locate == UCACHE_FETCH_LOCATE_UNCACHED) {
+ locate = ucache_fetch_probe_entry_table_locked(
+ hdr,
+ record,
+ entries,
+ epoch,
+ &slot_idx,
+ &pending_seed->detached_val
+ );
+ }
+
+ if (locate == UCACHE_FETCH_LOCATE_VAL_HIT) {
+ *found = true;
+
+ return true;
+ }
+
+ if (locate == UCACHE_FETCH_LOCATE_MISS) {
+ return false;
+ }
+
+ entry = &entries[slot_idx];
+
+ ucache_touch_entry_access(hdr, hdr->capacity, slot_idx);
+
+ *found = true;
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE) {
+ if (ucache_key_record_val_retained(record)) {
+ ZVAL_COPY(return_value, &record->val);
+
+ return true;
+ }
+
+ ucache_key_record_release_val(record, &pending_seed->detached_val);
+ }
+
+ return ucache_fetch_emit_val_locked(hdr, entry, epoch, return_value, pending_seed, lock_held);
+}
+
+bool ucache_exists_locked(zend_string *key)
+{
+ zend_ulong hash = zend_string_hash_val(key);
+ uint32_t slot_idx;
+ bool found;
+
+ if (!ucache_find_slot_locked(key, hash, UCACHE_FIND_SLOT_SKIP_EXPIRED, NULL, &slot_idx, &found)) {
+ return false;
+ }
+
+ return found;
+}
+
+void ucache_discard_replaced_entry_locked(ucache_entry *replaced_entry)
+{
+ if (!ucache_entry_is_used(replaced_entry)) {
+ return;
+ }
+
+ ucache_release_entry_storage_locked(replaced_entry);
+
+ memset(replaced_entry, 0, sizeof(*replaced_entry));
+}
+
+void ucache_rollback_replaced_entry_locked(
+ zend_string *key,
+ ucache_entry *replaced_entry)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entry;
+ uint32_t slot_idx;
+ bool found;
+
+ if (!ucache_find_slot_locked(
+ key,
+ zend_string_hash_val(key),
+ UCACHE_FIND_SLOT_IGNORE_EXPIRY,
+ &hdr,
+ &slot_idx,
+ &found
+ )
+ ) {
+ return;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[slot_idx];
+
+ if (ucache_entry_is_used(replaced_entry)) {
+ if (found) {
+ ucache_pool_idx_unlink_locked(hdr, entry, slot_idx);
+ ucache_release_entry_storage_locked(entry);
+ } else {
+ if (ucache_entry_is_tombstone(entry) && hdr->tombstone_count != 0) {
+ hdr->tombstone_count--;
+ }
+
+ hdr->count++;
+ }
+
+ if (found && entry->expires_at != 0) {
+ hdr->expiring_count--;
+ }
+
+ if (replaced_entry->expires_at != 0) {
+ hdr->expiring_count++;
+ }
+
+ *entry = *replaced_entry;
+
+ ucache_expiry_floor_lower_locked(hdr, entry->expires_at);
+
+ ucache_pool_idx_link_locked(hdr, entry, slot_idx);
+ ucache_pool_bucket_changed_locked(hdr, ucache_entry_pool_bucket(entry));
+ ucache_entry_set_block_owner(entry, slot_idx);
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ entry->gen = hdr->mutation_epoch;
+
+ memset(replaced_entry, 0, sizeof(*replaced_entry));
+ } else if (found) {
+ ucache_delete_entry_locked(hdr, entry, slot_idx);
+ }
+}
+
+uint64_t ucache_delete_locked(zend_string *key)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entries;
+ zend_ulong hash = zend_string_hash_val(key);
+ uint32_t slot_idx;
+ bool found;
+
+ ucache_maybe_expunge_expired_locked();
+
+ if (ucache_find_slot_for_write_locked(key, hash, &hdr, &slot_idx, &found) && found) {
+ entries = ucache_entries_ptr(hdr);
+
+ ucache_delete_entry_locked(hdr, &entries[slot_idx], slot_idx);
+
+ ucache_maybe_rehash_locked();
+ }
+
+ return ucache_hdr_is_initialized_locked()
+ ? ucache_hdr_ptr()->mutation_epoch
+ : 0
+ ;
+}
+
+uint64_t ucache_delete_gen_locked(zend_string *key, uint64_t gen)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entry;
+ uint32_t slot_idx;
+ bool found;
+
+ if (!ucache_find_slot_for_write_locked(key, zend_string_hash_val(key), &hdr, &slot_idx, &found) || !found) {
+ return 0;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[slot_idx];
+ if (entry->gen != gen) {
+ return 0;
+ }
+
+ ucache_delete_entry_locked(hdr, entry, slot_idx);
+
+ ucache_maybe_rehash_locked();
+
+ return hdr->mutation_epoch;
+}
+
+void ucache_delete_by_prefix_locked(zend_string *prefix)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entries, *entry;
+ ucache_pool_links *links;
+ uint32_t bucket, ref, next, slot, walked = 0;
+
+ hdr = ucache_hdr_ptr();
+ if (hdr == NULL || !ucache_hdr_adoptable_locked()) {
+ return;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ links = ucache_pool_links_ptr(hdr);
+ bucket = (uint32_t) (zend_string_hash_val(prefix) & (UCACHE_POOL_BUCKETS - 1));
+ for (ref = hdr->pool_bucket_heads[bucket]; ref != 0; ref = next) {
+ ZEND_ASSERT(ucache_pool_link_ref_fits(hdr, ref) && walked < hdr->capacity);
+
+ if (!ucache_pool_link_ref_fits(hdr, ref) || walked == hdr->capacity) {
+ hdr->pool_bucket_heads[bucket] = 0;
+
+ ucache_pool_bucket_changed_locked(hdr, bucket);
+
+ break;
+ }
+
+ walked++;
+ slot = ucache_pool_link_ref_slot(ref);
+ entry = &entries[slot];
+ next = links[slot].next;
+ if (!ucache_entry_is_used(entry) ||
+ entry->key_len < ZSTR_LEN(prefix) ||
+ memcmp(
+ ucache_entry_key_in_hdr(hdr, entry),
+ ZSTR_VAL(prefix),
+ ZSTR_LEN(prefix)
+ ) != 0
+ ) {
+ continue;
+ }
+
+ ucache_delete_entry_locked(hdr, entry, slot);
+ }
+
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ ucache_maybe_rehash_locked();
+}
+
+bool ucache_atomic_update_locked(
+ zend_string *key,
+ zend_long step,
+ zend_long ttl,
+ bool decrement,
+ ucache_atomic_update_result *result)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entries, *entry;
+ zend_ulong hash = zend_string_hash_val(key);
+ zend_long updated;
+ uint32_t slot_idx;
+ bool found, is_overflow;
+
+ result->new_val = 0;
+ result->stored_gen = 0;
+ result->is_overflow = false;
+ result->is_type_err = false;
+
+ ucache_maybe_expunge_expired_locked();
+
+ if (!ucache_find_slot_for_write_locked(key, hash, &hdr, &slot_idx, &found) || !found) {
+ return ucache_atomic_insert_missing_locked(
+ key,
+ step,
+ ttl,
+ decrement,
+ result
+ );
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ entry = &entries[slot_idx];
+ if (ucache_entry_val_type(entry) != UCACHE_VAL_LONG) {
+ result->is_type_err = true;
+
+ return false;
+ }
+
+ is_overflow = decrement
+ ? ucache_long_sub_overflow(entry->long_val, step, &updated)
+ : ucache_long_add_overflow(entry->long_val, step, &updated)
+ ;
+ if (is_overflow) {
+ result->is_overflow = true;
+
+ return false;
+ }
+
+ entry->long_val = updated;
+
+ ucache_touch_entry_access(hdr, hdr->capacity, slot_idx);
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ entry->gen = hdr->mutation_epoch;
+
+ result->new_val = entry->long_val;
+ result->stored_gen = entry->gen;
+ result->stored_slot = slot_idx;
+ result->stored_expires_at = entry->expires_at;
+ if (entry->expires_at == 0 && ttl == 0) {
+ ucache_scalar_write_enable_locked(hdr);
+ }
+
+ return true;
+}
+
+bool ucache_try_store_scalar(
+ ucache_key_record *record,
+ const ucache_prepared_val *prepared)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entry;
+ uint32_t stripe_idx, slot_idx;
+
+ ZEND_ASSERT(prepared->val_type <= UCACHE_VAL_DOUBLE);
+
+ if (ucache_key_record_is_cur_miss(record)) {
+ return false;
+ }
+
+ ucache_scalar_write_release_record_val(record);
+
+ if (!ucache_scalar_write_begin(prepared->hash, &hdr, &stripe_idx)) {
+ return false;
+ }
+
+ if (!ucache_scalar_write_locate(hdr, record, &slot_idx)) {
+ ucache_scalar_write_end();
+
+ return false;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[slot_idx];
+ if (entry->expires_at != 0 || !ucache_entry_holds_scalar(entry) ||
+ !ucache_scalar_write_can_skip_sweep()
+ ) {
+ ucache_scalar_write_end();
+
+ return false;
+ }
+
+ ucache_scalar_write_prepare(hdr, stripe_idx, slot_idx);
+
+ ucache_entry_set_val_type(entry, prepared->val_type);
+ if (prepared->val_type == UCACHE_VAL_DOUBLE) {
+ entry->double_val = prepared->double_val;
+ } else {
+ ucache_entry_set_long_zero_filled(
+ entry,
+ prepared->val_type == UCACHE_VAL_LONG ? prepared->long_val : 0
+ );
+ }
+
+ ucache_touch_entry_access(hdr, hdr->capacity, slot_idx);
+
+ ucache_scalar_write_commit(hdr, stripe_idx, entry);
+
+ ucache_key_record_mark_hit(record, entry->gen, slot_idx, entry, NULL);
+
+ ucache_scalar_write_end();
+
+ return true;
+}
+
+bool ucache_try_atomic_update(
+ ucache_key_record *record,
+ zend_long step,
+ zend_long ttl,
+ bool decrement,
+ ucache_atomic_update_result *result,
+ bool *updated)
+{
+ ucache_hdr *hdr;
+ ucache_entry *entry;
+ zend_long val;
+ uint32_t stripe_idx, slot_idx;
+
+ if (ttl != 0 || ucache_key_record_is_cur_miss(record)) {
+ return false;
+ }
+
+ ucache_scalar_write_release_record_val(record);
+
+ if (!ucache_scalar_write_begin(ZSTR_H(record->storage_key), &hdr, &stripe_idx)) {
+ return false;
+ }
+
+ if (!ucache_scalar_write_locate(hdr, record, &slot_idx)) {
+ ucache_scalar_write_end();
+
+ return false;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[slot_idx];
+ if (entry->expires_at != 0 || !ucache_entry_holds_scalar(entry) ||
+ !ucache_scalar_write_can_skip_sweep()
+ ) {
+ ucache_scalar_write_end();
+
+ return false;
+ }
+
+ memset(result, 0, sizeof(*result));
+
+ *updated = false;
+
+ if (ucache_entry_val_type(entry) != UCACHE_VAL_LONG) {
+ result->is_type_err = true;
+ } else if (decrement
+ ? ucache_long_sub_overflow(entry->long_val, step, &val)
+ : ucache_long_add_overflow(entry->long_val, step, &val)
+ ) {
+ result->is_overflow = true;
+ } else {
+ ucache_scalar_write_prepare(hdr, stripe_idx, slot_idx);
+ entry->long_val = val;
+
+ ucache_touch_entry_access(hdr, hdr->capacity, slot_idx);
+
+ ucache_scalar_write_commit(hdr, stripe_idx, entry);
+
+ ucache_key_record_mark_hit(record, entry->gen, slot_idx, entry, NULL);
+
+ result->new_val = val;
+ *updated = true;
+ }
+
+ ucache_scalar_write_end();
+
+ return true;
+}
+
+PHP_UCACHE_HOT ucache_optimistic_result ucache_fetch_optimistic(
+ ucache_key_record *record,
+ zval *return_value,
+ bool allow_decode)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ zend_string *key = record->storage_key;
+ uint64_t seq, epoch;
+
+ if (!UCACHE_OPTIMISTIC_ENABLED || hdr == NULL || UCACHE_DEBUG_FAULT("FORCE_LOCKED_FETCH")) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ seq = ucache_load_seq(hdr, key);
+ if (seq < 2 || (seq & 1) != 0) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ epoch = ucache_atomic_load_64(&hdr->mutation_epoch);
+
+ if (record->mutation_epoch == epoch) {
+ if (record->state == UCACHE_RECORD_MISS) {
+ return UCACHE_OPTIMISTIC_MISS;
+ }
+
+ if (record->val_kind != UCACHE_RECORD_VAL_NONE &&
+ ucache_key_record_val_usable(hdr, record)
+ ) {
+ if (record->expires_at != 0 && ucache_read_seq(hdr, key) != seq) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ ZVAL_COPY(return_value, &record->val);
+
+ ucache_touch_cached_entry_access(hdr, record->slot_idx, &record->access_touches);
+
+ return UCACHE_OPTIMISTIC_FOUND;
+ }
+ }
+
+ return ucache_fetch_optimistic_entry(hdr, record, seq, epoch, return_value, allow_decode);
+}
+
+PHP_UCACHE_HOT ucache_optimistic_result ucache_exists_optimistic(ucache_key_record *record)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ zend_string *key = record->storage_key;
+ uint64_t seq, epoch;
+
+ if (!UCACHE_OPTIMISTIC_ENABLED || hdr == NULL) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ seq = ucache_load_seq(hdr, key);
+ if (seq < 2 || (seq & 1) != 0) {
+ return UCACHE_OPTIMISTIC_FALLBACK;
+ }
+
+ epoch = ucache_atomic_load_64(&hdr->mutation_epoch);
+
+ if (record->mutation_epoch == epoch) {
+ if (record->state == UCACHE_RECORD_MISS) {
+ return UCACHE_OPTIMISTIC_MISS;
+ }
+
+ if (record->state == UCACHE_RECORD_HIT && record->expires_at == 0) {
+ return UCACHE_OPTIMISTIC_FOUND;
+ }
+ }
+
+ return ucache_exists_optimistic_entry(hdr, record, seq, epoch);
+}
diff --git a/ext/user_cache/user_cache_entries.h b/ext/user_cache/user_cache_entries.h
new file mode 100644
index 000000000000..58b8d82309cb
--- /dev/null
+++ b/ext/user_cache/user_cache_entries.h
@@ -0,0 +1,308 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef UCACHE_ENTRIES_H
+#define UCACHE_ENTRIES_H
+
+#include "user_cache_internal.h"
+
+#define UCACHE_EXPIRED_READ_EXPUNGE_THRESHOLD 64U
+
+typedef struct {
+ uint64_t gen;
+ const ucache_ctx *ctx;
+ bool needs_deep_clone;
+ bool has_clone_verdicts;
+ bool no_aliases;
+ bool has_val;
+ bool proto_rejected;
+ bool released_while_cloning;
+ uint16_t clone_depth;
+ zval val;
+} ucache_req_local_slot;
+
+typedef struct {
+ ucache_req_local_slot slot;
+ HashTable clone_verdicts;
+} ucache_req_local_slot_with_verdicts;
+
+typedef struct {
+ bool expired;
+ bool slot_pressure_evict;
+ bool mem_pressure_evict;
+ bool clear;
+ bool room_checked;
+ bool room_after_clear;
+ uint32_t room_entry_lock_count;
+} ucache_store_retries;
+
+ucache_req_local_slot *ucache_find_req_local_slot(
+ zend_string *key,
+ uint64_t gen);
+void ucache_req_local_slot_free(ucache_req_local_slot *slot);
+bool ucache_clone_req_local_slot_val_known(
+ zval *dst,
+ zval *src,
+ HashTable *verdicts,
+ bool no_aliases,
+ bool *proto_rejected);
+void ucache_req_local_slots_drop_oldest_half(void);
+void ucache_store_req_local_slot(
+ zend_string *key,
+ uint64_t gen,
+ zval *val,
+ bool no_aliases,
+ uint32_t charged_bytes);
+void ucache_mark_req_local_slot(zend_string *key, uint64_t gen);
+void ucache_expunge_expired_bounded_locked(void);
+bool ucache_reclaim_space_for_store_locked(
+ bool fits_in_empty_cache,
+ size_t needed_size,
+ ucache_store_retries *retries);
+
+static zend_always_inline bool ucache_entry_is_tombstone(const ucache_entry *entry)
+{
+ return ucache_entry_kind(entry) == UCACHE_ENTRY_TOMBSTONE;
+}
+
+static zend_always_inline void ucache_entry_set_kind(ucache_entry *entry, uint32_t kind)
+{
+ entry->flags = (uint16_t) ((entry->flags & ~UCACHE_ENTRY_KIND_MASK) | (kind << UCACHE_ENTRY_KIND_SHIFT));
+}
+
+static zend_always_inline void ucache_entry_set_tombstone(ucache_entry *entry)
+{
+ ucache_entry_set_kind(entry, UCACHE_ENTRY_TOMBSTONE);
+}
+
+static zend_always_inline bool ucache_record_val_fits(size_t bytes)
+{
+ size_t budget = ucache_req_cache_budget();
+
+ return bytes <= UINT32_MAX &&
+ UC_G(record_val_bytes) <= budget &&
+ bytes <= budget - UC_G(record_val_bytes)
+ ;
+}
+
+static zend_always_inline uint32_t ucache_entry_pool_bucket(const ucache_entry *entry)
+{
+ return (entry->flags & UCACHE_ENTRY_POOL_BUCKET_MASK)
+ >> UCACHE_ENTRY_POOL_BUCKET_SHIFT
+ ;
+}
+
+static zend_always_inline uint32_t ucache_pool_link_ref_slot(uint32_t ref)
+{
+ ZEND_ASSERT(ref != 0);
+
+ return ref - 1;
+}
+
+static zend_always_inline void ucache_pool_idx_unlink_locked(
+ ucache_hdr *hdr,
+ ucache_entry *entry,
+ uint32_t slot)
+{
+ ucache_pool_links *links = ucache_pool_links_ptr(hdr), *link = &links[slot];
+ uint32_t bucket = ucache_entry_pool_bucket(entry), prev = link->prev, next = link->next;
+
+ ZEND_ASSERT(ucache_pool_link_ref_fits(hdr, prev) && ucache_pool_link_ref_fits(hdr, next));
+
+ if (!ucache_pool_link_ref_fits(hdr, prev) || !ucache_pool_link_ref_fits(hdr, next)) {
+ hdr->pool_bucket_heads[bucket] = 0;
+ } else {
+ if (prev != 0) {
+ links[ucache_pool_link_ref_slot(prev)].next = next;
+ } else {
+ hdr->pool_bucket_heads[bucket] = next;
+ }
+
+ if (next != 0) {
+ links[ucache_pool_link_ref_slot(next)].prev = prev;
+ }
+ }
+
+ link->prev = 0;
+ link->next = 0;
+
+ ucache_pool_bucket_changed_locked(hdr, bucket);
+}
+
+static zend_always_inline uint32_t ucache_release_val_storage_locked(uint8_t val_type, uint32_t val_offset)
+{
+ bool gquiescent;
+
+ if (val_offset == 0) {
+ return 0;
+ }
+
+ switch (val_type) {
+ case UCACHE_VAL_SGRAPH:
+ gquiescent = ucache_quiesce_graph_payloads_locked();
+
+ if (!ucache_sgraph_retire_payload_locked(val_offset)) {
+ return 0;
+ }
+
+ if (!gquiescent) {
+ ucache_sgraph_orphan_payload_locked(val_offset);
+
+ return 0;
+ }
+
+ return ucache_free_locked(val_offset);
+ case UCACHE_VAL_STR:
+ return ucache_free_locked(val_offset);
+ default:
+ return 0;
+ }
+}
+
+static zend_always_inline uint32_t ucache_release_entry_storage_locked(ucache_entry *entry)
+{
+ uint32_t val_offset, key_region = 0, val_region = 0;
+ bool combined;
+
+ combined = (entry->flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) != 0;
+
+ if (entry->key_offset != 0 && !combined) {
+ key_region = ucache_free_locked(entry->key_offset);
+ }
+
+ val_offset = ucache_entry_val_offset(entry);
+ if (val_offset != 0) {
+ val_region = ucache_release_val_storage_locked(
+ ucache_entry_val_type(entry),
+ val_offset
+ );
+ }
+
+ return val_region != 0
+ ? val_region
+ : key_region
+ ;
+}
+
+static zend_always_inline uint32_t ucache_delete_entry_locked(
+ ucache_hdr *hdr,
+ ucache_entry *entry,
+ uint32_t slot)
+{
+ uint32_t region;
+
+ if (ucache_entry_is_used(entry)) {
+ ucache_pool_idx_unlink_locked(hdr, entry, slot);
+
+ if (hdr->count != 0) {
+ hdr->count--;
+
+ if (entry->expires_at != 0) {
+ hdr->expiring_count--;
+ }
+ }
+ }
+
+ if (!ucache_entry_is_tombstone(entry)) {
+ hdr->tombstone_count++;
+ }
+
+ region = ucache_release_entry_storage_locked(entry);
+
+ entry->hash = 0;
+ entry->key_offset = 0;
+ entry->key_len = 0;
+ entry->flags = 0;
+
+ ucache_entry_set_tombstone(entry);
+
+ entry->expires_at = 0;
+ entry->gen = 0;
+ entry->double_val = 0;
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ return region;
+}
+
+static zend_always_inline void ucache_release_req_local_slot_table(HashTable **slots_ptr)
+{
+ HashTable *slots = *slots_ptr;
+
+ if (slots == NULL) {
+ return;
+ }
+
+ *slots_ptr = NULL;
+
+ UC_G(req_local_slot_owner_pid) = 0;
+
+ zend_hash_destroy(slots);
+
+ FREE_HASHTABLE(slots);
+}
+
+static zend_always_inline void ucache_req_local_slots_check_fork(void)
+{
+ if (UC_G(req_local_slot_table) != NULL &&
+ UNEXPECTED(UC_G(req_local_slot_owner_pid) != ucache_cached_pid())
+ ) {
+ ucache_release_req_local_slot_table(&UC_G(req_local_slot_table));
+ }
+}
+
+static zend_always_inline bool ucache_is_expired(const ucache_entry *entry, uint64_t now_rel)
+{
+ return ucache_entry_is_used(entry) &&
+ entry->expires_at != 0 &&
+ (uint64_t) entry->expires_at <= now_rel
+ ;
+}
+
+static zend_always_inline void ucache_access_note_time(uint64_t now)
+{
+ uint32_t seconds = (uint32_t) (now / UCACHE_CLOCK_TICKS_PER_SEC);
+
+ if (seconds != 0) {
+ UC_G(access_now) = seconds;
+ UC_G(access_now_touches) = 0;
+ }
+}
+
+static zend_always_inline bool ucache_payload_can_fit_locked(size_t size)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ size_t total_size;
+
+ if (
+ !hdr ||
+ size == 0 ||
+ size > UCACHE_BLOCK_SIZE_MAX - sizeof(ucache_block)
+ ) {
+ return false;
+ }
+
+ total_size = ucache_block_total_size(size);
+
+ return total_size <= UCACHE_BLOCK_SIZE_MAX && total_size <= hdr->data_size;
+}
+
+static zend_always_inline HashTable *ucache_req_local_slot_verdicts(ucache_req_local_slot *slot)
+{
+ ZEND_ASSERT(slot->needs_deep_clone);
+
+ return &((ucache_req_local_slot_with_verdicts *) slot)->clone_verdicts;
+}
+
+#endif /* UCACHE_ENTRIES_H */
diff --git a/ext/user_cache/user_cache_entry_lock.c b/ext/user_cache/user_cache_entry_lock.c
new file mode 100644
index 000000000000..19144993aa4c
--- /dev/null
+++ b/ext/user_cache/user_cache_entry_lock.c
@@ -0,0 +1,1335 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_storage.h"
+
+#define UCACHE_ENTRY_LOCK_SWEEP_INTERVAL_TICKS UCACHE_CLOCK_TICKS_PER_SEC
+#define UCACHE_DEFERRED_RELEASE_RETRY_CTXS 16U
+#define UCACHE_ENTRY_LOCK_LEASED_DIVISOR 4U
+#define UCACHE_ENTRY_LOCK_KEY_BYTES_DIVISOR 16U
+
+#ifdef ZTS
+static ucache_entry_lock *ucache_orphaned_entry_lock_releases = NULL;
+static atomic_bool ucache_orphaned_entry_lock_releases_pending = false;
+#endif
+
+static zend_always_inline bool ucache_entry_lock_record_key_matches(
+ const ucache_entry_lock_record *record,
+ const char *key,
+ size_t key_len,
+ zend_ulong hash)
+{
+ return record->state == UCACHE_ENTRY_LOCK_USED &&
+ record->hash == hash &&
+ record->key_len == key_len &&
+ memcmp(ucache_ptr(record->key_offset), key, key_len) == 0
+ ;
+}
+
+static zend_always_inline void ucache_entry_lock_record_disown_locked(ucache_entry_lock_record *record)
+{
+ record->owner_pid = 0;
+ record->owner_start_time = 0;
+ record->owner_token = 0;
+}
+
+static bool ucache_entry_lock_record_is_stale(const ucache_entry_lock_record *record, uint64_t now_rel)
+{
+ if (record->expires_at != 0) {
+ return (uint64_t) record->expires_at <= now_rel;
+ }
+
+ return record->owner_pid == 0;
+}
+
+static uint32_t ucache_entry_lock_expires_at(
+ const ucache_hdr *hdr,
+ zend_long lease)
+{
+ ZEND_ASSERT(lease > 0);
+
+ return ucache_expiry_deadline(hdr, ucache_clock_now(), lease);
+}
+
+static void ucache_remove_entry_lock_record_locked(
+ ucache_entry_lock_record *record)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ ZEND_ASSERT(record->state == UCACHE_ENTRY_LOCK_USED);
+ ZEND_ASSERT(hdr->entry_lock_count > 0);
+
+ if (record->key_offset != 0) {
+ ucache_free_locked(record->key_offset);
+ }
+
+ hdr->entry_lock_count--;
+ hdr->entry_lock_tombstone_count++;
+
+ memset(record, 0, sizeof(*record));
+
+ record->state = UCACHE_ENTRY_LOCK_TOMBSTONE;
+}
+
+static void ucache_rehash_entry_locks_locked(ucache_hdr *hdr)
+{
+ ucache_entry_lock_record *records, copy;
+ uint32_t mask = hdr->entry_lock_capacity - 1, idx, slot;
+ bool moved;
+
+ if (hdr->entry_lock_tombstone_count < hdr->entry_lock_capacity / 4) {
+ return;
+ }
+
+ records = ucache_entry_lock_records_ptr(hdr);
+ if (hdr->entry_lock_count == 0) {
+ memset(records, 0, (size_t) hdr->entry_lock_capacity * sizeof(*records));
+
+ hdr->entry_lock_tombstone_count = 0;
+
+ return;
+ }
+
+ for (idx = 0; idx < hdr->entry_lock_capacity; idx++) {
+ if (records[idx].state == UCACHE_ENTRY_LOCK_TOMBSTONE) {
+ memset(&records[idx], 0, sizeof(records[idx]));
+ }
+ }
+
+ hdr->entry_lock_tombstone_count = 0;
+
+ do {
+ moved = false;
+
+ for (idx = 0; idx < hdr->entry_lock_capacity; idx++) {
+ if (records[idx].state != UCACHE_ENTRY_LOCK_USED) {
+ continue;
+ }
+
+ for (slot = ucache_entry_lock_table_idx(hdr, records[idx].hash); slot != idx; slot = (slot + 1) & mask) {
+ if (records[slot].state != UCACHE_ENTRY_LOCK_EMPTY) {
+ continue;
+ }
+
+ copy = records[idx];
+ copy.state = UCACHE_ENTRY_LOCK_EMPTY;
+ records[slot] = copy;
+
+ UCACHE_ATOMIC_FENCE_SEQ_CST();
+ records[slot].state = UCACHE_ENTRY_LOCK_USED;
+ UCACHE_ATOMIC_FENCE_SEQ_CST();
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_IN_ENTRY_LOCK_REHASH");
+
+ memset(&records[idx], 0, sizeof(records[idx]));
+
+ moved = true;
+
+ break;
+ }
+ }
+ } while (moved);
+}
+
+static bool ucache_find_entry_lock_record_slot_raw_locked(
+ ucache_hdr *hdr,
+ const char *key,
+ size_t key_len,
+ zend_ulong hash,
+ bool inserting,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ ucache_entry_lock_record *record;
+ uint64_t now = 0;
+ uint32_t first_avail = UINT32_MAX, i, probe;
+ bool swept = false;
+
+ *found = false;
+
+ ucache_rehash_entry_locks_locked(hdr);
+
+ if (hdr->entry_lock_count == 0) {
+ *slot_idx = ucache_entry_lock_table_idx(hdr, hash);
+
+ return true;
+ }
+
+ for (;;) {
+ for (probe = 0; probe < hdr->entry_lock_capacity; probe++) {
+ i = (ucache_entry_lock_table_idx(hdr, hash) + probe) & (hdr->entry_lock_capacity - 1);
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+
+ if (record->state == UCACHE_ENTRY_LOCK_EMPTY) {
+ *slot_idx = first_avail != UINT32_MAX ? first_avail : i;
+
+ return true;
+ }
+
+ if (record->state == UCACHE_ENTRY_LOCK_TOMBSTONE) {
+ if (first_avail == UINT32_MAX) {
+ first_avail = i;
+ }
+
+ continue;
+ }
+
+ if (now == 0) {
+ now = ucache_time_rel(hdr, ucache_clock_now());
+ }
+
+ if (ucache_entry_lock_record_key_matches(record, key, key_len, hash)) {
+ if (ucache_entry_lock_record_is_active_locked(record, now)) {
+ *slot_idx = i;
+ *found = true;
+
+ return true;
+ }
+ } else if (!ucache_entry_lock_record_is_stale(record, now)) {
+ continue;
+ }
+
+ ucache_remove_entry_lock_record_locked(record);
+
+ if (first_avail == UINT32_MAX) {
+ first_avail = i;
+ }
+ }
+
+ if (first_avail != UINT32_MAX) {
+ *slot_idx = first_avail;
+
+ return true;
+ }
+
+ if (!inserting || swept) {
+ return false;
+ }
+
+ swept = true;
+
+ ucache_sweep_entry_locks_locked(hdr, now);
+ }
+}
+
+static bool ucache_find_entry_lock_record_insert_slot_locked(
+ ucache_hdr *hdr,
+ zend_string *key,
+ zend_ulong hash,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ return ucache_find_entry_lock_record_slot_raw_locked(
+ hdr, ZSTR_VAL(key), ZSTR_LEN(key), hash, true, slot_idx, found
+ );
+}
+
+static bool ucache_leased_entry_lock_fits_budget_locked(ucache_hdr *hdr, size_t key_len)
+{
+ const ucache_entry_lock_record *record;
+ uint64_t key_bytes = key_len, now_rel = ucache_time_rel(hdr, ucache_clock_now());
+ uint32_t i, leased = 1;
+
+ for (i = 0; i < hdr->entry_lock_capacity; i++) {
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+ if (record->state != UCACHE_ENTRY_LOCK_USED || ucache_entry_lock_record_is_stale(record, now_rel)) {
+ continue;
+ }
+
+ key_bytes += record->key_len;
+ leased += record->expires_at != 0;
+ }
+
+ return leased <= hdr->entry_lock_capacity / UCACHE_ENTRY_LOCK_LEASED_DIVISOR &&
+ key_bytes <= hdr->data_size / UCACHE_ENTRY_LOCK_KEY_BYTES_DIVISOR
+ ;
+}
+
+static bool ucache_insert_entry_lock_record_locked(
+ ucache_hdr *hdr,
+ uint32_t slot_idx,
+ zend_string *key,
+ zend_ulong hash,
+ ucache_entry_lock *lock)
+{
+ ucache_entry_lock_record *record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+ uint64_t owner_pid;
+ uint32_t key_offset;
+
+ if (ZSTR_LEN(key) > UINT32_MAX) {
+ return false;
+ }
+
+ if (lock->lease > 0 && !ucache_leased_entry_lock_fits_budget_locked(hdr, ZSTR_LEN(key))) {
+ return false;
+ }
+
+ key_offset = ucache_alloc_locked(ZSTR_LEN(key), ZSTR_VAL(key), UCACHE_BLOCK_OWNER_NONE);
+ if (key_offset == 0 && hdr->entry_lock_count != 0) {
+ ucache_sweep_entry_locks_locked(hdr, ucache_time_rel(hdr, ucache_clock_now()));
+
+ key_offset = ucache_alloc_locked(ZSTR_LEN(key), ZSTR_VAL(key), UCACHE_BLOCK_OWNER_NONE);
+ }
+
+ if (key_offset == 0 && lock->lease == 0) {
+ ucache_write_section_announce(hdr);
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_IN_ENTRY_LOCK_EVICTION");
+
+ if (ucache_reclaim_space_for_entry_lock_locked(ZSTR_LEN(key))) {
+ key_offset = ucache_alloc_locked(
+ ZSTR_LEN(key),
+ ZSTR_VAL(key),
+ UCACHE_BLOCK_OWNER_NONE
+ );
+ }
+ }
+
+ if (key_offset == 0) {
+ return false;
+ }
+
+ owner_pid = ucache_cached_pid();
+
+ if (record->state == UCACHE_ENTRY_LOCK_TOMBSTONE) {
+ hdr->entry_lock_tombstone_count--;
+ }
+
+ hdr->entry_lock_count++;
+
+ memset(record, 0, sizeof(*record));
+
+ record->hash = hash;
+ record->owner_pid = owner_pid;
+ record->owner_start_time = ucache_cached_self_start_time_token(owner_pid);
+ record->owner_token = ++hdr->entry_lock_acquire_seq;
+ record->expires_at = lock->lease > 0 ? ucache_entry_lock_expires_at(hdr, lock->lease) : 0;
+ record->key_offset = key_offset;
+ record->key_len = (uint32_t) ZSTR_LEN(key);
+ record->state = UCACHE_ENTRY_LOCK_USED;
+
+ lock->owner_token = record->owner_token;
+ lock->owner_start_time = record->owner_start_time;
+
+ return true;
+}
+
+static bool ucache_update_entry_lock_record_lease_locked(
+ ucache_hdr *hdr,
+ zend_string *key,
+ const ucache_entry_lock *lock,
+ zend_long lease)
+{
+ ucache_entry_lock_record *record;
+ zend_ulong hash;
+ uint32_t expires_at, slot_idx;
+ bool found;
+
+ hash = zend_string_hash_val(key);
+ if (!ucache_find_entry_lock_record_slot_locked(
+ hdr,
+ key,
+ hash,
+ &slot_idx,
+ &found
+ ) || !found
+ ) {
+ return false;
+ }
+
+ record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+ if (record->owner_pid != lock->owner_pid || record->owner_token != lock->owner_token) {
+ return false;
+ }
+
+ if (lease > 0 && record->expires_at != 0) {
+ expires_at = ucache_entry_lock_expires_at(hdr, lease);
+ if (record->expires_at < expires_at) {
+ record->expires_at = expires_at;
+ }
+ }
+
+ return true;
+}
+
+static ucache_entry_lock *ucache_create_local_entry_lock(
+ ucache_ctx *ctx,
+ zend_string *key,
+ zend_long lease)
+{
+ ucache_entry_lock *lock;
+
+ if (ZSTR_LEN(key) > SIZE_MAX - sizeof(*lock) || ZSTR_LEN(key) > UINT32_MAX) {
+ return NULL;
+ }
+
+ lock = UC_G(entry_lock_spare);
+ if (lock != NULL) {
+ UC_G(entry_lock_spare) = NULL;
+
+ if (lock->key_len < ZSTR_LEN(key)) {
+ free(lock);
+
+ lock = NULL;
+ }
+ }
+
+ if (lock == NULL) {
+ lock = malloc(sizeof(*lock) + ZSTR_LEN(key));
+ if (lock == NULL) {
+ return NULL;
+ }
+ }
+
+ lock->ctx = ctx;
+ lock->next = NULL;
+ lock->owner_pid = ucache_cached_pid();
+ lock->owner_start_time = 0;
+ lock->owner_token = 0;
+ lock->lease = lease;
+ lock->key_len = (uint32_t) ZSTR_LEN(key);
+ lock->requested_by_lock = false;
+
+ memcpy(lock->key, ZSTR_VAL(key), ZSTR_LEN(key) + 1);
+
+ return lock;
+}
+
+static void ucache_defer_entry_lock_release(ucache_entry_lock *lock)
+{
+ lock->next = UC_G(deferred_entry_lock_releases);
+
+ UC_G(deferred_entry_lock_releases) = lock;
+}
+
+static void ucache_free_entry_lock_list(ucache_entry_lock *entry)
+{
+ ucache_entry_lock *next;
+
+ while (entry != NULL) {
+ next = entry->next;
+ free(entry);
+ entry = next;
+ }
+}
+
+#ifdef ZTS
+static void ucache_adopt_orphaned_entry_lock_releases(void)
+{
+ ucache_entry_lock *entry, *next;
+
+ if (!atomic_load(&ucache_orphaned_entry_lock_releases_pending)) {
+ return;
+ }
+
+ ucache_boundary_partitions_lock();
+
+ entry = ucache_orphaned_entry_lock_releases;
+ ucache_orphaned_entry_lock_releases = NULL;
+
+ atomic_store(&ucache_orphaned_entry_lock_releases_pending, false);
+
+ ucache_boundary_partitions_unlock();
+
+ while (entry != NULL) {
+ next = entry->next;
+
+ ucache_defer_entry_lock_release(entry);
+
+ entry = next;
+ }
+}
+#endif
+
+static bool ucache_add_local_entry_lock(
+ HashTable *locks,
+ zend_string *key,
+ ucache_entry_lock *lock)
+{
+ bool added = false;
+
+ zend_try {
+ added = zend_hash_add_ptr(locks, key, lock) != NULL;
+ } zend_catch {
+ lock->lease = 0;
+
+ ucache_defer_entry_lock_release(lock);
+
+ zend_bailout();
+ } zend_end_try();
+
+ return added;
+}
+
+static void ucache_release_entry_lock_record_locked(
+ ucache_hdr *hdr,
+ const ucache_entry_lock *lock,
+ zend_ulong hash)
+{
+ ucache_entry_lock_record *record;
+ uint32_t slot_idx;
+ bool found;
+
+ if (!ucache_find_entry_lock_record_slot_raw_locked(
+ hdr, lock->key, lock->key_len, hash, false, &slot_idx, &found
+ ) ||
+ !found
+ ) {
+ return;
+ }
+
+ record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+ if (record->owner_pid != lock->owner_pid ||
+ record->owner_start_time != lock->owner_start_time ||
+ record->owner_token != lock->owner_token
+ ) {
+ return;
+ }
+
+ if (lock->lease > 0 && record->expires_at != 0) {
+ ucache_entry_lock_record_disown_locked(record);
+ } else {
+ ucache_remove_entry_lock_record_locked(record);
+ }
+}
+
+static bool ucache_ctx_listed(
+ ucache_ctx *const *ctxs,
+ uint32_t count,
+ const ucache_ctx *ctx)
+{
+ uint32_t i;
+
+ for (i = 0; i < count; i++) {
+ if (ctxs[i] == ctx) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static void ucache_destroy_entry_locks_if_empty(HashTable **locks_ptr)
+{
+ if (*locks_ptr != NULL && zend_hash_num_elements(*locks_ptr) == 0) {
+ zend_hash_destroy(*locks_ptr);
+
+ FREE_HASHTABLE(*locks_ptr);
+
+ *locks_ptr = NULL;
+ }
+}
+
+static void ucache_release_entry_locks_for_ctx(ucache_ctx *ctx)
+{
+ ucache_ctx *prev_ctx;
+ ucache_entry_lock *lock;
+ ucache_hdr *hdr;
+ HashTable **locks_ptr = &UC_G(entry_lock_table);
+ zend_string *key;
+ zval *entry;
+ bool released_to_shm = false;
+
+ if (*locks_ptr == NULL) {
+ return;
+ }
+
+ if (zend_hash_num_elements(*locks_ptr) == 0) {
+ zend_hash_destroy(*locks_ptr);
+
+ FREE_HASHTABLE(*locks_ptr);
+
+ *locks_ptr = NULL;
+
+ return;
+ }
+
+ prev_ctx = ucache_activate_ctx(ctx);
+ if (ucache_wlock_entry_lock_table()) {
+ hdr = ucache_hdr_ptr();
+ if (ucache_hdr_init_locked()) {
+ ZEND_HASH_FOREACH_STR_KEY_PTR(*locks_ptr, key, lock) {
+ if (key != NULL && lock != NULL && lock->ctx == ctx) {
+ ucache_release_entry_lock_record_locked(hdr, lock, zend_string_hash_val(key));
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ released_to_shm = true;
+ }
+
+ ucache_unlock();
+ }
+
+ ucache_restore_ctx(prev_ctx);
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(*locks_ptr, key, entry) {
+ lock = Z_PTR_P(entry);
+
+ if (key == NULL || lock == NULL || lock->ctx != ctx) {
+ continue;
+ }
+
+ if (!released_to_shm) {
+ ucache_defer_entry_lock_release(lock);
+
+ ZVAL_PTR(entry, NULL);
+ }
+
+ zend_hash_del(*locks_ptr, key);
+ } ZEND_HASH_FOREACH_END();
+
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+}
+
+static void ucache_release_entry_locks_all_ctxs(void)
+{
+ ucache_ctx *ctx;
+ ucache_entry_lock *lock;
+
+ while (UC_G(entry_lock_table) != NULL) {
+ ctx = ucache_owning_ctx();
+
+ ZEND_HASH_FOREACH_PTR(UC_G(entry_lock_table), lock) {
+ ctx = lock->ctx;
+
+ break;
+ } ZEND_HASH_FOREACH_END();
+
+ ucache_release_entry_locks_for_ctx(ctx);
+ }
+}
+
+static void ucache_entry_lock_dtor(zval *lock_zv)
+{
+ ucache_entry_lock *lock = Z_PTR_P(lock_zv);
+
+ if (lock != NULL) {
+ if (UC_G(entry_lock_spare) == NULL && lock->key_len <= 256) {
+ UC_G(entry_lock_spare) = lock;
+ } else {
+ free(lock);
+ }
+ }
+}
+
+static HashTable *ucache_prepare_entry_locks_for_insert(void)
+{
+ HashTable *locks = UC_G(entry_lock_table);
+
+ if (locks == NULL) {
+ ALLOC_HASHTABLE(locks);
+
+ zend_hash_init(locks, 0, NULL, ucache_entry_lock_dtor, 0);
+
+ UC_G(entry_lock_table) = locks;
+ }
+
+ zend_hash_extend(locks, zend_hash_num_elements(locks) + 1, 0);
+
+ return locks;
+}
+
+static bool ucache_upgrade_held_entry_lock_or_drop_if_lost(
+ zend_string *key,
+ zend_long lease,
+ ucache_entry_lock *lock)
+{
+ bool updated;
+
+ if (!ucache_wlock_entry_lock_table()) {
+ return false;
+ }
+
+ updated = ucache_hdr_init_locked() &&
+ ucache_update_entry_lock_record_lease_locked(ucache_hdr_ptr(), key, lock, lease)
+ ;
+
+ ucache_unlock();
+
+ if (!updated) {
+ zend_hash_del(UC_G(entry_lock_table), key);
+ ucache_destroy_entry_locks_if_empty(&UC_G(entry_lock_table));
+
+ return false;
+ }
+
+ if (lease > lock->lease) {
+ lock->lease = lease;
+ }
+
+ return true;
+}
+
+static bool ucache_remove_owned_entry_lock_record(
+ zend_string *key,
+ zend_ulong hash,
+ uint64_t owner_pid,
+ uint64_t owner_token,
+ bool *removed)
+{
+ ucache_hdr *hdr;
+ ucache_entry_lock_record *record;
+ uint32_t slot_idx;
+ bool found;
+
+ if (!ucache_wlock_entry_lock_table()) {
+ return false;
+ }
+
+ hdr = ucache_hdr_ptr();
+ *removed = false;
+
+ if (ucache_hdr_is_initialized_locked() &&
+ ucache_find_entry_lock_record_slot_locked(
+ hdr,
+ key,
+ hash,
+ &slot_idx,
+ &found
+ ) &&
+ found
+ ) {
+ record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+
+ if (record->owner_pid == owner_pid && record->owner_token == owner_token) {
+ ucache_remove_entry_lock_record_locked(record);
+
+ *removed = true;
+ }
+ }
+
+ ucache_unlock();
+
+ return true;
+}
+
+static bool ucache_acquire_entry_lock_record(
+ zend_string *key,
+ zend_long lease,
+ bool blocking)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_entry_lock *lock;
+ ucache_entry_lock_holder holder;
+ ucache_hdr *hdr;
+ zend_ulong hash = zend_string_hash_val(key);
+ HashTable *locks, **locks_ptr = &UC_G(entry_lock_table);
+ uint64_t waited_us = 0;
+ uint32_t slot_idx;
+ bool found, inserted, insert_failed, removed;
+
+ ucache_ensure_entry_lock_owner();
+
+ ucache_drain_deferred_entry_lock_releases();
+
+ lock = ucache_find_local_entry_lock(ctx, key);
+ if (lock != NULL) {
+ if (ucache_upgrade_held_entry_lock_or_drop_if_lost(key, lease, lock)) {
+ return true;
+ }
+
+ if (ucache_find_local_entry_lock(ctx, key) != NULL) {
+ return false;
+ }
+ }
+
+ locks = ucache_prepare_entry_locks_for_insert();
+ lock = ucache_create_local_entry_lock(ctx, key, lease);
+ if (lock == NULL) {
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+
+ return false;
+ }
+
+ for (;;) {
+ inserted = false;
+ found = false;
+ insert_failed = false;
+
+ if (!ucache_wlock_entry_lock_table()) {
+ free(lock);
+
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+
+ return false;
+ }
+
+ hdr = ucache_hdr_ptr();
+
+ if (!ucache_hdr_init_locked()) {
+ ucache_unlock();
+
+ free(lock);
+
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+
+ return false;
+ }
+
+ if (ucache_find_entry_lock_record_insert_slot_locked(hdr, key, hash, &slot_idx, &found)) {
+ if (found) {
+ ucache_entry_lock_holder_capture(&holder, hdr, slot_idx);
+ } else if (!(inserted = ucache_insert_entry_lock_record_locked(
+ hdr,
+ slot_idx,
+ key,
+ hash,
+ lock
+ ))
+ ) {
+ insert_failed = true;
+ }
+ } else {
+ insert_failed = true;
+ }
+
+ ucache_unlock();
+
+ if (inserted) {
+ if (ucache_add_local_entry_lock(locks, key, lock)) {
+ return true;
+ }
+
+ if (!ucache_remove_owned_entry_lock_record(
+ key,
+ hash,
+ lock->owner_pid,
+ lock->owner_token,
+ &removed
+ )
+ ) {
+ lock->lease = 0;
+
+ ucache_defer_entry_lock_release(lock);
+ } else {
+ free(lock);
+ }
+
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+
+ return false;
+ }
+
+ if (insert_failed) {
+ free(lock);
+
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+
+ return false;
+ }
+
+ if (!blocking || waited_us >= ucache_entry_lock_wait_timeout_us()) {
+ free(lock);
+
+ ucache_destroy_entry_locks_if_empty(locks_ptr);
+
+ return false;
+ }
+
+ do {
+ waited_us += ucache_sleep_entry_lock_retry_interval(waited_us);
+ } while (found &&
+ waited_us < ucache_entry_lock_wait_timeout_us() &&
+ ucache_entry_lock_holder_unchanged(&ctx->storage, &holder)
+ );
+ }
+}
+
+bool ucache_entry_lock_record_is_active_locked(
+ ucache_entry_lock_record *record,
+ uint64_t now_rel)
+{
+ if (record->state != UCACHE_ENTRY_LOCK_USED) {
+ return false;
+ }
+
+ if (record->expires_at != 0 && (uint64_t) record->expires_at <= now_rel) {
+ return false;
+ }
+
+ if (record->owner_pid == 0) {
+ return record->expires_at != 0;
+ }
+
+ if (ucache_owner_is_dead(record->owner_pid, record->owner_start_time)) {
+ ucache_entry_lock_record_disown_locked(record);
+
+ return record->expires_at != 0;
+ }
+
+ return true;
+}
+
+void ucache_sweep_entry_locks_locked(ucache_hdr *hdr, uint64_t now)
+{
+ ucache_entry_lock_record *record;
+ uint32_t i;
+
+ hdr->entry_lock_sweep_at = (uint32_t) MIN(now + UCACHE_ENTRY_LOCK_SWEEP_INTERVAL_TICKS, UINT32_MAX);
+
+ for (i = 0; i < hdr->entry_lock_capacity && hdr->entry_lock_count != 0; i++) {
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+ if (record->state == UCACHE_ENTRY_LOCK_USED &&
+ !ucache_entry_lock_record_is_active_locked(record, now)
+ ) {
+ ucache_remove_entry_lock_record_locked(record);
+ }
+ }
+}
+
+bool ucache_find_entry_lock_record_slot_locked(
+ ucache_hdr *hdr,
+ zend_string *key,
+ zend_ulong hash,
+ uint32_t *slot_idx,
+ bool *found)
+{
+ return ucache_find_entry_lock_record_slot_raw_locked(
+ hdr, ZSTR_VAL(key), ZSTR_LEN(key), hash, false, slot_idx, found
+ );
+}
+
+void ucache_entry_lock_holder_capture(
+ ucache_entry_lock_holder *holder,
+ ucache_hdr *hdr,
+ uint32_t slot_idx)
+{
+ const ucache_entry_lock_record *record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+
+ holder->owner_pid = record->owner_pid;
+ holder->owner_start_time = record->owner_start_time;
+ holder->owner_token = record->owner_token;
+ holder->hash = record->hash;
+ holder->slot_idx = slot_idx;
+ holder->expires_at = record->expires_at;
+}
+
+bool ucache_entry_lock_holder_unchanged(
+ const ucache_storage *storage,
+ const ucache_entry_lock_holder *holder)
+{
+ const ucache_hdr *hdr = (const ucache_hdr *) storage->base;
+ const ucache_entry_lock_record *record;
+
+ if (hdr == NULL ||
+ !storage->layout_memo_valid ||
+ holder->slot_idx >= storage->entry_lock_capacity_memo
+ ) {
+ return false;
+ }
+
+ record = (const ucache_entry_lock_record *) (
+ (const char *) hdr + storage->entry_lock_offset_memo
+ ) + holder->slot_idx;
+
+ if (record->state != UCACHE_ENTRY_LOCK_USED ||
+ record->hash != holder->hash ||
+ record->owner_token != holder->owner_token ||
+ record->owner_pid != holder->owner_pid ||
+ record->expires_at != holder->expires_at
+ ) {
+ return false;
+ }
+
+ if (holder->expires_at != 0 &&
+ (uint64_t) holder->expires_at <= ucache_time_rel(hdr, ucache_clock_now())
+ ) {
+ return false;
+ }
+
+ if (holder->owner_pid == 0) {
+ return holder->expires_at != 0;
+ }
+
+ return !ucache_owner_is_dead(holder->owner_pid, holder->owner_start_time);
+}
+
+void ucache_drain_deferred_entry_lock_releases(void)
+{
+ ucache_ctx *ctx;
+ ucache_entry_lock **link = &UC_G(deferred_entry_lock_releases), *entry, *local_lock;
+ ucache_hdr *hdr = NULL;
+ uint64_t self_pid;
+ bool drainable = false;
+
+ if (*link == NULL) {
+ return;
+ }
+
+ ctx = ucache_active_ctx();
+ self_pid = ucache_cached_pid();
+
+ while ((entry = *link) != NULL) {
+ if (entry->owner_pid != self_pid) {
+ *link = entry->next;
+
+ free(entry);
+
+ continue;
+ }
+
+ drainable = drainable || entry->ctx == ctx;
+ link = &entry->next;
+ }
+
+ if (!drainable || !ucache_wlock_entry_lock_table()) {
+ return;
+ }
+
+ if (ucache_hdr_is_initialized_locked()) {
+ hdr = ucache_hdr_ptr();
+ }
+
+ link = &UC_G(deferred_entry_lock_releases);
+ while ((entry = *link) != NULL) {
+ if (entry->ctx != ctx) {
+ link = &entry->next;
+
+ continue;
+ }
+
+ local_lock = UC_G(entry_lock_table) != NULL
+ ? zend_hash_str_find_ptr(UC_G(entry_lock_table), entry->key, entry->key_len)
+ : NULL
+ ;
+
+ if (hdr != NULL && (local_lock == NULL || local_lock->ctx != ctx)) {
+ ucache_release_entry_lock_record_locked(
+ hdr, entry, zend_hash_func(entry->key, entry->key_len)
+ );
+ }
+
+ *link = entry->next;
+
+ free(entry);
+ }
+
+ ucache_unlock();
+}
+
+void ucache_ensure_entry_lock_owner(void)
+{
+#ifndef ZEND_WIN32
+ zend_ulong cur_pid = (zend_ulong) ucache_cached_pid();
+
+ if (UC_G(entry_lock_owner_pid) == 0) {
+ UC_G(entry_lock_owner_pid) = cur_pid;
+
+ return;
+ }
+
+ if (UC_G(entry_lock_owner_pid) == cur_pid) {
+ return;
+ }
+
+ if (UC_G(entry_lock_table) != NULL) {
+ zend_hash_destroy(UC_G(entry_lock_table));
+
+ FREE_HASHTABLE(UC_G(entry_lock_table));
+
+ UC_G(entry_lock_table) = NULL;
+ }
+
+ UC_G(entry_lock_owner_pid) = cur_pid;
+#endif
+}
+
+bool ucache_try_acquire_entry_lock(zend_string *key, zend_long lease)
+{
+ ucache_entry_lock *lock;
+
+ if (!ucache_acquire_entry_lock_record(key, lease, false)) {
+ return false;
+ }
+
+ lock = ucache_find_local_entry_lock(ucache_active_ctx(), key);
+ if (lock != NULL) {
+ lock->requested_by_lock = true;
+ }
+
+ return true;
+}
+
+bool ucache_acquire_entry_lock(zend_string *key)
+{
+ return ucache_acquire_entry_lock_record(key, 0, true);
+}
+
+bool ucache_release_entry_lock(zend_string *key)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_entry_lock *lock;
+ bool still_owned;
+
+ ucache_ensure_entry_lock_owner();
+
+ lock = ucache_find_local_entry_lock(ctx, key);
+ if (lock == NULL) {
+ return false;
+ }
+
+ if (!ucache_remove_owned_entry_lock_record(
+ key,
+ zend_string_hash_val(key),
+ lock->owner_pid,
+ lock->owner_token,
+ &still_owned
+ )
+ ) {
+ return false;
+ }
+
+ zend_hash_del(UC_G(entry_lock_table), key);
+
+ ucache_destroy_entry_locks_if_empty(&UC_G(entry_lock_table));
+
+ return still_owned;
+}
+
+bool ucache_release_entry_lock_unless_requested(zend_string *key)
+{
+ ucache_entry_lock *lock;
+
+ ucache_ensure_entry_lock_owner();
+
+ lock = ucache_find_local_entry_lock(ucache_active_ctx(), key);
+ if (lock != NULL && lock->requested_by_lock) {
+ return true;
+ }
+
+ return ucache_release_entry_lock(key);
+}
+
+bool ucache_req_owns_entry_lock(zend_string *key)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_entry_lock *lock;
+
+ ucache_ensure_entry_lock_owner();
+
+ lock = ucache_find_local_entry_lock(ctx, key);
+ if (lock == NULL) {
+ return false;
+ }
+
+ return ucache_upgrade_held_entry_lock_or_drop_if_lost(key, 0, lock) ||
+ ucache_find_local_entry_lock(ctx, key) != NULL
+ ;
+}
+
+bool ucache_entry_key_lock_active_locked(
+ ucache_hdr *hdr,
+ uint32_t hash,
+ size_t key_pos,
+ uint32_t key_len,
+ uint64_t now)
+{
+ ucache_entry_lock_record *record;
+ uint32_t i, probe;
+
+ for (probe = 0; probe < hdr->entry_lock_capacity; probe++) {
+ i = (ucache_entry_lock_table_idx(hdr, hash) + probe)
+ & (hdr->entry_lock_capacity - 1)
+ ;
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+
+ if (record->state == UCACHE_ENTRY_LOCK_EMPTY) {
+ return false;
+ }
+
+ if (record->state != UCACHE_ENTRY_LOCK_USED ||
+ ucache_table_hash(record->hash) != hash ||
+ record->key_len != key_len
+ ) {
+ continue;
+ }
+
+ if (memcmp(
+ (const char *) hdr + ucache_offset_bytes(record->key_offset),
+ (const char *) hdr + key_pos,
+ key_len
+ ) != 0
+ ) {
+ continue;
+ }
+
+ return ucache_entry_lock_record_is_active_locked(record, now);
+ }
+
+ return false;
+}
+
+bool ucache_entry_locks_allow_clear_locked(zend_string *prefix)
+{
+ const char *key;
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_entry_lock_record *record;
+ ucache_entry_lock *local_lock;
+ uint64_t now;
+ uint32_t i;
+
+ ucache_ensure_entry_lock_owner();
+
+ if (hdr == NULL || !ucache_hdr_adoptable_locked()) {
+ return true;
+ }
+
+ now = ucache_time_rel(hdr, ucache_clock_now());
+
+ for (i = 0; i < hdr->entry_lock_capacity; i++) {
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+ if (record->state != UCACHE_ENTRY_LOCK_USED) {
+ continue;
+ }
+
+ key = (const char *) ucache_ptr(record->key_offset);
+ if (prefix != NULL && (record->key_len < ZSTR_LEN(prefix) ||
+ memcmp(key, ZSTR_VAL(prefix), ZSTR_LEN(prefix)) != 0)
+ ) {
+ continue;
+ }
+
+ if (!ucache_entry_lock_record_is_active_locked(record, now) || record->owner_pid == 0) {
+ ucache_remove_entry_lock_record_locked(record);
+
+ continue;
+ }
+
+ local_lock = UC_G(entry_lock_table) != NULL
+ ? zend_hash_str_find_ptr(UC_G(entry_lock_table), key, record->key_len)
+ : NULL
+ ;
+ if (local_lock == NULL || local_lock->ctx != ucache_active_ctx() ||
+ record->owner_pid != local_lock->owner_pid ||
+ record->owner_token != local_lock->owner_token
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+void ucache_retry_deferred_entry_lock_releases(void)
+{
+ ucache_ctx *attempted[UCACHE_DEFERRED_RELEASE_RETRY_CTXS], *prev_ctx;
+ ucache_entry_lock *entry;
+ uint32_t attempted_count = 0;
+
+ ucache_ensure_entry_lock_owner();
+
+#ifdef ZTS
+ ucache_adopt_orphaned_entry_lock_releases();
+#endif
+
+ entry = UC_G(deferred_entry_lock_releases);
+ while (entry != NULL && attempted_count < UCACHE_DEFERRED_RELEASE_RETRY_CTXS) {
+ if (ucache_ctx_listed(attempted, attempted_count, entry->ctx)) {
+ entry = entry->next;
+
+ continue;
+ }
+
+ attempted[attempted_count++] = entry->ctx;
+
+ prev_ctx = ucache_activate_ctx(entry->ctx);
+ ucache_drain_deferred_entry_lock_releases();
+ ucache_restore_ctx(prev_ctx);
+
+ entry = UC_G(deferred_entry_lock_releases);
+ }
+}
+
+bool ucache_active_ctx_has_deferred_entry_lock_releases(void)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_entry_lock *entry;
+
+ for (entry = UC_G(deferred_entry_lock_releases); entry != NULL; entry = entry->next) {
+ if (entry->ctx == ctx) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+void ucache_release_req_entry_locks(void)
+{
+ ucache_ensure_entry_lock_owner();
+ ucache_drain_deferred_entry_lock_releases();
+ ucache_release_entry_locks_all_ctxs();
+
+ free(UC_G(entry_lock_spare));
+
+ UC_G(entry_lock_spare) = NULL;
+#ifndef ZEND_WIN32
+ UC_G(entry_lock_owner_pid) = 0;
+#endif
+}
+
+void ucache_free_thread_deferred_entry_lock_releases(ucache_globals *globals)
+{
+ ucache_entry_lock *entry = globals->deferred_entry_lock_releases;
+
+ free(globals->entry_lock_spare);
+
+ globals->entry_lock_spare = NULL;
+ globals->deferred_entry_lock_releases = NULL;
+
+ ucache_free_entry_lock_list(entry);
+}
+
+#ifdef ZTS
+void ucache_orphan_thread_deferred_entry_lock_releases(ucache_globals *globals)
+{
+ ucache_entry_lock *entry = globals->deferred_entry_lock_releases, *next;
+ uint64_t self_pid = ucache_cached_pid();
+
+ free(globals->entry_lock_spare);
+
+ globals->entry_lock_spare = NULL;
+ globals->deferred_entry_lock_releases = NULL;
+
+ ucache_boundary_partitions_lock();
+
+ while (entry != NULL) {
+ next = entry->next;
+
+ if (entry->owner_pid == self_pid) {
+ entry->next = ucache_orphaned_entry_lock_releases;
+ ucache_orphaned_entry_lock_releases = entry;
+
+ atomic_store(&ucache_orphaned_entry_lock_releases_pending, true);
+ } else {
+ free(entry);
+ }
+
+ entry = next;
+ }
+
+ ucache_boundary_partitions_unlock();
+}
+
+void ucache_free_orphaned_entry_lock_releases(void)
+{
+ ucache_entry_lock *entry = ucache_orphaned_entry_lock_releases;
+
+ ucache_orphaned_entry_lock_releases = NULL;
+
+ atomic_store(&ucache_orphaned_entry_lock_releases_pending, false);
+
+ ucache_free_entry_lock_list(entry);
+}
+#endif
diff --git a/ext/user_cache/user_cache_eviction.c b/ext/user_cache/user_cache_eviction.c
new file mode 100644
index 000000000000..d3ecda8036ed
--- /dev/null
+++ b/ext/user_cache/user_cache_eviction.c
@@ -0,0 +1,672 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_entries.h"
+
+#define UCACHE_EXPUNGE_SCAN_MAX 4096U
+
+#define UCACHE_EVICTION_WINDOW 32U
+#define UCACHE_EVICTION_MAX_VICTIMS 64U
+#define UCACHE_EVICTION_AGE_ORDERED_VICTIMS 4U
+#define UCACHE_EVICTION_REMAINDER_PROBES 4U
+#define UCACHE_EVICTION_SCAN_MAX 4096U
+
+typedef struct {
+ uint32_t stamps[UCACHE_EVICTION_WINDOW];
+ uint32_t slots[UCACHE_EVICTION_WINDOW];
+ uint32_t count;
+} ucache_eviction_window;
+
+static zend_always_inline bool ucache_expiry_floor_not_reached_locked(
+ const ucache_hdr *hdr,
+ uint64_t now_rel)
+{
+ return now_rel < (uint64_t) hdr->expiry_floor;
+}
+
+static bool ucache_expunge_expired_locked(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_entry *entries, *entry;
+ uint64_t now;
+ uint32_t i, floor;
+ bool removed = false;
+
+ if (hdr == NULL || !ucache_hdr_init_locked()) {
+ return false;
+ }
+
+ if (hdr->expiring_count == 0) {
+ hdr->expiry_floor = UCACHE_EXPIRY_FLOOR_NONE;
+
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ return false;
+ }
+
+ now = ucache_time_rel(hdr, ucache_clock_now());
+ if (ucache_expiry_floor_not_reached_locked(hdr, now)) {
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ return false;
+ }
+
+ floor = UCACHE_EXPIRY_FLOOR_NONE;
+ entries = ucache_entries_ptr(hdr);
+ for (i = 0; i < hdr->capacity; i++) {
+ entry = &entries[i];
+
+ if (ucache_is_expired(entry, now)) {
+ ucache_delete_entry_locked(hdr, entry, i);
+
+ removed = true;
+ } else if (ucache_entry_is_used(entry) && entry->expires_at != 0 && entry->expires_at < floor) {
+ floor = entry->expires_at;
+ }
+ }
+
+ hdr->expiry_floor = floor;
+
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ return removed;
+}
+
+static bool ucache_entry_evictable_locked(
+ ucache_hdr *hdr,
+ const ucache_entry *entry,
+ int8_t *gquiescent,
+ uint64_t lock_now_rel)
+{
+ if (!ucache_entry_is_used(entry)) {
+ return false;
+ }
+
+ if (ucache_entry_val_type(entry) == UCACHE_VAL_SGRAPH && entry->val_offset != 0) {
+ if (ucache_sgraph_payload_has_refs_locked(entry->val_offset)) {
+ return false;
+ }
+
+ if (*gquiescent < 0) {
+ *gquiescent = ucache_quiesce_graph_payloads_locked() ? 1 : 0;
+ }
+
+ if (*gquiescent == 0) {
+ return false;
+ }
+ }
+
+ return !ucache_entry_key_lock_active_locked(
+ hdr,
+ entry->hash,
+ ucache_entry_key_pos(entry),
+ entry->key_len,
+ lock_now_rel
+ );
+}
+
+static bool ucache_eviction_window_accepts(const ucache_eviction_window *window, uint32_t stamp)
+{
+ uint32_t newer = 0, i;
+
+ for (i = 0; i < window->count; i++) {
+ newer += window->stamps[i] >= stamp;
+ }
+
+ return newer * 4 >= window->count * 3;
+}
+
+static uint32_t ucache_entry_coalesced_size_locked(const ucache_hdr *hdr, const ucache_entry *entry)
+{
+ const ucache_block *block;
+ uint32_t payload_offset = ucache_entry_val_offset(entry),
+ block_offset, next_offset, size, neighbour_size, merge_limit = ucache_block_merge_limit()
+ ;
+
+ if (payload_offset == 0) {
+ if ((entry->flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) != 0 || entry->key_offset == 0) {
+ return 0;
+ }
+
+ payload_offset = entry->key_offset;
+ }
+
+ block_offset = ucache_payload_block_offset(payload_offset);
+ block = ucache_block_ptr_in_hdr(hdr, block_offset);
+ size = ucache_block_size(block);
+
+ next_offset = ucache_offset_after(block_offset, size);
+ if (next_offset < ucache_used_end_offset_locked(hdr) &&
+ ucache_block_is_free(ucache_block_ptr_in_hdr(hdr, next_offset))
+ ) {
+ neighbour_size = ucache_block_size(ucache_block_ptr_in_hdr(hdr, next_offset));
+ if ((uint64_t) size + neighbour_size <= merge_limit) {
+ size += neighbour_size;
+ }
+ }
+
+ if (ucache_block_prev_is_free(block)) {
+ memcpy(&neighbour_size, (const uint8_t *) block - sizeof(neighbour_size), sizeof(neighbour_size));
+ if ((uint64_t) size + neighbour_size <= merge_limit) {
+ size += neighbour_size;
+ }
+ }
+
+ return size;
+}
+
+static ucache_entry *ucache_block_owner_entry_locked(ucache_hdr *hdr, uint32_t block_offset)
+{
+ ucache_block *block = ucache_block_ptr(block_offset);
+ ucache_entry *entry;
+ uint32_t payload_offset = ucache_block_payload_offset(block_offset);
+
+ if (ucache_block_is_free(block) || block->owner >= hdr->capacity) {
+ return NULL;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[block->owner];
+ if (!ucache_entry_is_used(entry)) {
+ return NULL;
+ }
+
+ if (ucache_entry_val_offset(entry) == payload_offset ||
+ ((entry->flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) == 0 && entry->key_offset == payload_offset)
+ ) {
+ return entry;
+ }
+
+ return NULL;
+}
+
+static bool ucache_grow_free_block_by_eviction_locked(
+ ucache_hdr *hdr,
+ uint32_t region,
+ size_t needed_size,
+ uint32_t max_victims,
+ const ucache_eviction_window *window,
+ uint32_t *victims,
+ int8_t *gquiescent,
+ uint64_t lock_now_rel)
+{
+ ucache_entry *entry, *entries = ucache_entries_ptr(hdr);
+ uint32_t *stamps = ucache_access_stamps_ptr(hdr), next_offset, evicted = 0;
+
+ while (region != 0 && evicted < max_victims && *victims < UCACHE_EVICTION_MAX_VICTIMS) {
+ next_offset = ucache_offset_after(region, ucache_block_size(ucache_block_ptr(region)));
+ if (next_offset >= ucache_used_end_offset_locked(hdr)) {
+ return false;
+ }
+
+ entry = ucache_block_owner_entry_locked(hdr, next_offset);
+ if (entry == NULL ||
+ !ucache_eviction_window_accepts(window, UCACHE_ATOMIC_LOAD_32_RELAXED(&stamps[entry - entries])) ||
+ !ucache_entry_evictable_locked(hdr, entry, gquiescent, lock_now_rel)
+ ) {
+ return false;
+ }
+
+ ucache_delete_entry_locked(hdr, entry, (uint32_t) (entry - entries));
+
+ hdr->eviction_count++;
+ (*victims)++;
+ evicted++;
+
+ if (ucache_alloc_can_satisfy_locked(needed_size)) {
+ return true;
+ }
+
+ if (region >= ucache_used_end_offset_locked(hdr)) {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+static bool ucache_evict_remainder_neighbor_locked(
+ ucache_hdr *hdr,
+ size_t needed_size,
+ const ucache_eviction_window *window,
+ uint32_t *victims,
+ int8_t *gquiescent,
+ uint64_t lock_now_rel)
+{
+ const uint32_t *bins = ucache_free_bins_ptr(hdr), *mask = ucache_free_bin_mask_ptr(hdr);
+ uint32_t word = ucache_free_bin_words(hdr->free_bin_count), bits, bin, block_offset, probes = 0;
+
+ while (word-- > 0) {
+ bits = mask[word];
+
+ while (bits != 0) {
+ bin = word * 32U + ucache_floor_log2(bits);
+ bits &= ~(1U << (bin % 32U));
+
+ block_offset = bins[bin];
+ if (block_offset == 0) {
+ continue;
+ }
+
+ if (probes++ == UCACHE_EVICTION_REMAINDER_PROBES) {
+ return false;
+ }
+
+ if (ucache_grow_free_block_by_eviction_locked(
+ hdr,
+ block_offset,
+ needed_size,
+ 1,
+ window,
+ victims,
+ gquiescent,
+ lock_now_rel
+ )
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+static void ucache_eviction_window_collect(
+ ucache_hdr *hdr,
+ ucache_eviction_window *window,
+ uint32_t *hand,
+ uint32_t *visited,
+ int8_t *gquiescent,
+ uint64_t lock_now_rel)
+{
+ ucache_entry *entries = ucache_entries_ptr(hdr), *entry;
+ uint32_t *stamps = ucache_access_stamps_ptr(hdr), slot, scanned;
+
+ window->count = 0;
+
+ for (scanned = 0;
+ *visited < hdr->capacity &&
+ scanned < UCACHE_EVICTION_SCAN_MAX &&
+ window->count < UCACHE_EVICTION_WINDOW;
+ (*visited)++
+ ) {
+ slot = *hand;
+ *hand = slot + 1 == hdr->capacity ? 0 : slot + 1;
+ entry = &entries[slot];
+
+ if (window->count != 0 || ucache_entry_is_used(entry)) {
+ scanned++;
+ }
+
+ if (ucache_entry_evictable_locked(hdr, entry, gquiescent, lock_now_rel)) {
+ window->stamps[window->count] = UCACHE_ATOMIC_LOAD_32_RELAXED(&stamps[slot]);
+ window->slots[window->count++] = slot;
+ }
+ }
+}
+
+static uint32_t ucache_eviction_window_pick(
+ ucache_hdr *hdr,
+ ucache_eviction_window *window,
+ size_t needed_total,
+ uint32_t victims,
+ int8_t *gquiescent,
+ uint64_t lock_now_rel,
+ bool *fits_ptr)
+{
+ ucache_entry *entries = ucache_entries_ptr(hdr), *entry;
+ uint32_t i, best = UINT32_MAX;
+ bool fits, best_fits = false;
+
+ for (i = 0; i < window->count; i++) {
+ if (window->slots[i] == UINT32_MAX) {
+ continue;
+ }
+
+ entry = &entries[window->slots[i]];
+ if (!ucache_entry_evictable_locked(hdr, entry, gquiescent, lock_now_rel)) {
+ window->slots[i] = UINT32_MAX;
+
+ continue;
+ }
+
+ fits = needed_total == 0 ||
+ (
+ ucache_entry_coalesced_size_locked(hdr, entry) >= needed_total &&
+ (
+ victims >= UCACHE_EVICTION_AGE_ORDERED_VICTIMS ||
+ ucache_eviction_window_accepts(window, window->stamps[i])
+ )
+ )
+ ;
+ if (best == UINT32_MAX ||
+ (fits && !best_fits) ||
+ (fits == best_fits && window->stamps[i] < window->stamps[best])
+ ) {
+ best = i;
+ best_fits = fits;
+ }
+ }
+
+ *fits_ptr = best_fits;
+
+ return best;
+}
+
+static bool ucache_evict_lru_locked(size_t needed_size)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_eviction_window window;
+ uint64_t lock_now, lock_now_rel;
+ uint32_t hand, visited = 0, best, best_slot, region, victims = 0;
+ int8_t gquiescent = -1;
+ size_t needed_total = needed_size != 0
+ ? ucache_block_total_size(needed_size)
+ : 0
+ ;
+ bool evicted_any = false, best_fits;
+
+ if (hdr == NULL || hdr->count == 0) {
+ return false;
+ }
+
+ hand = hdr->eviction_hand % hdr->capacity;
+ lock_now = ucache_clock_now();
+ lock_now_rel = ucache_time_rel(hdr, lock_now);
+
+ ucache_access_note_time(lock_now);
+
+ window.count = 0;
+
+ while (victims < UCACHE_EVICTION_MAX_VICTIMS) {
+ best = ucache_eviction_window_pick(
+ hdr,
+ &window,
+ needed_total,
+ victims,
+ &gquiescent,
+ lock_now_rel,
+ &best_fits
+ );
+ if (best == UINT32_MAX) {
+ if (visited >= hdr->capacity) {
+ break;
+ }
+
+ ucache_eviction_window_collect(hdr, &window, &hand, &visited, &gquiescent, lock_now_rel);
+ if (window.count == 0) {
+ break;
+ }
+
+ continue;
+ }
+
+ best_slot = window.slots[best];
+ window.slots[best] = UINT32_MAX;
+
+ if (needed_size != 0 &&
+ !best_fits &&
+ ucache_evict_remainder_neighbor_locked(
+ hdr,
+ needed_size,
+ &window,
+ &victims,
+ &gquiescent,
+ lock_now_rel
+ )
+ ) {
+ evicted_any = true;
+
+ break;
+ }
+
+ if (!ucache_entry_is_used(&ucache_entries_ptr(hdr)[best_slot])) {
+ evicted_any = true;
+
+ continue;
+ }
+
+ region = ucache_delete_entry_locked(hdr, &ucache_entries_ptr(hdr)[best_slot], best_slot);
+
+ hdr->eviction_count++;
+ victims++;
+ evicted_any = true;
+
+ if (needed_size == 0 ||
+ ucache_alloc_can_satisfy_locked(needed_size) ||
+ ucache_grow_free_block_by_eviction_locked(
+ hdr,
+ region,
+ needed_size,
+ UCACHE_EVICTION_MAX_VICTIMS,
+ &window,
+ &victims,
+ &gquiescent,
+ lock_now_rel
+ )
+ ) {
+ break;
+ }
+ }
+
+ hdr->eviction_hand = hand;
+
+ return evicted_any;
+}
+
+static bool ucache_clear_locked(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_entry *entries;
+ uint32_t i;
+
+ if (hdr == NULL || !ucache_hdr_init_locked()) {
+ return false;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ for (i = 0; i < hdr->capacity; i++) {
+ if (ucache_entry_is_used(&entries[i])) {
+ ucache_release_entry_storage_locked(&entries[i]);
+ }
+ }
+
+ memset(entries, 0, sizeof(ucache_entry) * hdr->capacity);
+
+ ucache_pool_idx_reset_locked(hdr);
+ ucache_access_stamps_reset(hdr);
+
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ (void) ucache_sgraph_strip_dead_pins_locked(true);
+
+ hdr->count = 0;
+ hdr->expiring_count = 0;
+ hdr->expiry_floor = UCACHE_EXPIRY_FLOOR_NONE;
+ hdr->tombstone_count = 0;
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ return true;
+}
+
+static bool ucache_val_takes_most_of_data_area(const ucache_hdr *hdr, size_t needed_size)
+{
+ return ucache_block_total_size(needed_size) > hdr->data_size / 2;
+}
+
+static bool ucache_eviction_can_make_room_locked(
+ size_t needed_size,
+ bool clearing,
+ ucache_store_retries *retries)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ if (hdr == NULL ||
+ needed_size == 0 ||
+ (!clearing && !ucache_val_takes_most_of_data_area(hdr, needed_size))
+ ) {
+ return true;
+ }
+
+ if (!retries->room_checked || retries->room_entry_lock_count != hdr->entry_lock_count) {
+ retries->room_checked = true;
+ retries->room_entry_lock_count = hdr->entry_lock_count;
+ retries->room_after_clear = ucache_block_total_size(needed_size) <=
+ ucache_sgraph_largest_space_after_clear_locked(hdr)
+ ;
+ }
+
+ return retries->room_after_clear;
+}
+
+void ucache_expunge_expired_bounded_locked(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_entry *entries;
+ uint64_t now;
+ uint32_t cursor, scan_len, i;
+
+ if (hdr == NULL || !ucache_hdr_init_locked()) {
+ return;
+ }
+
+ if (hdr->expiring_count == 0) {
+ hdr->expiry_floor = UCACHE_EXPIRY_FLOOR_NONE;
+
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ return;
+ }
+
+ now = ucache_time_rel(hdr, ucache_clock_now());
+ if (ucache_expiry_floor_not_reached_locked(hdr, now)) {
+ ucache_sgraph_reclaim_orphaned_locked();
+
+ return;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+
+ cursor = UC_G(expired_expunge_cursor);
+ if (cursor >= hdr->capacity) {
+ cursor = 0;
+ }
+
+ scan_len = hdr->capacity < UCACHE_EXPUNGE_SCAN_MAX
+ ? hdr->capacity
+ : UCACHE_EXPUNGE_SCAN_MAX
+ ;
+ for (i = 0; i < scan_len; i++) {
+ if (ucache_is_expired(&entries[cursor], now)) {
+ ucache_delete_entry_locked(hdr, &entries[cursor], cursor);
+ }
+
+ ++cursor;
+
+ if (cursor == hdr->capacity) {
+ cursor = 0;
+ }
+ }
+
+ UC_G(expired_expunge_cursor) = cursor;
+
+ ucache_sgraph_reclaim_orphaned_locked();
+}
+
+bool ucache_reclaim_space_for_store_locked(
+ bool fits_in_empty_cache,
+ size_t needed_size,
+ ucache_store_retries *retries)
+{
+ bool reclaimed, *evict_used = needed_size == 0 ? &retries->slot_pressure_evict : &retries->mem_pressure_evict;
+
+ if (!retries->expired) {
+ retries->expired = true;
+
+ reclaimed = ucache_expunge_expired_locked();
+
+ if (ucache_sgraph_strip_dead_pins_locked(true)) {
+ reclaimed = true;
+ }
+
+ if (reclaimed || (needed_size != 0 && ucache_alloc_can_satisfy_locked(needed_size))) {
+ return true;
+ }
+ }
+
+ if (fits_in_empty_cache &&
+ UC_G(eviction_policy) == UCACHE_EVICTION_POLICY_LRU &&
+ !*evict_used &&
+ ucache_eviction_can_make_room_locked(needed_size, false, retries)
+ ) {
+ *evict_used = true;
+
+ if (ucache_evict_lru_locked(needed_size)) {
+ return true;
+ }
+ }
+
+ if (fits_in_empty_cache &&
+ UC_G(eviction_policy) != UCACHE_EVICTION_POLICY_NONE &&
+ !retries->clear
+ ) {
+ retries->clear = true;
+
+ if (ucache_entry_locks_allow_clear_locked(NULL) &&
+ ucache_eviction_can_make_room_locked(needed_size, true, retries) &&
+ ucache_clear_locked()
+ ) {
+ ucache_hdr_ptr()->expunge_count++;
+
+ return true;
+ }
+ }
+
+ return false;
+}
+
+void ucache_expunge_expired_at_req_end(void)
+{
+ if (EXPECTED(UC_G(expired_read_observations) < UCACHE_EXPIRED_READ_EXPUNGE_THRESHOLD)) {
+ return;
+ }
+
+ if (!ucache_wlock()) {
+ return;
+ }
+
+ UC_G(expired_read_observations) = 0;
+
+ ucache_expunge_expired_bounded_locked();
+
+ ucache_unlock();
+}
+
+bool ucache_reclaim_space_for_entry_lock_locked(size_t key_size)
+{
+ ucache_store_retries retries = {0};
+
+ while (!ucache_alloc_can_satisfy_locked(key_size)) {
+ if (!ucache_reclaim_space_for_store_locked(
+ ucache_payload_can_fit_locked(key_size),
+ key_size,
+ &retries
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/ext/user_cache/user_cache_internal.h b/ext/user_cache/user_cache_internal.h
new file mode 100644
index 000000000000..53fff8bd90a0
--- /dev/null
+++ b/ext/user_cache/user_cache_internal.h
@@ -0,0 +1,1688 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef UCACHE_INTERNAL_H
+#define UCACHE_INTERNAL_H
+
+#include "php.h"
+
+#include
+#include
+#ifdef ZTS
+# include "TSRM/TSRM.h"
+#endif
+#if defined(ZEND_WIN32) && defined(_MSC_VER)
+# include
+#endif
+#ifndef ZEND_WIN32
+# include
+#endif
+
+#include "Zend/zend_bitset.h"
+#include "Zend/zend_call_stack.h"
+#include "Zend/zend_enum.h"
+#include "Zend/zend_exceptions.h"
+#include "Zend/zend_hrtime.h"
+#include "Zend/zend_smart_str.h"
+#include "Zend/zend_system_id.h"
+
+#include "php_user_cache.h"
+#include "user_cache_shm.h"
+
+#include "ext/standard/php_var.h"
+
+#include "SAPI.h"
+
+#if ZEND_DEBUG
+# define UCACHE_DEBUG_FAULT(name) ucache_debug_fault("USER_CACHE_DEBUG_" name)
+# define UCACHE_DEBUG_SIMULATE_KILL(name) \
+ do { \
+ if (UCACHE_DEBUG_FAULT(name)) { \
+ _Exit(0); \
+ } \
+ } while (0)
+# define UCACHE_DEBUG_PAUSE(name) \
+ do { \
+ if (UCACHE_DEBUG_FAULT(name)) { \
+ ucache_debug_pause(); \
+ } \
+ } while (0)
+#else
+# define UCACHE_DEBUG_FAULT(name) false
+# define UCACHE_DEBUG_SIMULATE_KILL(name) ((void) 0)
+# define UCACHE_DEBUG_PAUSE(name) ((void) 0)
+#endif
+
+#define UCACHE_MAGIC 0xCAC17E01U
+#define UCACHE_MIN_CAPACITY 127U
+#define UCACHE_TABLE_SLOT_SIZE \
+ (sizeof(ucache_entry) + sizeof(uint32_t) + sizeof(ucache_pool_links))
+#define UCACHE_AUTO_SEG_BYTES_PER_ENTRY 1024U
+#define UCACHE_ENTRIES_HINT_MAX 16777213
+
+#define UCACHE_REQ_CACHE_BUDGET_MIN (8U * 1024U * 1024U)
+#define UCACHE_REQ_CACHE_BUDGET_DIVISOR 8U
+
+#define UCACHE_STORAGE_KEY_MAX UINT16_MAX
+#define UCACHE_POOL_NAME_MAX (UCACHE_STORAGE_KEY_MAX - 1 - MAX_LENGTH_OF_LONG)
+
+#define UCACHE_KEY_DELIM "\x1f"
+#define UCACHE_KEY_DELIM_CHAR '\x1f'
+#define UCACHE_KEY_DELIM_NAME "0x1F"
+
+#define UCACHE_MSG_RESOURCE_UNSTORABLE "Resources cannot be stored in the user cache"
+#define UCACHE_MSG_OBJ_UNSTORABLE "%s objects cannot be stored in the user cache"
+#define UCACHE_MSG_LAZY_OBJ_UNSTORABLE "Uninitialized lazy objects cannot be stored in the user cache"
+#define UCACHE_MSG_NESTED_TOO_DEEPLY "Value is nested too deeply to be stored in the user cache"
+
+#define UCACHE_ENTRY_EMPTY 0
+#define UCACHE_ENTRY_TOMBSTONE 1
+#define UCACHE_ENTRY_USED 2
+
+#define UCACHE_VAL_NULL 0
+#define UCACHE_VAL_TRUE 1
+#define UCACHE_VAL_FALSE 2
+#define UCACHE_VAL_LONG 3
+#define UCACHE_VAL_DOUBLE 4
+#define UCACHE_VAL_STR 5
+#define UCACHE_VAL_SGRAPH 6
+
+#define UCACHE_PREPARED_OWNER_BUF 1
+#define UCACHE_PREPARED_OWNER_STR 2
+
+#define UCACHE_SGRAPH_FLAG_EMPTY_ROOT 0x1U
+#define UCACHE_SGRAPH_FLAG_HAS_SHARED_IDENTITY 0x2U
+#define UCACHE_SGRAPH_FLAG_HAS_OBJ 0x4U
+#define UCACHE_SGRAPH_FLAG_PREFERS_PROTO 0x8U
+#define UCACHE_SGRAPH_REF_STATE_RETIRED (1 << 30)
+#define UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK (UCACHE_SGRAPH_REF_STATE_RETIRED - 1)
+
+#define UCACHE_SGRAPH_VAL_UNDEF 0
+#define UCACHE_SGRAPH_VAL_NULL 1
+#define UCACHE_SGRAPH_VAL_TRUE 2
+#define UCACHE_SGRAPH_VAL_FALSE 3
+#define UCACHE_SGRAPH_VAL_LONG 4
+#define UCACHE_SGRAPH_VAL_LONG_WIDE 5
+#define UCACHE_SGRAPH_VAL_DOUBLE 6
+#define UCACHE_SGRAPH_VAL_STR 7
+#define UCACHE_SGRAPH_VAL_ARR 8
+#define UCACHE_SGRAPH_VAL_OBJ 9
+#define UCACHE_SGRAPH_VAL_DYNAMIC_ARR 10
+#define UCACHE_SGRAPH_VAL_OBJ_REF 11
+#define UCACHE_SGRAPH_VAL_REF 12
+#define UCACHE_SGRAPH_VAL_REF_REF 13
+#define UCACHE_SGRAPH_VAL_ENUM 14
+#define UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ 15
+#define UCACHE_SGRAPH_VAL_SERIALIZED_OBJ 16
+#define UCACHE_SGRAPH_VAL_SERDES_OBJ 17
+#define UCACHE_SGRAPH_VAL_SLEEP_OBJ 18
+#define UCACHE_SGRAPH_VAL_SHAPED_ARR 19
+#define UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ 20
+#define UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ 21
+
+#define UCACHE_SGRAPH_OBJ_FLAG_SHARED 0x1U
+
+#define UCACHE_SGRAPH_ELEM_STR_KEY 0x1U
+
+#define UCACHE_SGRAPH_ARR_FLAG_PACKED 0x1U
+#define UCACHE_SGRAPH_ARR_FLAG_WIDE_NEXT_FREE 0x2U
+#define UCACHE_SGRAPH_ARR_FLAG_PACKED_VALS 0x4U
+#define UCACHE_SGRAPH_ARR_SHAPE_MAX_KEYS 8U
+
+#define UCACHE_DECODE_DIRECT_CACHE_SLOTS 4U
+
+#define UCACHE_CLOCK_TICKS_PER_SEC 16U
+#define UCACHE_CLOCK_TICK_NS (ZEND_NANO_IN_SEC / UCACHE_CLOCK_TICKS_PER_SEC)
+#define UCACHE_CLOCK_SPAN_SEC (UINT32_MAX / UCACHE_CLOCK_TICKS_PER_SEC)
+
+#define UCACHE_EXPIRY_FLOOR_NONE UINT32_MAX
+
+#define UCACHE_EVICTION_POLICY_LRU 0
+#define UCACHE_EVICTION_POLICY_CLEAR 1
+#define UCACHE_EVICTION_POLICY_NONE 2
+
+#define UCACHE_ENTRY_LOCK_MIN_CAPACITY 128U
+#define UCACHE_ENTRY_LOCK_EMPTY 0
+#define UCACHE_ENTRY_LOCK_USED 1
+#define UCACHE_ENTRY_LOCK_TOMBSTONE 2
+
+/* libatomic's fallback locks are per process; shared memory needs lock-free atomics. */
+#if (defined(__GNUC__) || defined(__clang__)) && \
+ defined(__GCC_ATOMIC_LLONG_LOCK_FREE) && __GCC_ATOMIC_LLONG_LOCK_FREE == 2
+# define UCACHE_HAVE_OPTIMISTIC 1
+#elif defined(ZEND_WIN32) && defined(_MSC_VER)
+# define UCACHE_HAVE_OPTIMISTIC 1
+# define UCACHE_OPTIMISTIC_MSVC 1
+#endif
+
+#if !defined(ZEND_WIN32) && defined(HAVE_PTHREAD_MUTEXATTR_SETPSHARED) && \
+ defined(HAVE_PTHREAD_MUTEXATTR_SETROBUST) && defined(HAVE_PTHREAD_MUTEX_CONSISTENT) && \
+ (defined(HAVE_PTHREAD_MUTEX_CLOCKLOCK) || defined(HAVE_PTHREAD_MUTEX_TIMEDLOCK))
+# define UCACHE_HAVE_SHARED_MUTEX 1
+#endif
+
+#if defined(UCACHE_HAVE_SHARED_MUTEX) && defined(UCACHE_HAVE_OPTIMISTIC)
+# define UCACHE_HAVE_SCALAR_WRITE 1
+#endif
+
+#ifdef UCACHE_USE_SHM_OPEN
+# define UCACHE_HAVE_BOUNDARY_SHM 1
+# define UCACHE_BOUNDARY_SALT_SIZE 32
+#endif
+
+#define UCACHE_MAX_BOUNDARY_PARTITIONS 32U
+
+#define UCACHE_LOCK_MODEL_FCNTL 0U
+#define UCACHE_LOCK_MODEL_MUTEX 1U
+
+#define UCACHE_CPU_CACHE_LINE_SIZE 64U
+
+#define UCACHE_SCALAR_WRITE_STRIPES 8U
+
+#define UCACHE_READER_SLOTS 256U
+#define UCACHE_READER_CLAIM_MAX 4U
+
+#define UCACHE_OWNER_PROBES 16U
+
+#define UCACHE_ORPHANED_GRAPH_SLOTS 32U
+
+#define UCACHE_GRAPH_PIN_SLOTS_MAX 256U
+#define UCACHE_GRAPH_PIN_WORDS_MAX (UCACHE_GRAPH_PIN_SLOTS_MAX / 32U)
+#define UCACHE_GRAPH_PIN_CLAIM_MAX 4U
+#define UCACHE_FORK_TRANSFER_MAX 32U
+#define UCACHE_VERBATIM_VERDICT_SETS 256U
+#define UCACHE_VERBATIM_VERDICT_WAYS 4U
+#define UCACHE_PAYLOAD_VERDICT_SETS 64U
+#define UCACHE_GRAPH_PIN_OWNER_ABANDONED (-2)
+
+#define UCACHE_RECORD_EMPTY 0
+#define UCACHE_RECORD_HIT 1
+#define UCACHE_RECORD_MISS 2
+
+#define UCACHE_RECORD_VAL_NONE 0
+#define UCACHE_RECORD_VAL_COPY 1
+#define UCACHE_RECORD_VAL_PINNED 2
+#define UCACHE_RECORD_VAL_PINNED_COPY (UCACHE_RECORD_VAL_PINNED | UCACHE_RECORD_VAL_COPY)
+
+#define UCACHE_INLINE_KEY_RECORDS 4U
+
+#define UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY 0x0001U
+#define UCACHE_ENTRY_KIND_SHIFT 4U
+#define UCACHE_ENTRY_KIND_MASK 0x00f0U
+#define UCACHE_ENTRY_KIND_USED_BITS 0x00e0U
+#define UCACHE_ENTRY_POOL_BUCKET_SHIFT 8U
+#define UCACHE_ENTRY_POOL_BUCKET_MASK 0xff00U
+
+#define UCACHE_POOL_BUCKETS 256U
+
+#define UCACHE_BLOCK_FREE 1U
+#define UCACHE_BLOCK_PREV_FREE 2U
+#define UCACHE_BLOCK_FLAGS (UCACHE_BLOCK_FREE | UCACHE_BLOCK_PREV_FREE)
+#define UCACHE_BLOCK_MIN_SIZE 24U
+#define UCACHE_BLOCK_OWNER_NONE UINT32_MAX
+#define UCACHE_BLOCK_PAYLOAD_ALIGNMENT MIN(UCACHE_PLATFORM_ALIGNMENT, sizeof(ucache_block))
+#define UCACHE_BLOCK_SIZE_MAX 0xFE000000U
+#if ZEND_DEBUG
+# define UCACHE_DEBUG_BLOCK_MERGE_LIMIT (64U * 1024U)
+# define UCACHE_DEBUG_PAUSE_US 2000000U
+# define UCACHE_DEBUG_PAUSE_STEP_US 100000U
+#endif
+#define UCACHE_BLOCK_HDR_UNITS 1U
+
+#define UCACHE_OFFSET_SHIFT 3U
+#define UCACHE_OFFSET_UNIT (1U << UCACHE_OFFSET_SHIFT)
+#define UCACHE_SEG_SIZE_MAX ((uint64_t) UINT32_MAX << UCACHE_OFFSET_SHIFT)
+#define UCACHE_SHM_SIZE_MAX \
+ MIN(UCACHE_SEG_SIZE_MAX, (uint64_t) (MIN((uint64_t) SIZE_MAX, (uint64_t) ZEND_LONG_MAX) & ~(uint64_t) (UCACHE_OFFSET_UNIT - 1)))
+
+#define UCACHE_ENTRY_KEY_BYTE_SHIFT 1U
+#define UCACHE_ENTRY_KEY_BYTE_MASK ((UCACHE_OFFSET_UNIT - 1U) << UCACHE_ENTRY_KEY_BYTE_SHIFT)
+
+#define UCACHE_SGRAPH_ALIGNMENT_SLACK \
+ (ZEND_MM_ALIGNMENT > UCACHE_BLOCK_PAYLOAD_ALIGNMENT ? ZEND_MM_ALIGNMENT - 1 : 0)
+#define UCACHE_SGRAPH_HDR_SIZE(pin_word_count) \
+ (offsetof(ucache_sgraph_hdr, pin_owners) + (size_t) (pin_word_count) * sizeof(atomic_int))
+
+#define UCACHE_SIZE_CLASS_EXACT_LIMIT 1024U
+#define UCACHE_SIZE_CLASS_EXACT_BINS (UCACHE_SIZE_CLASS_EXACT_LIMIT / 8U - 2U)
+#define UCACHE_SIZE_CLASS_MIN_SHIFT 10U
+#define UCACHE_SIZE_CLASS_STEP_SHIFT 6U
+
+#define UCACHE_FREE_BINS \
+ (UCACHE_SIZE_CLASS_EXACT_BINS + ((32U - UCACHE_SIZE_CLASS_MIN_SHIFT) << UCACHE_SIZE_CLASS_STEP_SHIFT))
+
+#ifdef UCACHE_HAVE_OPTIMISTIC
+# define UCACHE_OPTIMISTIC_ENABLED 1
+#ifdef UCACHE_OPTIMISTIC_MSVC
+# if defined(_M_X64) || defined(_M_ARM64)
+/* Aligned loads are single-copy atomic here, so a load only needs the acquire barrier, not a locked read-modify-write. */
+# define UCACHE_MSVC_PLAIN_ATOMIC_LOADS 1
+# ifdef _M_ARM64
+# define UCACHE_MSVC_LOAD_ACQUIRE_BARRIER() __dmb(_ARM64_BARRIER_ISH)
+# else
+# define UCACHE_MSVC_LOAD_ACQUIRE_BARRIER() _ReadWriteBarrier()
+# endif
+# define UCACHE_ATOMIC_LOAD_64(target) \
+ ucache_msvc_load_acquire_64((const volatile __int64 *) (target))
+# define UCACHE_ATOMIC_LOAD_32(target) \
+ ucache_msvc_load_acquire_32((const volatile int *) (target))
+# else
+/* winnt.h maps these to intrinsics, or to CAS loops on x86 which lacks them. */
+# define UCACHE_ATOMIC_LOAD_64(target) \
+ ((uint64_t) InterlockedOr64((volatile LONG64 *) (target), 0))
+# define UCACHE_ATOMIC_LOAD_32(target) \
+ ((uint32_t) _InterlockedOr((volatile long *) (target), 0))
+# endif
+# define UCACHE_ATOMIC_STORE_64(target, val) \
+ ((void) InterlockedExchange64((volatile LONG64 *) (target), (LONG64) (val)))
+# define UCACHE_ATOMIC_CAS_64(target, expected, desired) \
+ ((uint64_t) _InterlockedCompareExchange64( \
+ (volatile __int64 *) (target), \
+ (__int64) (desired), \
+ (__int64) (expected) \
+ ) == (expected))
+# define UCACHE_ATOMIC_STORE_32(target, val) \
+ ((void) _InterlockedExchange((volatile long *) (target), (long) (val)))
+# define UCACHE_ATOMIC_CAS_32(target, expected, desired) \
+ ((uint32_t) _InterlockedCompareExchange((volatile long *) (target), (long) (desired), (long) (expected)) == (expected))
+# define UCACHE_ATOMIC_LOAD_32_RELAXED(target) \
+ (*(volatile uint32_t *) (target))
+# define UCACHE_ATOMIC_STORE_32_RELAXED(target, val) \
+ ((void) (*(volatile uint32_t *) (target) = (val)))
+# define UCACHE_ATOMIC_FENCE_SEQ_CST() MemoryBarrier()
+# define UCACHE_ATOMIC_FENCE_ACQUIRE() MemoryBarrier()
+#else
+/* i386 aligns uint64_t to 4, making clang call libatomic; all targets are 8-aligned. */
+# define UCACHE_ATOMIC_LOAD_64(target) \
+ __atomic_load_n((const ucache_atomic_u64 *) (const void *) (target), __ATOMIC_ACQUIRE)
+# define UCACHE_ATOMIC_STORE_64(target, val) \
+ __atomic_store_n((ucache_atomic_u64 *) (void *) (target), (val), __ATOMIC_RELEASE)
+# define UCACHE_ATOMIC_CAS_64(target, expected, desired) \
+ __atomic_compare_exchange_n( \
+ (ucache_atomic_u64 *) (void *) (target), \
+ (ucache_atomic_u64 *) (void *) &(expected), \
+ (desired), \
+ false, \
+ __ATOMIC_ACQ_REL, \
+ __ATOMIC_ACQUIRE \
+ )
+# define UCACHE_ATOMIC_LOAD_32(target) \
+ __atomic_load_n((target), __ATOMIC_ACQUIRE)
+# define UCACHE_ATOMIC_STORE_32(target, val) \
+ __atomic_store_n((target), (val), __ATOMIC_RELEASE)
+# define UCACHE_ATOMIC_CAS_32(target, expected, desired) \
+ __atomic_compare_exchange_n((target), &(expected), (desired), false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)
+# define UCACHE_ATOMIC_LOAD_32_RELAXED(target) \
+ __atomic_load_n((target), __ATOMIC_RELAXED)
+# define UCACHE_ATOMIC_STORE_32_RELAXED(target, val) \
+ __atomic_store_n((target), (val), __ATOMIC_RELAXED)
+# define UCACHE_ATOMIC_FENCE_SEQ_CST() __atomic_thread_fence(__ATOMIC_SEQ_CST)
+# define UCACHE_ATOMIC_FENCE_ACQUIRE() __atomic_thread_fence(__ATOMIC_ACQUIRE)
+#endif /* UCACHE_OPTIMISTIC_MSVC */
+#else
+# define UCACHE_OPTIMISTIC_ENABLED 0
+# define UCACHE_ATOMIC_LOAD_64(target) (*(target))
+# define UCACHE_ATOMIC_STORE_64(target, val) ((void) (*(target) = (val)))
+# define UCACHE_ATOMIC_CAS_64(target, expected, desired) \
+ (*(target) == (expected) ? ((*(target) = (desired)), true) : false)
+# define UCACHE_ATOMIC_LOAD_32(target) (*(target))
+# define UCACHE_ATOMIC_STORE_32(target, val) ((void) (*(target) = (val)))
+# define UCACHE_ATOMIC_CAS_32(target, expected, desired) \
+ (*(target) == (expected) ? ((*(target) = (desired)), true) : false)
+# define UCACHE_ATOMIC_LOAD_32_RELAXED(target) (*(target))
+# define UCACHE_ATOMIC_STORE_32_RELAXED(target, val) ((void) (*(target) = (val)))
+# define UCACHE_ATOMIC_FENCE_SEQ_CST() ((void) 0)
+# define UCACHE_ATOMIC_FENCE_ACQUIRE() ((void) 0)
+#endif /* UCACHE_HAVE_OPTIMISTIC */
+
+#ifdef ZTS
+# define UC_G(v) ZEND_TSRMG_FAST(user_cache_globals_offset, ucache_globals *, v)
+# ifdef ZEND_ENABLE_STATIC_TSRMLS_CACHE
+# define UCACHE_GLOBALS_PTR() TSRMG_FAST_BULK_STATIC(user_cache_globals_offset, ucache_globals *)
+# else
+# define UCACHE_GLOBALS_PTR() TSRMG_FAST_BULK(user_cache_globals_offset, ucache_globals *)
+# endif
+#else
+# define UC_G(v) (user_cache_globals.v)
+# define UCACHE_GLOBALS_PTR() (&user_cache_globals)
+#endif
+
+#define UCACHE_DEFINE_OBJ_FROM_STD(type, name) \
+ static zend_always_inline type *ucache_##name##_from_obj(zend_object *obj) \
+ { \
+ return (type *) ((char *) obj - offsetof(type, std)); \
+ }
+
+#if defined(UCACHE_HAVE_OPTIMISTIC) && !defined(UCACHE_OPTIMISTIC_MSVC)
+typedef uint64_t ucache_atomic_u64 __attribute__((aligned(8)));
+#endif
+
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+typedef union {
+ pthread_mutex_t mutex;
+ char padding[64];
+} ucache_shared_mutex;
+#endif
+
+typedef enum {
+ UCACHE_OPTIMISTIC_FALLBACK = 0,
+ UCACHE_OPTIMISTIC_FOUND,
+ UCACHE_OPTIMISTIC_MISS,
+ UCACHE_OPTIMISTIC_UNRESTORABLE
+} ucache_optimistic_result;
+
+typedef struct {
+ size_t configured_mem;
+ php_ucache_reason failure_reason;
+ bool enabled;
+ bool available;
+} ucache_runtime;
+
+typedef struct _ucache_lock_ops ucache_lock_ops;
+
+typedef struct {
+ const ucache_shm_handlers *handler;
+ const char *handler_name;
+ ucache_shm_seg *seg;
+ void *base;
+ const ucache_lock_ops *lock_ops;
+ size_t size;
+ int lock_file;
+#ifdef ZTS
+ uint32_t startup_complete;
+#endif
+ uint32_t capacity_memo;
+ uint32_t data_offset_memo;
+ uint32_t entry_lock_capacity_memo;
+ uint32_t entry_lock_offset_memo;
+ uint32_t free_bins_offset_memo;
+ uint32_t free_bin_count_memo;
+ bool initialized;
+ bool initialized_before_req;
+ bool lock_initialized;
+ bool layout_memo_valid;
+ bool capacity_clamped;
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ bool boundary_digest_memoized;
+ uint8_t boundary_digest_memo[32];
+ bool boundary_salt_loaded;
+ uint8_t boundary_salt[UCACHE_BOUNDARY_SALT_SIZE];
+#endif
+#ifdef ZTS
+ MUTEX_T zts_lock;
+#endif
+} ucache_storage;
+
+typedef struct {
+ ucache_storage storage;
+ const char *lock_name;
+ const char *boundary_identity;
+ size_t boundary_identity_len;
+ uint32_t graph_pin_slot_count;
+} ucache_ctx;
+
+struct _php_ucache_partition {
+ ucache_ctx ctx;
+ char *name;
+ struct _php_ucache_partition *next;
+};
+
+typedef struct {
+ zend_ulong hash;
+ uint32_t key_offset;
+ uint32_t key_len;
+ uint32_t expires_at;
+ uint8_t state;
+ uint8_t reserved[3];
+ uint64_t owner_pid;
+ uint64_t owner_start_time;
+ uint64_t owner_token;
+} ucache_entry_lock_record;
+
+typedef struct _ucache_entry_lock {
+ ucache_ctx *ctx;
+ struct _ucache_entry_lock *next;
+ uint64_t owner_pid;
+ uint64_t owner_start_time;
+ uint64_t owner_token;
+ zend_long lease;
+ uint32_t key_len;
+ bool requested_by_lock;
+ char key[1];
+} ucache_entry_lock;
+
+typedef struct {
+ uint64_t owner_pid;
+ uint64_t owner_start_time;
+ uint32_t active;
+ uint32_t reserved;
+} ucache_reader_slot;
+
+typedef struct {
+ atomic_int owner_pid;
+ atomic_int pin_count;
+ uint64_t owner_start_time;
+} ucache_graph_pin_slot;
+
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+typedef struct {
+ ucache_shared_mutex mutex;
+ uint64_t seq;
+ uint64_t old_val;
+ uint64_t old_gen;
+ uint32_t slot_idx;
+ uint32_t active;
+ uint16_t old_flags;
+ uint8_t padding_to_128_bytes[30];
+} ucache_scalar_write_stripe;
+#endif
+
+typedef struct {
+ uint32_t magic;
+ uint32_t capacity;
+ uint32_t count;
+ uint32_t data_offset;
+ uint32_t boundary_identity_digest_set;
+ uint32_t scalar_write_enabled;
+ uint64_t data_size;
+ uint64_t next_free;
+ uint64_t free_list_bytes;
+ uint64_t mutation_epoch;
+ uint64_t write_seq;
+ uint64_t time_base;
+ uint64_t expunge_count;
+ uint64_t store_failure_count;
+ uint64_t eviction_count;
+ uint64_t graph_dead_pin_owners_reclaimed;
+ uint64_t graph_dead_pins_stripped;
+ uint32_t tombstone_count;
+ uint32_t expiring_count;
+ uint32_t lock_model;
+ uint32_t orphaned_graphs_saturated;
+ uint32_t eviction_hand;
+ uint32_t reader_slots_used;
+ uint32_t entry_lock_capacity;
+ uint32_t entry_lock_offset;
+ uint32_t entry_lock_count;
+ uint32_t entry_lock_tombstone_count;
+ uint32_t entry_lock_table_section_open;
+ uint32_t entry_lock_sweep_at;
+ uint32_t free_bins_offset;
+ uint32_t free_bin_count;
+ uint32_t graph_pin_slot_count;
+ uint32_t expiry_floor;
+ uint64_t entry_lock_acquire_seq;
+ uint32_t pool_bucket_heads[UCACHE_POOL_BUCKETS];
+ uint64_t pool_bucket_epochs[UCACHE_POOL_BUCKETS];
+ uint8_t boundary_identity_digest[32];
+ uint32_t orphaned_graphs[UCACHE_ORPHANED_GRAPH_SLOTS];
+ ucache_graph_pin_slot graph_pin_slots[UCACHE_GRAPH_PIN_SLOTS_MAX];
+ ucache_reader_slot reader_slots[UCACHE_READER_SLOTS];
+ uint64_t lock_file_dev;
+ uint64_t lock_file_ino;
+ uint64_t committed_end;
+ uint64_t commit_ceiling;
+ uint64_t commit_failure_count;
+ uint64_t stale_tail_end;
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+ ucache_shared_mutex global_shared_mutex;
+ ZEND_SET_ALIGNED(128, uint32_t scalar_write_ready);
+ uint32_t scalar_write_gate;
+ ZEND_SET_ALIGNED(128, ucache_scalar_write_stripe scalar_write_stripes[UCACHE_SCALAR_WRITE_STRIPES]);
+#endif
+} ucache_hdr;
+
+typedef struct {
+ uint32_t size;
+ uint32_t owner;
+} ucache_block;
+
+typedef struct {
+ uint32_t next_free;
+ uint32_t prev_free;
+} ucache_free_links;
+
+typedef struct {
+ uint32_t hash;
+ uint32_t key_offset;
+ uint32_t expires_at;
+ uint16_t key_len;
+ uint16_t flags;
+ union {
+ zend_long long_val;
+ double double_val;
+ struct {
+ uint32_t val_offset;
+ uint32_t val_len;
+ };
+ };
+ uint64_t gen;
+} ucache_entry;
+
+typedef struct {
+ uint32_t prev;
+ uint32_t next;
+} ucache_pool_links;
+
+typedef struct {
+ zend_string *storage_key;
+ uint64_t mutation_epoch;
+ zval val;
+ uint32_t slot_idx;
+ union {
+ uint32_t pinned_payload_offset;
+ uint32_t copy_charged_bytes;
+ };
+ uint32_t expires_at;
+ uint8_t state;
+ uint8_t val_kind;
+ uint16_t access_touches;
+} ucache_key_record;
+
+typedef struct {
+ zend_ulong hash;
+ size_t payload_size;
+ size_t payload_used_size;
+ union {
+ const uint8_t *payload_src;
+ zend_long long_val;
+ double double_val;
+ };
+ union {
+ uint8_t *owned_buf;
+ zend_string *owned_str;
+ };
+ uint32_t *fixup_offsets;
+ HashTable *state_memo;
+ uint32_t fixup_count;
+ uint8_t val_type;
+ uint8_t owner_type;
+ bool has_verbatim_arr;
+ bool packed_vals_allowed;
+} ucache_prepared_val;
+
+typedef struct {
+ ucache_entry replaced_entry;
+ uint64_t stored_gen;
+ uint32_t stored_slot;
+ uint32_t stored_expires_at;
+ uint8_t stored_val_type;
+ bool committed;
+} ucache_store_result;
+
+typedef struct {
+ uint64_t gen;
+ uint64_t mutation_epoch;
+ zval detached_val;
+ uint32_t flags;
+ uint32_t val_len;
+ bool should_seed;
+} ucache_fetch_pending_seed;
+
+typedef struct {
+ zend_long new_val;
+ uint64_t stored_gen;
+ uint32_t stored_slot;
+ uint32_t stored_expires_at;
+ bool is_overflow;
+ bool is_type_err;
+} ucache_atomic_update_result;
+
+typedef struct {
+ uint8_t root_type;
+ uint8_t flags;
+ uint16_t pin_word_count;
+ atomic_int ref_state;
+ atomic_int pin_owners[1];
+} ucache_sgraph_hdr;
+
+typedef struct {
+ ucache_ctx *ctx;
+ uint32_t payload_offset;
+} ucache_req_graph_ref;
+
+typedef struct {
+ ucache_hdr *hdr;
+ uint32_t slot_idx;
+} ucache_reader_claim;
+
+typedef struct {
+ ucache_hdr *hdr;
+ uint32_t slot_idx;
+} ucache_graph_pin_claim;
+
+typedef struct {
+ ucache_ctx *ctx;
+ ucache_hdr *hdr;
+ uint32_t slot_idx;
+ uint32_t fork_seq;
+} ucache_fork_transfer;
+
+typedef struct {
+ const ucache_ctx *ctx;
+ const void *arr;
+ uint64_t gen;
+} ucache_verbatim_verdict;
+
+typedef struct {
+ uint64_t pid;
+ uint64_t start_time;
+ uint64_t probed_at;
+ bool dead;
+} ucache_owner_probe;
+
+typedef struct _ucache_restore_queue ucache_restore_queue;
+typedef struct _ucache_owned_decode_frame ucache_owned_decode_frame;
+typedef struct _ucache_sgraph_snapshot ucache_sgraph_snapshot;
+
+typedef struct _ucache_op_lease {
+ struct _ucache_op_lease *next;
+ ucache_ctx *ctx;
+ uint64_t owner_pid;
+ uint32_t payload_offset;
+ uint32_t users;
+ bool acquired;
+} ucache_op_lease;
+
+typedef struct _ucache_obj {
+ zend_string *scope;
+ zend_string *scope_prefix;
+ ucache_ctx *ctx;
+ struct _ucache_obj *live_prev;
+ struct _ucache_obj *live_next;
+ HashTable *key_records;
+ zend_string *inline_keys[UCACHE_INLINE_KEY_RECORDS];
+ ucache_key_record *inline_records[UCACHE_INLINE_KEY_RECORDS];
+ uint32_t inline_count;
+ uint32_t key_len_max;
+ zend_object std;
+} ucache_obj;
+
+typedef struct {
+ zend_string *scope;
+ zend_long entry_count;
+ zend_long used_mem;
+ zval entry_keys;
+ zend_object std;
+} ucache_pool_status_obj;
+
+typedef struct {
+ zend_long configured_mem;
+ zend_long shared_mem_size;
+ zend_long used_mem;
+ zend_long free_mem;
+ zend_long wasted_mem;
+ zend_long entry_count;
+ zend_long entry_capacity;
+ zend_long tombstone_count;
+ zend_long expunge_count;
+ zend_long store_failure_count;
+ zend_long eviction_count;
+ zend_long graph_pin_slots_in_use;
+ zend_long graph_pinned_refs;
+ zend_long dead_pin_owners_reclaimed;
+ zend_long dead_pins_stripped;
+ zend_long committed_mem;
+ zend_long commit_failure_count;
+} ucache_info_stats;
+
+typedef struct {
+ ucache_info_stats stats;
+ php_ucache_reason availability_reason;
+ bool initialized;
+ zend_object std;
+} ucache_status_obj;
+
+typedef struct {
+ php_ucache_partition *active_partition;
+ ucache_ctx *active_ctx_ptr;
+ const ucache_ctx *runtime_resolved_ctx;
+ ucache_runtime runtime_state;
+ uint32_t op_depth;
+ uint32_t access_now;
+ uint32_t access_now_touches;
+ bool runtime_resolved;
+ bool runtime_resolved_enabled;
+ bool enable;
+ bool enable_cli;
+ bool persistent_exec;
+ bool stack_overflowed;
+ bool in_req_shutdown;
+ bool key_records_trim_due;
+ uint32_t key_record_count;
+ bool lock_held;
+ bool lock_held_is_write;
+ ucache_hdr *scalar_write_hdr;
+ uint32_t scalar_write_idx;
+ php_ucache_reason req_unavailable_reason;
+ ucache_req_graph_ref *sgraph_refs;
+ uint32_t sgraph_ref_count;
+ uint32_t sgraph_ref_capacity;
+ uint64_t sgraph_ref_owner_pid;
+ uint32_t *sgraph_ref_slots;
+ size_t sgraph_ref_bytes;
+ HashTable *req_local_slot_table;
+ uint64_t req_local_slot_owner_pid;
+ HashTable *entry_lock_table;
+ ucache_entry_lock *deferred_entry_lock_releases;
+ ucache_entry_lock *entry_lock_spare;
+ HashTable *pool_table;
+ uint32_t pool_trim_at;
+ struct _ucache_obj *live_pools;
+ struct _ucache_held_records *held_records;
+ HashTable *pool_status_snapshots;
+ HashTable *decode_identity_map;
+ HashTable *decode_ref_map;
+ ucache_restore_queue *decode_restore_queue;
+ ucache_owned_decode_frame *owned_decode_frame;
+ uint64_t unrestorable_gen;
+ ucache_op_lease *op_leases;
+ ucache_op_lease *op_lease_free;
+ ucache_op_lease op_lease_inline;
+ HashTable *decode_resolve_cache;
+ HashTable *decode_shape_proto_cache;
+ HashTable *obj_route_memo;
+ const void *decode_resolve_direct_keys[UCACHE_DECODE_DIRECT_CACHE_SLOTS];
+ void *decode_resolve_direct_vals[UCACHE_DECODE_DIRECT_CACHE_SLOTS];
+ const void *decode_shape_proto_direct_keys[UCACHE_DECODE_DIRECT_CACHE_SLOTS];
+ zend_array *decode_shape_proto_direct_vals[UCACHE_DECODE_DIRECT_CACHE_SLOTS];
+ ucache_verbatim_verdict verbatim_verdicts[UCACHE_VERBATIM_VERDICT_SETS * UCACHE_VERBATIM_VERDICT_WAYS];
+ ucache_verbatim_verdict payload_verdicts[UCACHE_PAYLOAD_VERDICT_SETS * UCACHE_VERBATIM_VERDICT_WAYS];
+ uint64_t decode_payload_gen;
+ ucache_reader_claim reader_claims[UCACHE_READER_CLAIM_MAX];
+ ucache_graph_pin_claim graph_pin_claims[UCACHE_GRAPH_PIN_CLAIM_MAX];
+ ucache_fork_transfer fork_transfers[UCACHE_FORK_TRANSFER_MAX];
+ uint32_t fork_transfer_count;
+ uint32_t fork_seq;
+ uint64_t fork_transfer_pid;
+#ifndef ZEND_WIN32
+ zend_ulong entry_lock_owner_pid;
+#endif /* ZEND_WIN32 */
+ uint64_t graph_pin_probe_last_at;
+ uint64_t reader_claim_pid;
+ uint64_t reader_claim_failed_pid;
+ ucache_hdr *reader_claim_failed_hdr;
+ uint64_t graph_pin_claim_failed_pid;
+ ucache_hdr *graph_pin_claim_failed_hdr;
+ ucache_owner_probe owner_probes[UCACHE_OWNER_PROBES];
+ uint64_t self_start_time_pid;
+ uint64_t self_start_time_token;
+ zend_long shm_size;
+ zend_long entries_hint;
+ zend_long eviction_policy;
+ zend_long lock_lease_max;
+ char *lockfile_path;
+ char *mem_model;
+ uint32_t reader_claim_count;
+ uint32_t graph_pin_claim_count;
+ uint32_t decode_depth;
+ uint32_t decode_nesting;
+ uint32_t restore_hook_calls;
+ uint32_t expired_read_observations;
+ uint32_t expunge_write_ops;
+ uint32_t rehash_retry_skips;
+ uint32_t entry_lock_sweep_ops;
+ uint32_t expired_expunge_cursor;
+ size_t record_val_bytes;
+ size_t req_local_slot_bytes;
+ size_t key_record_bytes;
+ size_t key_record_bytes_trim_at;
+ uint32_t key_record_trim_at;
+ uint8_t decode_resolve_direct_next;
+ uint8_t decode_shape_proto_direct_next;
+ uint8_t verbatim_verdict_next_way;
+ uint8_t payload_verdict_next_way;
+ bool decode_payload_validated;
+ bool write_seq_bumped;
+ bool entry_lock_table_section_open;
+ bool store_defer_unlock;
+ bool req_local_slot_may_cycle;
+ int8_t reader_drain_state;
+ bool exec_prepared;
+ bool exec_active;
+ bool exec_poisoned;
+ bool logical_req_ending;
+ php_ucache_partition *exec_prev_partition;
+ ucache_ctx *exec_prev_ctx;
+ php_ucache_reason exec_prev_unavailable_reason;
+ php_ucache_partition *exec_partition;
+ uint64_t logical_scope_token;
+} ucache_globals;
+
+#ifdef ZTS
+extern int user_cache_globals_id;
+extern size_t user_cache_globals_offset;
+#else
+extern ucache_globals user_cache_globals;
+#endif
+
+extern ucache_ctx ucache_ctx_state;
+extern uint64_t ucache_self_pid;
+extern bool ucache_runtime_opted_in;
+extern php_ucache_partition *ucache_partitions;
+extern zend_class_entry *ucache_availability_ce;
+extern zend_object_handlers ucache_status_obj_handlers;
+extern zend_object_handlers ucache_pool_status_obj_handlers;
+extern php_ucache_mode ucache_registered_mode;
+extern atomic_bool ucache_registration_closed;
+#ifdef ZTS
+extern MUTEX_T ucache_boundary_partitions_mutex;
+#endif
+
+void ucache_boundary_partitions_lock(void);
+void ucache_boundary_partitions_unlock(void);
+uint64_t ucache_resolve_pid(void);
+void ucache_use_req_method_handlers(void);
+void ucache_partitions_shutdown(void);
+void ucache_boundary_partitions_shutdown(void);
+void ucache_restore_exec_preparation(void);
+ZEND_COLD void ucache_warn(const char *format, ...) ZEND_ATTRIBUTE_FORMAT(printf, 1, 2);
+ZEND_COLD void ucache_warn_docref(const char *format, ...) ZEND_ATTRIBUTE_FORMAT(printf, 1, 2);
+ZEND_COLD void ucache_log_err(const char *msg);
+void ucache_collect_info_stats(ucache_info_stats *stats);
+void ucache_release_pool_status_snapshots(void);
+void ucache_collect_pool_status(
+ ucache_obj *cache,
+ zend_long *entry_count,
+ zend_long *used_mem,
+ zval *entry_keys);
+void ucache_pool_status_obj_free(zend_object *obj);
+zend_object *ucache_pool_status_obj_create(zend_class_entry *ce);
+zend_object *ucache_status_obj_create(zend_class_entry *ce);
+void ucache_reset_runtime(void);
+bool ucache_exec_available(void);
+bool ucache_storage_startup_is_complete(const ucache_storage *storage);
+void ucache_reset_storage(void);
+size_t ucache_shm_size_min(void);
+bool ucache_mem_model_is_available(const char *model);
+uint64_t ucache_committed_bytes_locked(const ucache_hdr *hdr);
+bool ucache_hdr_init_locked(void);
+bool ucache_hdr_adoptable_locked(void);
+const char *ucache_lock_model_name(const ucache_storage *storage);
+uint32_t ucache_free_locked(uint32_t payload_offset);
+void ucache_shrink_locked(uint32_t payload_offset, size_t payload_size);
+uint32_t ucache_alloc_locked(size_t size, const void *src, uint32_t owner);
+bool ucache_alloc_can_satisfy_locked(size_t size);
+bool ucache_reclaim_space_for_entry_lock_locked(size_t key_size);
+bool ucache_startup_storage_before_req(void);
+void ucache_shutdown_storage(void);
+void ucache_classify_sapi(void);
+void ucache_ensure_ready_impl(void);
+bool ucache_rlock(void);
+bool ucache_wlock(void);
+bool ucache_wlock_for_entry_mutation(zend_string *key);
+bool ucache_wlock_for_entry_mutations(zend_string **keys, uint32_t count);
+bool ucache_try_wlock_for_entry_mutation(zend_string *key);
+bool ucache_wlock_for_ref_release(bool *recovered);
+bool ucache_rlock_for_pin_adoption(void);
+void ucache_unlock(void);
+void ucache_unlock_if_held(void);
+bool ucache_scalar_write_begin(zend_ulong hash, ucache_hdr **hdr_ptr, uint32_t *stripe_idx);
+void ucache_scalar_write_enable_locked(ucache_hdr *hdr);
+void ucache_scalar_write_prepare(ucache_hdr *hdr, uint32_t stripe_idx, uint32_t slot_idx);
+void ucache_scalar_write_commit(ucache_hdr *hdr, uint32_t stripe_idx, ucache_entry *entry);
+void ucache_scalar_write_end(void);
+bool ucache_try_acquire_entry_lock(zend_string *key, zend_long lease);
+bool ucache_acquire_entry_lock(zend_string *key);
+bool ucache_release_entry_lock(zend_string *key);
+bool ucache_release_entry_lock_unless_requested(zend_string *key);
+bool ucache_req_owns_entry_lock(zend_string *key);
+bool ucache_entry_locks_allow_clear_locked(zend_string *prefix);
+bool ucache_entry_key_lock_active_locked(
+ ucache_hdr *hdr,
+ uint32_t hash,
+ size_t key_pos,
+ uint32_t key_len,
+ uint64_t now);
+void ucache_release_req_entry_locks(void);
+void ucache_retry_deferred_entry_lock_releases(void);
+bool ucache_active_ctx_has_deferred_entry_lock_releases(void);
+void ucache_free_thread_deferred_entry_lock_releases(ucache_globals *globals);
+#ifdef ZTS
+void ucache_orphan_thread_deferred_entry_lock_releases(ucache_globals *globals);
+void ucache_free_orphaned_entry_lock_releases(void);
+#endif
+const php_ucache_safe_direct_handlers *ucache_safe_direct_find_handlers(
+ zend_class_entry *ce,
+ zend_class_entry **base_ce_ptr);
+php_ucache_safe_direct_copy_func_t ucache_safe_direct_copy_func(zend_class_entry *ce);
+bool ucache_serdes_encode(zval *val, smart_str *buf);
+bool ucache_serdes_decode(const uint8_t *data, size_t len, zval *dst);
+void ucache_sgraph_calc_verbatim_root(
+ const zval *val,
+ HashTable *verbatim_verdicts,
+ size_t *buf_len);
+bool ucache_calc_sgraph_size(
+ const zval *val,
+ HashTable *state_memo,
+ HashTable *verbatim_verdicts,
+ size_t *buf_len,
+ bool *packed_vals_allowed);
+bool ucache_build_sgraph_in_place(
+ const zval *val,
+ HashTable *state_memo,
+ const HashTable *verbatim_verdicts,
+ bool packed_vals_allowed,
+ uint8_t *buf,
+ size_t buf_len,
+ size_t *glen,
+ bool *has_verbatim_arr,
+ uint32_t **fixup_offsets,
+ uint32_t *fixup_count);
+bool ucache_sgraph_copy_fits_buf(
+ const uint8_t *dst_buf,
+ const uint8_t *src_buf,
+ size_t buf_len,
+ size_t src_glen);
+bool ucache_sgraph_decode(const uint8_t *buf, size_t buf_len, uint64_t gen, zval *dst);
+ucache_sgraph_snapshot *ucache_sgraph_snapshot_create(const uint8_t *buf, size_t buf_len);
+bool ucache_sgraph_decode_snapshot(ucache_sgraph_snapshot *snapshot, uint64_t gen, zval *dst);
+bool ucache_owned_decode_validate_proc_impl(void);
+const uint32_t *ucache_sgraph_node_sizes(uint32_t *count);
+uint32_t ucache_sgraph_payload_flags(uint32_t payload_offset);
+void ucache_sgraph_obj_route_memo_release(void);
+ZEND_COLD void ucache_throw_unstorable_res(void);
+ZEND_COLD void ucache_throw_unstorable_obj(const zend_class_entry *ce);
+void ucache_decode_payload_addr_caches_release(void);
+void ucache_decode_maps_teardown(void);
+bool ucache_sgraph_quiesce_for_overwrite_locked(uint32_t payload_offset);
+bool ucache_sgraph_payload_has_refs_locked(uint32_t payload_offset);
+bool ucache_sgraph_publish_copied_payload_locked(
+ uint8_t *dst_buf,
+ const uint8_t *src_buf,
+ size_t buf_len,
+ size_t src_glen,
+ bool has_verbatim_arr,
+ const uint32_t *fixup_offsets,
+ uint32_t fixup_count);
+bool ucache_sgraph_acquire_ref(uint32_t payload_offset, bool *resource_limited);
+bool ucache_sgraph_release_op_ref(uint32_t payload_offset);
+void ucache_retry_op_lease_releases(void);
+void ucache_release_op_leases(void);
+void ucache_abandon_graph_pin_claims(void);
+size_t ucache_sgraph_largest_space_after_clear_locked(ucache_hdr *hdr);
+bool ucache_sgraph_retire_payload_locked(uint32_t payload_offset);
+uint32_t ucache_req_sgraph_ref_idx(uint32_t payload_offset);
+uint32_t ucache_register_sgraph_ref(uint32_t payload_offset, uint32_t payload_len);
+void ucache_release_req_sgraph_refs(void);
+void ucache_sgraph_refs_transfer_before_fork(void);
+void ucache_sgraph_refs_adopt_after_fork(void);
+void ucache_sgraph_fork_transfers_undo(void);
+void ucache_expunge_expired_at_req_end(void);
+bool ucache_prepare_val(
+ zend_string *key,
+ zval *val,
+ ucache_prepared_val *prepared);
+void ucache_destroy_prepared_val(ucache_prepared_val *prepared);
+bool ucache_store_prepared_locked(
+ zend_string *key,
+ zval *val,
+ const ucache_prepared_val *prepared,
+ zend_long ttl,
+ bool bulk,
+ ucache_store_result *result);
+bool ucache_fetch_locked(
+ ucache_key_record *record,
+ zval *return_value,
+ bool *found,
+ ucache_fetch_pending_seed *pending_seed,
+ bool *lock_held);
+void ucache_fetch_finish(
+ ucache_key_record *record,
+ ucache_fetch_pending_seed *pending_seed,
+ zval *return_value);
+void ucache_key_record_reset(ucache_key_record *record, zval *detached);
+void ucache_key_record_stored(
+ ucache_key_record *record,
+ const ucache_store_result *result,
+ zval *val);
+void ucache_key_record_deleted(ucache_key_record *record, uint64_t epoch);
+void ucache_key_record_atomic_updated(
+ ucache_key_record *record,
+ const ucache_atomic_update_result *result);
+bool ucache_exists_locked(zend_string *key);
+uint64_t ucache_delete_locked(zend_string *key);
+uint64_t ucache_delete_gen_locked(zend_string *key, uint64_t gen);
+void ucache_discard_replaced_entry_locked(ucache_entry *replaced_entry);
+void ucache_rollback_replaced_entry_locked(zend_string *key, ucache_entry *replaced_entry);
+void ucache_delete_by_prefix_locked(zend_string *prefix);
+bool ucache_atomic_update_locked(
+ zend_string *key,
+ zend_long step,
+ zend_long ttl,
+ bool decrement,
+ ucache_atomic_update_result *result);
+bool ucache_try_store_scalar(
+ ucache_key_record *record,
+ const ucache_prepared_val *prepared);
+bool ucache_try_atomic_update(
+ ucache_key_record *record,
+ zend_long step,
+ zend_long ttl,
+ bool decrement,
+ ucache_atomic_update_result *result,
+ bool *updated);
+void ucache_release_req_local_slots(void);
+void ucache_release_req_local_slot(zend_string *key);
+void ucache_release_active_req_local_slots_by_prefix(zend_string *prefix);
+void ucache_obj_table_dtor(zval *zv);
+void ucache_ref_table_dtor(zval *zv);
+ucache_optimistic_result ucache_fetch_optimistic(
+ ucache_key_record *record,
+ zval *return_value,
+ bool allow_decode);
+ucache_optimistic_result ucache_exists_optimistic(ucache_key_record *record);
+bool ucache_optimistic_reader_begin(ucache_hdr *hdr, uint32_t *slot_idx_ptr);
+void ucache_optimistic_reader_end(ucache_hdr *hdr, uint32_t slot_idx);
+#if defined(ZTS) && !defined(ZEND_WIN32)
+void ucache_lock_storage_startup_before_fork(void);
+void ucache_unlock_storage_startup_after_fork(void);
+void ucache_reinit_storage_locks_after_fork(void);
+#endif
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+void ucache_shared_boundary_segs_after_fork(void);
+#endif
+void ucache_release_thread_reader_claims(ucache_globals *globals);
+bool ucache_quiesce_graph_payloads_locked(void);
+void ucache_sgraph_ref_reserve(void);
+void ucache_sgraph_orphan_payload_locked(uint32_t payload_offset);
+void ucache_sgraph_reclaim_orphaned_locked(void);
+bool ucache_sgraph_strip_dead_pins_locked(bool force);
+bool ucache_owner_is_dead(uint64_t owner_pid, uint64_t owner_start_time);
+uint32_t ucache_sleep_us(uint32_t interval_us);
+uint64_t ucache_self_start_time_token(void);
+void ucache_release_thread_graph_pin_claims(ucache_globals *globals);
+ZEND_METHOD(UserCache_CacheStatus, __construct);
+ZEND_METHOD(UserCache_CacheStatus, getAvailability);
+ZEND_METHOD(UserCache_CacheStatus, getConfiguredMemory);
+ZEND_METHOD(UserCache_CacheStatus, getSharedMemorySize);
+ZEND_METHOD(UserCache_CacheStatus, getUsedMemory);
+ZEND_METHOD(UserCache_CacheStatus, getFreeMemory);
+ZEND_METHOD(UserCache_CacheStatus, getWastedMemory);
+ZEND_METHOD(UserCache_CacheStatus, getEntryCount);
+ZEND_METHOD(UserCache_CacheStatus, getEntryCapacity);
+ZEND_METHOD(UserCache_CacheStatus, getTombstoneCount);
+ZEND_METHOD(UserCache_CacheStatus, getExpungeCount);
+ZEND_METHOD(UserCache_CacheStatus, getEvictionCount);
+ZEND_METHOD(UserCache_CacheStatus, getStoreFailureCount);
+ZEND_METHOD(UserCache_CacheStatus, getGraphPinSlotsInUse);
+ZEND_METHOD(UserCache_CacheStatus, getGraphPinnedReferences);
+ZEND_METHOD(UserCache_CacheStatus, getDeadPinOwnersReclaimed);
+ZEND_METHOD(UserCache_CacheStatus, getDeadPinsStripped);
+ZEND_METHOD(UserCache_CacheStatus, getCommittedMemory);
+ZEND_METHOD(UserCache_CacheStatus, getCommitFailureCount);
+ZEND_METHOD(UserCache_CachePoolStatus, __construct);
+ZEND_METHOD(UserCache_CachePoolStatus, getPoolName);
+ZEND_METHOD(UserCache_CachePoolStatus, getEntryCount);
+ZEND_METHOD(UserCache_CachePoolStatus, getEntryKeys);
+ZEND_METHOD(UserCache_CachePoolStatus, getUsedMemory);
+
+static_assert(
+ offsetof(ucache_hdr, scalar_write_enabled) / UCACHE_CPU_CACHE_LINE_SIZE ==
+ offsetof(ucache_hdr, write_seq) / UCACHE_CPU_CACHE_LINE_SIZE,
+ "scalar_write_enabled must share the cache line of write_seq"
+);
+static_assert(
+ sizeof(ucache_entry) == 32,
+ "two entries must share a cache line"
+);
+static_assert(
+ sizeof(ucache_block) == UCACHE_BLOCK_HDR_UNITS * UCACHE_OFFSET_UNIT,
+ "the block header must span whole offset units"
+);
+static_assert(
+ UCACHE_PLATFORM_ALIGNMENT % UCACHE_OFFSET_UNIT == 0,
+ "blocks must start on offset units"
+);
+static_assert(
+ UCACHE_BLOCK_SIZE_MAX % UCACHE_PLATFORM_ALIGNMENT == 0,
+ "the largest block must keep block alignment"
+);
+static_assert(
+ (uint64_t) MAX(
+ UCACHE_ENTRIES_HINT_MAX,
+ UCACHE_SEG_SIZE_MAX / UCACHE_AUTO_SEG_BYTES_PER_ENTRY
+ ) * 2 * UCACHE_TABLE_SLOT_SIZE < UINT32_MAX,
+ "the largest entry table must keep the layout offsets within 32 bits"
+);
+
+#if ZEND_DEBUG
+static zend_always_inline bool ucache_debug_fault(const char *env_name)
+{
+ const char *val = getenv(env_name);
+
+ return val != NULL && val[0] != '\0' && val[0] != '0';
+}
+
+static zend_always_inline void ucache_debug_pause(void)
+{
+ uint32_t waited_us;
+
+ for (waited_us = 0;
+ waited_us < UCACHE_DEBUG_PAUSE_US;
+ waited_us += ucache_sleep_us(UCACHE_DEBUG_PAUSE_STEP_US)
+ );
+}
+#endif
+
+static zend_always_inline bool ucache_globals_allocated(void)
+{
+#ifdef ZTS
+ return user_cache_globals_id != 0;
+#else
+ return true;
+#endif
+}
+
+static zend_always_inline uint32_t ucache_table_hash(zend_ulong hash)
+{
+ return (uint32_t) hash;
+}
+
+static zend_always_inline uint32_t ucache_entry_kind(const ucache_entry *entry)
+{
+ return (entry->flags & UCACHE_ENTRY_KIND_MASK) >> UCACHE_ENTRY_KIND_SHIFT;
+}
+
+static zend_always_inline bool ucache_entry_is_used(const ucache_entry *entry)
+{
+ return (entry->flags & UCACHE_ENTRY_KIND_USED_BITS) != 0;
+}
+
+static zend_always_inline bool ucache_entry_holds_scalar(const ucache_entry *entry)
+{
+ return ucache_entry_kind(entry) - UCACHE_ENTRY_USED <= UCACHE_VAL_DOUBLE;
+}
+
+static zend_always_inline uint8_t ucache_entry_val_type(const ucache_entry *entry)
+{
+ ZEND_ASSERT(ucache_entry_is_used(entry));
+
+ return (uint8_t) (ucache_entry_kind(entry) - UCACHE_ENTRY_USED);
+}
+
+static zend_always_inline uint32_t ucache_entry_val_offset(const ucache_entry *entry)
+{
+ uint32_t kind = ucache_entry_kind(entry);
+
+ return kind == UCACHE_ENTRY_USED + UCACHE_VAL_STR ||
+ kind == UCACHE_ENTRY_USED + UCACHE_VAL_SGRAPH
+ ? entry->val_offset : 0
+ ;
+}
+
+static zend_always_inline uint64_t ucache_cached_pid(void)
+{
+ uint64_t pid = ucache_self_pid;
+
+ return EXPECTED(pid != 0) ? pid : ucache_resolve_pid();
+}
+
+static zend_always_inline bool ucache_stack_exhausted(void)
+{
+#ifdef ZEND_CHECK_STACK_LIMIT
+ return UNEXPECTED(zend_call_stack_overflowed(EG(stack_limit)));
+#else
+ return false;
+#endif /* ZEND_CHECK_STACK_LIMIT */
+}
+
+static zend_always_inline bool ucache_owned_decode_validate_proc(void)
+{
+ return EXPECTED(UC_G(owned_decode_frame) == NULL) || ucache_owned_decode_validate_proc_impl();
+}
+
+#ifdef UCACHE_MSVC_PLAIN_ATOMIC_LOADS
+static zend_always_inline uint64_t ucache_msvc_load_acquire_64(const volatile __int64 *target)
+{
+ uint64_t val = (uint64_t) __iso_volatile_load64(target);
+
+ UCACHE_MSVC_LOAD_ACQUIRE_BARRIER();
+
+ return val;
+}
+
+static zend_always_inline uint32_t ucache_msvc_load_acquire_32(const volatile int *target)
+{
+ uint32_t val = (uint32_t) __iso_volatile_load32(target);
+
+ UCACHE_MSVC_LOAD_ACQUIRE_BARRIER();
+
+ return val;
+}
+#endif
+
+static zend_always_inline uint64_t ucache_atomic_load_64(const uint64_t *target)
+{
+ return UCACHE_ATOMIC_LOAD_64(target);
+}
+
+static zend_always_inline void ucache_atomic_store_64(uint64_t *target, uint64_t val)
+{
+ UCACHE_ATOMIC_STORE_64(target, val);
+}
+
+static zend_always_inline bool ucache_atomic_cas_64(uint64_t *target, uint64_t expected, uint64_t desired)
+{
+ return UCACHE_ATOMIC_CAS_64(target, expected, desired);
+}
+
+static zend_always_inline bool ucache_atomic_cas_32(uint32_t *target, uint32_t expected, uint32_t desired)
+{
+ return UCACHE_ATOMIC_CAS_32(target, expected, desired);
+}
+
+static zend_always_inline uint32_t ucache_scalar_write_idx(zend_ulong hash)
+{
+ return (uint32_t) ((hash ^ (hash >> 16)) & (UCACHE_SCALAR_WRITE_STRIPES - 1));
+}
+
+static zend_always_inline void ucache_atomic_fence_acquire(void)
+{
+ UCACHE_ATOMIC_FENCE_ACQUIRE();
+}
+
+static zend_always_inline ucache_ctx *ucache_owning_ctx(void)
+{
+ return UC_G(active_partition) != NULL
+ ? &UC_G(active_partition)->ctx
+ : &ucache_ctx_state
+ ;
+}
+
+static zend_always_inline ucache_ctx *ucache_active_ctx(void)
+{
+ if (UC_G(active_ctx_ptr) != NULL) {
+ return UC_G(active_ctx_ptr);
+ }
+
+ return ucache_owning_ctx();
+}
+
+static zend_always_inline bool ucache_ctx_is_boundary(const ucache_ctx *ctx)
+{
+ return ctx->boundary_identity != NULL;
+}
+
+static zend_always_inline uint32_t ucache_ctx_graph_pin_slot_count(const ucache_ctx *ctx)
+{
+ return ctx->graph_pin_slot_count != 0 ? ctx->graph_pin_slot_count : UCACHE_GRAPH_PIN_SLOTS_MAX;
+}
+
+static zend_always_inline uint32_t ucache_graph_pin_slot_count(const ucache_hdr *hdr)
+{
+ return MIN(hdr->graph_pin_slot_count, UCACHE_GRAPH_PIN_SLOTS_MAX);
+}
+
+static zend_always_inline void *ucache_base(void)
+{
+ return ucache_active_ctx()->storage.base;
+}
+
+static zend_always_inline size_t ucache_req_cache_budget(void)
+{
+ size_t mem = PG(memory_limit) > 0
+ ? (size_t) PG(memory_limit)
+ : ucache_active_ctx()->storage.size
+ ;
+
+ return MAX(UCACHE_REQ_CACHE_BUDGET_MIN, mem / UCACHE_REQ_CACHE_BUDGET_DIVISOR);
+}
+
+static zend_always_inline ucache_hdr *ucache_hdr_ptr(void)
+{
+ return (ucache_hdr *) ucache_base();
+}
+
+static zend_always_inline size_t ucache_offset_bytes(uint32_t offset)
+{
+ return (size_t) offset << UCACHE_OFFSET_SHIFT;
+}
+
+static zend_always_inline uint32_t ucache_offset_from_bytes(size_t bytes)
+{
+ ZEND_ASSERT((bytes & (UCACHE_OFFSET_UNIT - 1)) == 0);
+#if SIZEOF_SIZE_T > 4
+ ZEND_ASSERT((uint64_t) bytes <= UCACHE_SEG_SIZE_MAX);
+#endif
+
+ return (uint32_t) (bytes >> UCACHE_OFFSET_SHIFT);
+}
+
+static zend_always_inline uint32_t ucache_offset_after(uint32_t offset, size_t bytes)
+{
+ return offset + ucache_offset_from_bytes(bytes);
+}
+
+static zend_always_inline uint32_t ucache_payload_block_offset(uint32_t payload_offset)
+{
+ return payload_offset - UCACHE_BLOCK_HDR_UNITS;
+}
+
+static zend_always_inline uint32_t ucache_block_payload_offset(uint32_t block_offset)
+{
+ return block_offset + UCACHE_BLOCK_HDR_UNITS;
+}
+
+static zend_always_inline uint8_t *ucache_ptr(uint32_t offset)
+{
+ return (uint8_t *) ucache_base() + ucache_offset_bytes(offset);
+}
+
+static zend_always_inline size_t ucache_entry_key_pos(const ucache_entry *entry)
+{
+ return ucache_offset_bytes(entry->key_offset) |
+ ((entry->flags & UCACHE_ENTRY_KEY_BYTE_MASK) >> UCACHE_ENTRY_KEY_BYTE_SHIFT)
+ ;
+}
+
+static zend_always_inline uint32_t ucache_used_end_offset_locked(const ucache_hdr *hdr)
+{
+ return ucache_offset_from_bytes((size_t) hdr->data_offset + hdr->next_free);
+}
+
+static zend_always_inline ucache_block *ucache_block_ptr(uint32_t offset)
+{
+ return (ucache_block *) ucache_ptr(offset);
+}
+
+static zend_always_inline ucache_block *ucache_block_ptr_in_hdr(
+ const ucache_hdr *hdr,
+ uint32_t offset)
+{
+ return (ucache_block *) ((uint8_t *) hdr + ucache_offset_bytes(offset));
+}
+
+static zend_always_inline uint32_t ucache_block_merge_limit(void)
+{
+#if ZEND_DEBUG
+ if (UCACHE_DEBUG_FAULT("SMALL_BLOCK_MERGE_LIMIT")) {
+ return UCACHE_DEBUG_BLOCK_MERGE_LIMIT;
+ }
+#endif
+
+ return UCACHE_BLOCK_SIZE_MAX;
+}
+
+static zend_always_inline uint32_t ucache_block_size(const ucache_block *block)
+{
+ return block->size & ~UCACHE_BLOCK_FLAGS;
+}
+
+static zend_always_inline bool ucache_block_is_free(const ucache_block *block)
+{
+ return (block->size & UCACHE_BLOCK_FREE) != 0;
+}
+
+static zend_always_inline bool ucache_block_prev_is_free(const ucache_block *block)
+{
+ return (block->size & UCACHE_BLOCK_PREV_FREE) != 0;
+}
+
+static zend_always_inline uint32_t ucache_floor_log2(uint32_t val)
+{
+ return (uint32_t) (SIZEOF_ZEND_LONG * 8 - 1) - (uint32_t) zend_ulong_nlz((zend_ulong) val);
+}
+
+static zend_always_inline size_t ucache_size_class_round_up(size_t size)
+{
+ size_t step;
+
+ if (size < UCACHE_SIZE_CLASS_EXACT_LIMIT || size > UINT32_MAX) {
+ return size;
+ }
+
+ step = (size_t) 1 << (ucache_floor_log2((uint32_t) size) - UCACHE_SIZE_CLASS_STEP_SHIFT);
+
+ return (size + step - 1) & ~(step - 1);
+}
+
+static zend_always_inline size_t ucache_block_total_size(size_t payload_size)
+{
+ size_t total = UCACHE_ALIGNED_SIZE(sizeof(ucache_block) + payload_size);
+
+ return ucache_size_class_round_up(MAX(total, UCACHE_BLOCK_MIN_SIZE));
+}
+
+static zend_always_inline bool ucache_payload_offset_in_used(
+ const ucache_hdr *hdr,
+ uint32_t payload_offset)
+{
+ uint64_t payload_pos = ucache_offset_bytes(payload_offset);
+
+ return payload_pos > hdr->data_offset &&
+ payload_pos < (uint64_t) hdr->data_offset + hdr->next_free
+ ;
+}
+
+static zend_always_inline uint32_t ucache_block_payload_capacity(
+ const ucache_hdr *hdr,
+ uint32_t payload_offset)
+{
+ uint32_t size;
+
+ if (!ucache_payload_offset_in_used(hdr, payload_offset)) {
+ return 0;
+ }
+
+ size = ucache_block_size(
+ ucache_block_ptr_in_hdr(hdr, ucache_payload_block_offset(payload_offset))
+ );
+
+ return size < sizeof(ucache_block) ? 0 : size - (uint32_t) sizeof(ucache_block);
+}
+
+static zend_always_inline bool ucache_bytes_committed(const ucache_hdr *hdr, uint64_t end)
+{
+#ifdef ZEND_WIN32
+ return end <= ucache_atomic_load_64(&hdr->committed_end);
+#else
+ (void) hdr;
+ (void) end;
+
+ return true;
+#endif
+}
+
+static zend_always_inline bool ucache_bytes_in_bounds(
+ const ucache_hdr *hdr,
+ size_t pos,
+ uint64_t len)
+{
+ return pos >= (size_t) hdr->data_offset + sizeof(ucache_block) &&
+ (uint64_t) pos + len <= (uint64_t) hdr->data_offset + hdr->data_size &&
+ ucache_bytes_committed(hdr, (uint64_t) pos + len)
+ ;
+}
+
+static zend_always_inline bool ucache_payload_in_bounds(
+ const ucache_hdr *hdr,
+ uint32_t offset,
+ uint64_t len)
+{
+ return ucache_bytes_in_bounds(hdr, ucache_offset_bytes(offset), len);
+}
+
+static zend_always_inline ucache_ctx *ucache_activate_ctx(ucache_ctx *ctx)
+{
+ ucache_ctx *prev = UC_G(active_ctx_ptr);
+
+ UC_G(active_ctx_ptr) = ctx;
+
+ return prev;
+}
+
+static zend_always_inline void ucache_restore_ctx(ucache_ctx *ctx)
+{
+ UC_G(active_ctx_ptr) = ctx;
+}
+
+static zend_always_inline ucache_runtime *ucache_active_runtime(void)
+{
+ return &UC_G(runtime_state);
+}
+
+static zend_always_inline bool ucache_hdr_is_initialized_locked(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ return hdr != NULL && hdr->magic == UCACHE_MAGIC;
+}
+
+static zend_always_inline uint64_t ucache_clock_now(void)
+{
+#if ZEND_HRTIME_AVAILABLE
+ return zend_hrtime() / UCACHE_CLOCK_TICK_NS;
+#else
+ return (uint64_t) time(NULL) * UCACHE_CLOCK_TICKS_PER_SEC;
+#endif
+}
+
+static zend_always_inline uint64_t ucache_time_rel(const ucache_hdr *hdr, uint64_t now)
+{
+ return now > hdr->time_base
+ ? now - hdr->time_base
+ : 0
+ ;
+}
+
+static zend_always_inline uint32_t ucache_expiry_deadline(
+ const ucache_hdr *hdr,
+ uint64_t now,
+ zend_long ttl)
+{
+ uint64_t next_full_tick, deadline;
+
+ if ((zend_ulong) ttl >= UCACHE_CLOCK_SPAN_SEC) {
+ return UINT32_MAX;
+ }
+
+ next_full_tick = ucache_time_rel(hdr, now) + 1;
+ deadline = next_full_tick + (uint64_t) ttl * UCACHE_CLOCK_TICKS_PER_SEC;
+
+ return deadline > (uint64_t) UINT32_MAX
+ ? UINT32_MAX
+ : (uint32_t) deadline
+ ;
+}
+
+static zend_always_inline uint64_t ucache_data_tail_limit_locked(const ucache_hdr *hdr)
+{
+ if (hdr->commit_ceiling > hdr->data_offset &&
+ hdr->commit_ceiling - hdr->data_offset < hdr->data_size
+ ) {
+ return hdr->commit_ceiling - hdr->data_offset;
+ }
+
+ return hdr->data_size;
+}
+
+static zend_always_inline void ucache_bump_mutation_epoch_locked(ucache_hdr *hdr)
+{
+ uint64_t epoch;
+
+ if (hdr == NULL) {
+ return;
+ }
+
+ epoch = hdr->mutation_epoch + 1;
+
+ ucache_atomic_store_64(&hdr->mutation_epoch, epoch != 0 ? epoch : 1);
+}
+
+static zend_always_inline ucache_entry *ucache_entries_ptr(ucache_hdr *hdr)
+{
+ return (ucache_entry *) ((char *) hdr + sizeof(ucache_hdr));
+}
+
+static zend_always_inline ucache_pool_links *ucache_pool_links_ptr(ucache_hdr *hdr)
+{
+ return (ucache_pool_links *) (
+ (char *) hdr
+ + sizeof(ucache_hdr)
+ + (size_t) hdr->capacity * (sizeof(ucache_entry) + sizeof(uint32_t))
+ );
+}
+
+static zend_always_inline bool ucache_pool_link_ref_fits(const ucache_hdr *hdr, uint32_t ref)
+{
+ return ref <= hdr->capacity;
+}
+
+static zend_always_inline void ucache_pool_bucket_changed_locked(ucache_hdr *hdr, uint32_t bucket)
+{
+ hdr->pool_bucket_epochs[bucket]++;
+ if (hdr->pool_bucket_epochs[bucket] == 0) {
+ hdr->pool_bucket_epochs[bucket] = 1;
+ }
+}
+
+static zend_always_inline void ucache_pool_idx_reset_locked(ucache_hdr *hdr)
+{
+ uint32_t bucket;
+
+ memset(hdr->pool_bucket_heads, 0, sizeof(hdr->pool_bucket_heads));
+ for (bucket = 0; bucket < UCACHE_POOL_BUCKETS; bucket++) {
+ ucache_pool_bucket_changed_locked(hdr, bucket);
+ }
+}
+
+static zend_always_inline uint32_t *ucache_access_stamps_ptr(ucache_hdr *hdr)
+{
+ return (uint32_t *) (
+ (char *) hdr
+ + sizeof(ucache_hdr)
+ + (size_t) hdr->capacity * sizeof(ucache_entry)
+ );
+}
+
+static zend_always_inline uint32_t ucache_free_bin_words(uint32_t bin_count)
+{
+ return (bin_count + 31U) / 32U;
+}
+
+static zend_always_inline uint32_t *ucache_free_bins_ptr(const ucache_hdr *hdr)
+{
+ return (uint32_t *) ((char *) hdr + hdr->free_bins_offset);
+}
+
+static zend_always_inline uint32_t *ucache_free_bin_mask_ptr(const ucache_hdr *hdr)
+{
+ return ucache_free_bins_ptr(hdr) + hdr->free_bin_count;
+}
+
+static zend_always_inline ucache_entry_lock_record *ucache_entry_lock_records_ptr(ucache_hdr *hdr)
+{
+ return (ucache_entry_lock_record *) ((char *) hdr + hdr->entry_lock_offset);
+}
+
+static zend_always_inline void ucache_access_stamps_reset(ucache_hdr *hdr)
+{
+ uint32_t i, *stamps = ucache_access_stamps_ptr(hdr);
+
+ for (i = 0; i < hdr->capacity; i++) {
+ UCACHE_ATOMIC_STORE_32_RELAXED(&stamps[i], 0);
+ }
+}
+
+static zend_always_inline bool ucache_seen_test_and_add(HashTable *seen, const void *ptr)
+{
+ return zend_hash_index_add_empty_element(seen, (zend_ulong) (uintptr_t) ptr) != NULL;
+}
+
+UCACHE_DEFINE_OBJ_FROM_STD(ucache_pool_status_obj, pool_status_obj)
+
+UCACHE_DEFINE_OBJ_FROM_STD(ucache_status_obj, status_obj)
+
+#endif /* UCACHE_INTERNAL_H */
diff --git a/ext/user_cache/user_cache_lock.c b/ext/user_cache/user_cache_lock.c
new file mode 100644
index 000000000000..4cc4f8fa39a4
--- /dev/null
+++ b/ext/user_cache/user_cache_lock.c
@@ -0,0 +1,1321 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_storage.h"
+
+#define UCACHE_ENTRY_LOCK_SWEEP_OP_INTERVAL 64U
+
+#define UCACHE_NSEC_PER_SEC 1000000000L
+#define UCACHE_ROBUST_MUTEX_RECHECK_NSEC (50L * 1000L * 1000L)
+#define UCACHE_FCNTL_DEADLOCK_RETRY_US 1000U
+#ifdef HAVE_PTHREAD_MUTEX_CLOCKLOCK
+# define UCACHE_ROBUST_MUTEX_CLOCK CLOCK_MONOTONIC
+# define UCACHE_ROBUST_MUTEX_LOCK_UNTIL(mutex, deadline) \
+ pthread_mutex_clocklock((mutex), CLOCK_MONOTONIC, (deadline))
+#else
+# define UCACHE_ROBUST_MUTEX_CLOCK CLOCK_REALTIME
+# define UCACHE_ROBUST_MUTEX_LOCK_UNTIL(mutex, deadline) \
+ pthread_mutex_timedlock((mutex), (deadline))
+#endif
+
+#define UCACHE_SEM_FILENAME_PREFIX ".PhpUserCacheSem."
+
+struct _ucache_lock_ops {
+ const char *name;
+ bool (*rlock)(ucache_storage *storage);
+ bool (*wlock)(ucache_storage *storage);
+ void (*unlock)(ucache_storage *storage);
+};
+
+#ifdef ZEND_WIN32
+static bool ucache_win32_rlock(ucache_storage *storage);
+static bool ucache_win32_wlock(ucache_storage *storage);
+static void ucache_win32_unlock(ucache_storage *storage);
+#else
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+static bool ucache_shared_mutex_lock(ucache_storage *storage);
+static void ucache_shared_mutex_unlock(ucache_storage *storage);
+#endif /* UCACHE_HAVE_SHARED_MUTEX */
+static bool ucache_fcntl_rlock(ucache_storage *storage);
+static bool ucache_fcntl_wlock(ucache_storage *storage);
+static void ucache_fcntl_unlock(ucache_storage *storage);
+#endif /* ZEND_WIN32 */
+
+#ifdef ZEND_WIN32
+static const ucache_lock_ops ucache_win32_lock_ops = {
+ "win32",
+ ucache_win32_rlock,
+ ucache_win32_wlock,
+ ucache_win32_unlock
+};
+#else
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+static const ucache_lock_ops ucache_shared_mutex_lock_ops = {
+ "mutex",
+ ucache_shared_mutex_lock,
+ ucache_shared_mutex_lock,
+ ucache_shared_mutex_unlock
+};
+#endif /* UCACHE_HAVE_SHARED_MUTEX */
+
+static const ucache_lock_ops ucache_fcntl_lock_ops = {
+ "fcntl",
+ ucache_fcntl_rlock,
+ ucache_fcntl_wlock,
+ ucache_fcntl_unlock
+};
+#endif /* ZEND_WIN32 */
+
+#ifdef ZEND_WIN32
+const ucache_platform_ops ucache_platform = {
+ ucache_win32_sleep_us,
+ ucache_win32_proc_start_time_token,
+ ucache_win32_proc_has_exited,
+ ucache_win32_alloc_err_code,
+ ucache_win32_log_alloc_failure
+};
+#else
+const ucache_platform_ops ucache_platform = {
+ ucache_posix_sleep_us,
+ ucache_posix_proc_start_time_token,
+ ucache_posix_proc_has_exited,
+ ucache_posix_alloc_err_code,
+ ucache_posix_log_alloc_failure
+};
+#endif /* ZEND_WIN32 */
+
+#ifdef UCACHE_HAVE_SCALAR_WRITE
+static zend_always_inline void ucache_scalar_write_back_off(ucache_scalar_write_stripe *stripe)
+{
+ ucache_atomic_store_32(&stripe->active, 0);
+
+ pthread_mutex_unlock(&stripe->mutex.mutex);
+}
+#endif /* UCACHE_HAVE_SCALAR_WRITE */
+
+static zend_always_inline bool ucache_hdr_is_initialized_acquire(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ if (ucache_atomic_load_32(&hdr->magic) != UCACHE_MAGIC) {
+ return false;
+ }
+
+ ucache_atomic_fence_acquire();
+
+ return true;
+}
+
+#ifdef ZTS
+static inline bool ucache_zts_lock_init(ucache_storage *storage)
+{
+ storage->zts_lock = tsrm_mutex_alloc();
+
+ return storage->zts_lock != NULL;
+}
+
+static inline void ucache_zts_lock_destroy(ucache_storage *storage)
+{
+ tsrm_mutex_free(storage->zts_lock);
+ storage->zts_lock = NULL;
+}
+
+static inline bool ucache_zts_lock(ucache_storage *storage)
+{
+ return tsrm_mutex_lock(storage->zts_lock) == 0;
+}
+
+static inline void ucache_zts_unlock(ucache_storage *storage)
+{
+ tsrm_mutex_unlock(storage->zts_lock);
+}
+#else
+static inline bool ucache_zts_lock_init(ucache_storage *storage)
+{
+ (void) storage;
+
+ return true;
+}
+
+static inline void ucache_zts_lock_destroy(ucache_storage *storage)
+{
+ (void) storage;
+}
+
+static inline bool ucache_zts_lock(ucache_storage *storage)
+{
+ (void) storage;
+
+ return true;
+}
+
+static inline void ucache_zts_unlock(ucache_storage *storage)
+{
+ (void) storage;
+}
+#endif /* ZTS */
+
+#ifndef ZEND_WIN32
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+/* Waiters recheck periodically: a robust mutex handoff can lose its wakeup when processes are killed during it. */
+static int ucache_robust_mutex_lock(pthread_mutex_t *mutex)
+{
+ struct timespec deadline;
+ int result = pthread_mutex_trylock(mutex);
+
+ while (result == EBUSY || result == ETIMEDOUT) {
+ clock_gettime(UCACHE_ROBUST_MUTEX_CLOCK, &deadline);
+
+ deadline.tv_nsec += UCACHE_ROBUST_MUTEX_RECHECK_NSEC;
+ if (deadline.tv_nsec >= UCACHE_NSEC_PER_SEC) {
+ deadline.tv_sec++;
+ deadline.tv_nsec -= UCACHE_NSEC_PER_SEC;
+ }
+
+ result = UCACHE_ROBUST_MUTEX_LOCK_UNTIL(mutex, &deadline);
+ }
+
+ return result;
+}
+
+static uint64_t ucache_scalar_next_gen(ucache_hdr *hdr)
+{
+ uint64_t expected, desired;
+
+ for (;;) {
+ expected = ucache_atomic_load_64(&hdr->mutation_epoch);
+ desired = expected + 1;
+
+ if (desired == 0) {
+ desired = 1;
+ }
+
+ if (ucache_atomic_cas_64(&hdr->mutation_epoch, expected, desired)) {
+ return desired;
+ }
+ }
+}
+
+static void ucache_scalar_write_undo_if_pending(ucache_hdr *hdr, ucache_scalar_write_stripe *stripe)
+{
+ ucache_entry *entry;
+ uint64_t seq = ucache_atomic_load_64(&stripe->seq);
+
+ if ((seq & 1) == 0) {
+ return;
+ }
+
+ entry = &ucache_entries_ptr(hdr)[stripe->slot_idx];
+
+ ZEND_ASSERT(ucache_entry_holds_scalar(entry));
+
+ memcpy(&entry->long_val, &stripe->old_val, sizeof(stripe->old_val));
+
+ entry->flags = stripe->old_flags;
+ entry->gen = stripe->old_gen;
+
+ ZEND_ASSERT(ucache_entry_holds_scalar(entry));
+
+ (void) ucache_scalar_next_gen(hdr);
+ ucache_seq_publish(&stripe->seq, seq + 1);
+}
+
+#ifdef UCACHE_HAVE_SCALAR_WRITE
+static bool ucache_entry_lock_hash_present(ucache_hdr *hdr, zend_ulong hash)
+{
+ ucache_entry_lock_record *records = ucache_entry_lock_records_ptr(hdr);
+ uint32_t i, probe;
+
+ for (probe = 0; probe < hdr->entry_lock_capacity; probe++) {
+ i = (ucache_entry_lock_table_idx(hdr, hash) + probe) & (hdr->entry_lock_capacity - 1);
+
+ if (records[i].state == UCACHE_ENTRY_LOCK_EMPTY) {
+ return false;
+ }
+
+ if (records[i].state == UCACHE_ENTRY_LOCK_USED && records[i].hash == hash) {
+ return true;
+ }
+ }
+
+ return false;
+}
+#endif /* UCACHE_HAVE_SCALAR_WRITE */
+
+static zend_never_inline bool ucache_quiesce_scalar_writers(ucache_hdr *hdr)
+{
+ ucache_scalar_write_stripe *stripe;
+ uint32_t i;
+ int result;
+
+ ucache_atomic_store_32(&hdr->scalar_write_gate, 1);
+ ucache_atomic_fence_seq_cst();
+ for (i = 0; i < UCACHE_SCALAR_WRITE_STRIPES; i++) {
+ stripe = &hdr->scalar_write_stripes[i];
+ if (ucache_atomic_load_32(&stripe->active) == 0 &&
+ (ucache_atomic_load_64(&stripe->seq) & 1) == 0
+ ) {
+ continue;
+ }
+
+ result = ucache_robust_mutex_lock(&stripe->mutex.mutex);
+ if (result == EOWNERDEAD) {
+ result = pthread_mutex_consistent(&stripe->mutex.mutex);
+ if (result != 0) {
+ pthread_mutex_unlock(&stripe->mutex.mutex);
+ }
+ }
+
+ if (result != 0) {
+ return false;
+ }
+
+ ucache_scalar_write_undo_if_pending(hdr, stripe);
+ ucache_atomic_store_32(&stripe->active, 0);
+
+ pthread_mutex_unlock(&stripe->mutex.mutex);
+ }
+
+ return true;
+}
+
+static bool ucache_shared_mutex_lock(ucache_storage *storage)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ int result;
+
+ (void) storage;
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ result = ucache_robust_mutex_lock(&hdr->global_shared_mutex.mutex);
+ if (result == EOWNERDEAD) {
+ if (pthread_mutex_consistent(&hdr->global_shared_mutex.mutex) != 0) {
+ pthread_mutex_unlock(&hdr->global_shared_mutex.mutex);
+
+ return false;
+ }
+
+ result = 0;
+ }
+
+ if (result != 0) {
+ return false;
+ }
+
+ if (UCACHE_OPTIMISTIC_ENABLED &&
+ UNEXPECTED(ucache_atomic_load_32(&hdr->scalar_write_enabled) != 0) &&
+ !ucache_quiesce_scalar_writers(hdr)
+ ) {
+ ucache_atomic_store_32(&hdr->scalar_write_gate, 0);
+ pthread_mutex_unlock(&hdr->global_shared_mutex.mutex);
+
+ return false;
+ }
+
+ UC_G(lock_held) = true;
+
+ return true;
+}
+
+static void ucache_shared_mutex_unlock(ucache_storage *storage)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ (void) storage;
+
+ if (hdr != NULL) {
+ if (ucache_atomic_load_32(&hdr->scalar_write_enabled) != 0) {
+ ucache_atomic_store_32(&hdr->scalar_write_gate, 0);
+ }
+
+ pthread_mutex_unlock(&hdr->global_shared_mutex.mutex);
+ }
+
+ UC_G(lock_held) = false;
+}
+#endif /* UCACHE_HAVE_SHARED_MUTEX */
+
+static bool ucache_fcntl_lock(ucache_storage *storage, short lock_type)
+{
+ struct flock mem_lock;
+
+ if (!ucache_zts_lock(storage)) {
+ return false;
+ }
+
+ mem_lock.l_type = lock_type;
+ mem_lock.l_whence = SEEK_SET;
+ mem_lock.l_start = 0;
+ mem_lock.l_len = 1;
+
+ while (fcntl(storage->lock_file, F_SETLKW, &mem_lock) == -1) {
+ if (errno == EDEADLK) {
+ ucache_platform.sleep_us(UCACHE_FCNTL_DEADLOCK_RETRY_US);
+ } else if (errno != EINTR) {
+ ucache_zts_unlock(storage);
+
+ return false;
+ }
+ }
+
+ UC_G(lock_held) = true;
+
+ return true;
+}
+
+static bool ucache_fcntl_rlock(ucache_storage *storage)
+{
+ return ucache_fcntl_lock(storage, F_RDLCK);
+}
+
+static bool ucache_fcntl_wlock(ucache_storage *storage)
+{
+ return ucache_fcntl_lock(storage, F_WRLCK);
+}
+
+static void ucache_fcntl_unlock(ucache_storage *storage)
+{
+ struct flock mem_unlock;
+
+ mem_unlock.l_type = F_UNLCK;
+ mem_unlock.l_whence = SEEK_SET;
+ mem_unlock.l_start = 0;
+ mem_unlock.l_len = 1;
+
+ fcntl(storage->lock_file, F_SETLK, &mem_unlock);
+
+ ucache_zts_unlock(storage);
+
+ UC_G(lock_held) = false;
+}
+#else
+static bool ucache_win32_open_lock_file_at(
+ ucache_storage *storage,
+ const char *dir,
+ const char *base_name,
+ bool delete_on_close)
+{
+ size_t dir_len;
+ const char *sep;
+ char lockfile_name[MAXPATHLEN];
+
+ if (dir == NULL || dir[0] == '\0') {
+ return false;
+ }
+
+ dir_len = strlen(dir);
+ sep = dir[dir_len - 1] == '/' || dir[dir_len - 1] == '\\'
+ ? ""
+ : "/"
+ ;
+
+ snprintf(
+ lockfile_name,
+ sizeof(lockfile_name),
+ "%s%s%s.lock",
+ dir,
+ sep,
+ base_name
+ );
+
+ storage->lock_file = php_win32_ioutil_open(
+ lockfile_name,
+ O_RDWR | O_CREAT | O_BINARY | (delete_on_close ? _O_TEMPORARY | O_EXCL : 0),
+ 0666
+ );
+ if (storage->lock_file < 0) {
+ return false;
+ }
+
+ if (!ucache_win32_owned_by_cur_user((HANDLE) _get_osfhandle(storage->lock_file), SE_FILE_OBJECT)) {
+ php_win32_ioutil_close(storage->lock_file);
+ storage->lock_file = -1;
+
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_win32_open_lock_file(ucache_ctx *ctx)
+{
+ const char *err_in;
+ ucache_storage *storage = &ctx->storage;
+ DWORD tmp_path_w_len;
+ wchar_t tmp_path_w[MAXPATHLEN];
+ size_t tmp_path_len;
+ char base_name[MAXPATHLEN], *tmp_path, *p;
+ bool opened;
+
+ if (!ucache_win32_ctx_is_proc_private(ctx) && !ucache_win32_load_salt(&err_in)) {
+ return false;
+ }
+
+ ucache_win32_format_name(
+ base_name,
+ sizeof(base_name),
+ UCACHE_WIN32_LOCK_FILE_NAME,
+ storage->size
+ );
+
+ for (p = base_name; *p != '\0'; p++) {
+ if ((uint8_t) *p < 32 || strchr("<>:\"/\\|?*", *p) != NULL) {
+ *p = '_';
+ }
+ }
+
+ tmp_path_w_len = GetTempPathW(MAXPATHLEN, tmp_path_w);
+ if (tmp_path_w_len == 0 || tmp_path_w_len >= MAXPATHLEN) {
+ return false;
+ }
+
+ tmp_path = php_win32_ioutil_conv_w_to_any(tmp_path_w, tmp_path_w_len, &tmp_path_len);
+ if (tmp_path == NULL) {
+ return false;
+ }
+
+ opened = ucache_win32_open_lock_file_at(
+ storage,
+ tmp_path,
+ base_name,
+ ucache_win32_ctx_is_proc_private(ctx)
+ );
+
+ free(tmp_path);
+
+ return opened;
+}
+
+static bool ucache_win32_lock_range(ucache_storage *storage, bool exclusive)
+{
+ OVERLAPPED overlapped;
+ HANDLE handle;
+
+ if (!storage->lock_initialized || storage->lock_file < 0) {
+ return false;
+ }
+
+ handle = (HANDLE) _get_osfhandle(storage->lock_file);
+ if (handle == INVALID_HANDLE_VALUE) {
+ return false;
+ }
+
+ memset(&overlapped, 0, sizeof(overlapped));
+
+ return LockFileEx(handle, exclusive ? LOCKFILE_EXCLUSIVE_LOCK : 0, 0, 1, 0, &overlapped) == TRUE;
+}
+
+static void ucache_win32_unlock_range(ucache_storage *storage)
+{
+ OVERLAPPED overlapped;
+ HANDLE handle;
+
+ if (!storage->lock_initialized || storage->lock_file < 0) {
+ return;
+ }
+
+ handle = (HANDLE) _get_osfhandle(storage->lock_file);
+ if (handle == INVALID_HANDLE_VALUE) {
+ return;
+ }
+
+ memset(&overlapped, 0, sizeof(overlapped));
+
+ UnlockFileEx(handle, 0, 1, 0, &overlapped);
+}
+
+static bool ucache_win32_lock(ucache_storage *storage, bool exclusive)
+{
+ if (!ucache_zts_lock(storage)) {
+ return false;
+ }
+
+ if (!ucache_win32_lock_range(storage, exclusive)) {
+ ucache_zts_unlock(storage);
+
+ return false;
+ }
+
+ UC_G(lock_held) = true;
+
+ return true;
+}
+
+static bool ucache_win32_rlock(ucache_storage *storage)
+{
+ return ucache_win32_lock(storage, false);
+}
+
+static bool ucache_win32_wlock(ucache_storage *storage)
+{
+ return ucache_win32_lock(storage, true);
+}
+
+static void ucache_win32_unlock(ucache_storage *storage)
+{
+ ucache_win32_unlock_range(storage);
+
+ ucache_zts_unlock(storage);
+
+ UC_G(lock_held) = false;
+}
+#endif /* ZEND_WIN32 */
+
+static bool ucache_rlock_impl(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ ZEND_ASSERT(!UC_G(lock_held) && !UC_G(write_seq_bumped));
+
+ return !UC_G(lock_held) && storage->lock_initialized && storage->lock_ops->rlock(storage);
+}
+
+static bool ucache_wlock_impl(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ ZEND_ASSERT(!UC_G(lock_held) && !UC_G(write_seq_bumped));
+
+ return !UC_G(lock_held) && storage->lock_initialized && storage->lock_ops->wlock(storage);
+}
+
+static void ucache_unlock_impl(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (storage->lock_initialized) {
+ storage->lock_ops->unlock(storage);
+ }
+}
+
+static bool ucache_wlock_recovered(void)
+{
+ if (!ucache_wlock_impl()) {
+ return false;
+ }
+
+ UC_G(lock_held_is_write) = true;
+
+ if (!ucache_recover_after_owner_death_locked()) {
+ UC_G(lock_held_is_write) = false;
+
+ ucache_unlock_impl();
+
+ return false;
+ }
+
+ return true;
+}
+
+static void ucache_write_section_enter(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ ZEND_ASSERT(UC_G(lock_held_is_write) && !UC_G(write_seq_bumped));
+
+ if (hdr == NULL ||
+ !ucache_hdr_is_initialized_locked()
+ ) {
+ return;
+ }
+
+ ucache_seq_announce(&hdr->write_seq, hdr->write_seq + 1);
+
+ UC_G(write_seq_bumped) = true;
+ UC_G(reader_drain_state) = 0;
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_IN_WRITE_SECTION");
+}
+
+static void ucache_write_section_leave(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+
+ if (UC_G(entry_lock_table_section_open)) {
+ UC_G(entry_lock_table_section_open) = false;
+
+ if (hdr != NULL) {
+ hdr->entry_lock_table_section_open = 0;
+ }
+ }
+
+ if (!UC_G(write_seq_bumped)) {
+ return;
+ }
+
+ UC_G(write_seq_bumped) = false;
+
+ if (hdr == NULL) {
+ return;
+ }
+
+ ucache_seq_publish(&hdr->write_seq, hdr->write_seq + 1);
+}
+
+static bool ucache_wlock_for_entry_mutations_within(
+ zend_string **keys,
+ uint32_t count,
+ uint64_t wait_budget_us)
+{
+ ucache_entry_lock *local_lock;
+ ucache_entry_lock_record *record;
+ ucache_entry_lock_holder holder;
+ ucache_hdr *hdr;
+ uint64_t waited_us = 0, now;
+ uint32_t i, slot_idx;
+ bool found;
+
+ ucache_ensure_entry_lock_owner();
+ ucache_drain_deferred_entry_lock_releases();
+
+ for (;;) {
+ if (!ucache_wlock()) {
+ return false;
+ }
+
+ if (!ucache_hdr_init_locked()) {
+ ucache_unlock();
+
+ return false;
+ }
+
+ hdr = ucache_hdr_ptr();
+ if (hdr->entry_lock_count != 0 &&
+ ++UC_G(entry_lock_sweep_ops) >= UCACHE_ENTRY_LOCK_SWEEP_OP_INTERVAL
+ ) {
+ UC_G(entry_lock_sweep_ops) = 0;
+
+ now = ucache_time_rel(hdr, ucache_clock_now());
+ if (now >= hdr->entry_lock_sweep_at) {
+ ucache_sweep_entry_locks_locked(hdr, now);
+ }
+ }
+
+ if (hdr->entry_lock_count == 0) {
+ return true;
+ }
+
+ for (i = 0; i < count; i++) {
+ if (
+ !ucache_find_entry_lock_record_slot_locked(
+ hdr,
+ keys[i],
+ zend_string_hash_val(keys[i]),
+ &slot_idx,
+ &found
+ ) || !found
+ ) {
+ continue;
+ }
+
+ record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+ local_lock = ucache_find_local_entry_lock(
+ ucache_active_ctx(),
+ keys[i]
+ );
+
+ if (local_lock == NULL ||
+ record->owner_pid != local_lock->owner_pid ||
+ record->owner_token != local_lock->owner_token
+ ) {
+ ucache_entry_lock_holder_capture(&holder, hdr, slot_idx);
+
+ break;
+ }
+ }
+
+ if (i == count) {
+ return true;
+ }
+
+ ucache_unlock();
+
+ if (waited_us >= wait_budget_us) {
+ return false;
+ }
+
+ do {
+ waited_us += ucache_sleep_entry_lock_retry_interval(waited_us);
+ } while (waited_us < wait_budget_us &&
+ ucache_entry_lock_holder_unchanged(&ucache_active_ctx()->storage, &holder)
+ );
+ }
+}
+
+#ifndef ZEND_WIN32
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+bool ucache_shared_mutex_init(ucache_hdr *hdr)
+{
+ pthread_mutexattr_t attr;
+ uint32_t i;
+ bool result;
+
+ if (UCACHE_DEBUG_FAULT("FORCE_FCNTL_LOCK_MODEL")) {
+ return false;
+ }
+
+ if (pthread_mutexattr_init(&attr) != 0) {
+ return false;
+ }
+
+ result = pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED) == 0 &&
+ pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST) == 0 &&
+ pthread_mutex_init(&hdr->global_shared_mutex.mutex, &attr) == 0
+ ;
+ if (result) {
+ for (i = 0; i < UCACHE_SCALAR_WRITE_STRIPES; i++) {
+ if (pthread_mutex_init(&hdr->scalar_write_stripes[i].mutex.mutex, &attr) != 0) {
+ break;
+ }
+ }
+
+ if (i == UCACHE_SCALAR_WRITE_STRIPES) {
+ hdr->scalar_write_ready = 1;
+ } else {
+ while (i != 0) {
+ pthread_mutex_destroy(&hdr->scalar_write_stripes[--i].mutex.mutex);
+ }
+ }
+ }
+
+ pthread_mutexattr_destroy(&attr);
+
+ return result;
+}
+#endif /* UCACHE_HAVE_SHARED_MUTEX */
+
+const ucache_lock_ops *ucache_lock_ops_for_model(uint32_t lock_model)
+{
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+ if (lock_model == UCACHE_LOCK_MODEL_MUTEX) {
+ return &ucache_shared_mutex_lock_ops;
+ }
+#else
+ (void) lock_model;
+#endif
+
+ return &ucache_fcntl_lock_ops;
+}
+
+bool ucache_create_lock(void)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_storage *storage = &ctx->storage;
+ int val;
+ char lockfile_name[MAXPATHLEN];
+
+ if (storage->lock_initialized) {
+ return true;
+ }
+
+ if (!ucache_zts_lock_init(storage)) {
+ return false;
+ }
+
+ storage->lock_ops = &ucache_fcntl_lock_ops;
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ if (ucache_ctx_is_boundary(ctx)) {
+ storage->lock_file = fcntl(ucache_boundary_seg_of(storage)->lock_fd, F_DUPFD_CLOEXEC, 0);
+ if (storage->lock_file < 0) {
+ ucache_zts_lock_destroy(storage);
+
+ return false;
+ }
+
+ storage->lock_initialized = true;
+
+ return true;
+ }
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+
+#if defined(__linux__) && defined(HAVE_MEMFD_CREATE) && defined(MFD_CLOEXEC)
+ storage->lock_file = memfd_create(ctx->lock_name, MFD_CLOEXEC);
+ if (storage->lock_file >= 0) {
+ storage->lock_initialized = true;
+
+ return true;
+ }
+#endif
+
+#ifdef O_TMPFILE
+ storage->lock_file = open(UC_G(lockfile_path), O_RDWR | O_TMPFILE | O_EXCL | O_CLOEXEC, 0666);
+ if (storage->lock_file >= 0) {
+ storage->lock_initialized = true;
+
+ return true;
+ }
+#endif
+
+ snprintf(
+ lockfile_name,
+ sizeof(lockfile_name),
+ "%s/" UCACHE_SEM_FILENAME_PREFIX "XXXXXX",
+ UC_G(lockfile_path)
+ );
+
+ storage->lock_file = mkstemp(lockfile_name);
+ if (storage->lock_file == -1) {
+ ucache_zts_lock_destroy(storage);
+
+ return false;
+ }
+
+ val = fcntl(storage->lock_file, F_GETFD, 0);
+ val |= FD_CLOEXEC;
+
+ fcntl(storage->lock_file, F_SETFD, val);
+ unlink(lockfile_name);
+
+ storage->lock_initialized = true;
+
+ return true;
+}
+
+void ucache_destroy_lock(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (!storage->lock_initialized) {
+ return;
+ }
+
+ if (storage->lock_file >= 0) {
+ close(storage->lock_file);
+ storage->lock_file = -1;
+ }
+
+ ucache_zts_lock_destroy(storage);
+
+ storage->lock_initialized = false;
+}
+#else
+const ucache_lock_ops *ucache_lock_ops_for_model(uint32_t lock_model)
+{
+ (void) lock_model;
+
+ return &ucache_win32_lock_ops;
+}
+
+bool ucache_create_lock(void)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_storage *storage = &ctx->storage;
+
+ if (storage->lock_initialized) {
+ return true;
+ }
+
+ if (!ucache_zts_lock_init(storage)) {
+ return false;
+ }
+
+ storage->lock_ops = &ucache_win32_lock_ops;
+
+ if (!ucache_win32_open_lock_file(ctx)) {
+ ucache_zts_lock_destroy(storage);
+
+ return false;
+ }
+
+ storage->lock_initialized = true;
+
+ return true;
+}
+
+void ucache_destroy_lock(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (!storage->lock_initialized) {
+ return;
+ }
+
+ if (storage->lock_file >= 0) {
+ php_win32_ioutil_close(storage->lock_file);
+ storage->lock_file = -1;
+ }
+
+ ucache_zts_lock_destroy(storage);
+
+ storage->lock_initialized = false;
+}
+#endif /* ZEND_WIN32 */
+
+bool ucache_wlock_entry_lock_table(void)
+{
+ ucache_hdr *hdr;
+
+ if (!ucache_wlock_recovered()) {
+ return false;
+ }
+
+ hdr = ucache_hdr_ptr();
+ if (hdr != NULL && ucache_hdr_is_initialized_locked()) {
+ hdr->entry_lock_table_section_open = 1;
+
+ UC_G(entry_lock_table_section_open) = true;
+ }
+
+ return true;
+}
+
+void ucache_write_section_announce(ucache_hdr *hdr)
+{
+ if (!UC_G(lock_held_is_write) || UC_G(write_seq_bumped)) {
+ return;
+ }
+
+ ucache_seq_announce(&hdr->write_seq, hdr->write_seq + 1);
+
+ UC_G(write_seq_bumped) = true;
+ UC_G(reader_drain_state) = 0;
+}
+
+bool ucache_wlock_negotiated_lock_model(void)
+{
+ const ucache_lock_ops *negotiated_ops;
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (!ucache_wlock_impl()) {
+ return false;
+ }
+
+ negotiated_ops = ucache_hdr_is_initialized_acquire()
+ ? ucache_lock_ops_for_model(ucache_hdr_ptr()->lock_model)
+ : storage->lock_ops
+ ;
+
+ if (negotiated_ops != storage->lock_ops) {
+ ucache_unlock_impl();
+
+ storage->lock_ops = negotiated_ops;
+
+ if (!ucache_wlock_impl()) {
+ return false;
+ }
+ }
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ if (ucache_hdr_is_initialized_acquire() &&
+ ucache_hdr_boundary_lock_file_replaced_locked(ucache_hdr_ptr())
+ ) {
+ ucache_unlock_impl();
+
+ ucache_shared_boundary_retire_seg_name();
+
+ return false;
+ }
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+
+ UC_G(lock_held_is_write) = true;
+
+ if (!ucache_recover_after_owner_death_locked()) {
+ UC_G(lock_held_is_write) = false;
+
+ ucache_unlock_impl();
+
+ return false;
+ }
+
+ ucache_write_section_enter();
+
+ return true;
+}
+
+const char *ucache_lock_model_name(const ucache_storage *storage)
+{
+ return storage->lock_initialized && storage->lock_ops != NULL ? storage->lock_ops->name : "none";
+}
+
+bool ucache_rlock(void)
+{
+ ucache_hdr *hdr;
+ uint32_t recovery_attempts = 0;
+
+ for (;;) {
+ if (!ucache_rlock_impl()) {
+ return false;
+ }
+
+ UC_G(lock_held_is_write) = false;
+
+ hdr = ucache_hdr_ptr();
+ if (!UNEXPECTED(
+ hdr != NULL &&
+ ucache_hdr_is_initialized_locked() &&
+ (
+ (ucache_atomic_load_64(&hdr->write_seq) & 1) != 0 ||
+ hdr->entry_lock_table_section_open != 0
+ )
+ )
+ ) {
+ return true;
+ }
+
+ ucache_unlock();
+
+ if (++recovery_attempts > 8) {
+ return false;
+ }
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_BEFORE_READ_LOCK_RECOVERY");
+
+ if (!ucache_wlock_recovered()) {
+ return false;
+ }
+
+ ucache_unlock();
+ }
+}
+
+bool ucache_wlock(void)
+{
+ if (!ucache_wlock_recovered()) {
+ return false;
+ }
+
+ ucache_write_section_enter();
+
+ return true;
+}
+
+bool ucache_wlock_for_ref_release(bool *recovered)
+{
+ if (!ucache_wlock_impl()) {
+ return false;
+ }
+
+ UC_G(lock_held_is_write) = true;
+
+ if (ucache_recover_after_owner_death_locked()) {
+ *recovered = true;
+
+ ucache_write_section_enter();
+
+ return true;
+ }
+
+ *recovered = false;
+
+ return true;
+}
+
+bool ucache_rlock_for_pin_adoption(void)
+{
+ if (!ucache_rlock_impl()) {
+ return false;
+ }
+
+ UC_G(lock_held_is_write) = false;
+
+ return true;
+}
+
+bool ucache_wlock_for_entry_mutations(zend_string **keys, uint32_t count)
+{
+ return ucache_wlock_for_entry_mutations_within(keys, count, ucache_entry_lock_wait_timeout_us());
+}
+
+bool ucache_wlock_for_entry_mutation(zend_string *key)
+{
+ return ucache_wlock_for_entry_mutations_within(&key, 1, ucache_entry_lock_wait_timeout_us());
+}
+
+bool ucache_try_wlock_for_entry_mutation(zend_string *key)
+{
+ return ucache_wlock_for_entry_mutations_within(&key, 1, 0);
+}
+
+#ifdef UCACHE_HAVE_SCALAR_WRITE
+void ucache_scalar_write_enable_locked(ucache_hdr *hdr)
+{
+ ZEND_ASSERT(UC_G(lock_held));
+
+ if (hdr->lock_model == UCACHE_LOCK_MODEL_MUTEX && hdr->scalar_write_ready &&
+ ucache_atomic_load_32(&hdr->scalar_write_enabled) == 0
+ ) {
+ ucache_atomic_store_32(&hdr->scalar_write_gate, 1);
+ ucache_atomic_store_32(&hdr->scalar_write_enabled, 1);
+ }
+}
+
+bool ucache_scalar_write_begin(zend_ulong hash, ucache_hdr **hdr_ptr, uint32_t *stripe_idx)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_scalar_write_stripe *stripe;
+ uint32_t idx;
+ int result;
+
+ if (UC_G(lock_held) || UC_G(scalar_write_hdr) != NULL ||
+ !ucache_hdr_is_initialized_acquire() ||
+ hdr->lock_model != UCACHE_LOCK_MODEL_MUTEX ||
+ ucache_atomic_load_32(&hdr->scalar_write_enabled) == 0
+ ) {
+ return false;
+ }
+
+ idx = ucache_scalar_write_idx(hash);
+ stripe = &hdr->scalar_write_stripes[idx];
+ result = ucache_robust_mutex_lock(&stripe->mutex.mutex);
+ if (result == EOWNERDEAD) {
+ result = pthread_mutex_consistent(&stripe->mutex.mutex);
+ if (result != 0) {
+ pthread_mutex_unlock(&stripe->mutex.mutex);
+ }
+ }
+
+ if (result != 0) {
+ return false;
+ }
+
+ ucache_atomic_store_32(&stripe->active, 1);
+ ucache_atomic_fence_seq_cst();
+
+ if (ucache_atomic_load_32(&hdr->scalar_write_gate) != 0 ||
+ (ucache_atomic_load_64(&hdr->write_seq) & 1) != 0 ||
+ ucache_atomic_load_32(&hdr->entry_lock_table_section_open) != 0
+ ) {
+ ucache_scalar_write_back_off(stripe);
+
+ return false;
+ }
+
+ ucache_scalar_write_undo_if_pending(hdr, stripe);
+
+ if (hdr->entry_lock_count != 0 && ucache_entry_lock_hash_present(hdr, hash)) {
+ ucache_scalar_write_back_off(stripe);
+
+ return false;
+ }
+
+ UC_G(scalar_write_hdr) = hdr;
+ UC_G(scalar_write_idx) = idx;
+
+ *hdr_ptr = hdr;
+ *stripe_idx = idx;
+
+ return true;
+}
+
+void ucache_scalar_write_prepare(ucache_hdr *hdr, uint32_t stripe_idx, uint32_t slot_idx)
+{
+ ucache_scalar_write_stripe *stripe = &hdr->scalar_write_stripes[stripe_idx];
+ ucache_entry *entry = &ucache_entries_ptr(hdr)[slot_idx];
+
+ stripe->slot_idx = slot_idx;
+
+ memcpy(&stripe->old_val, &entry->long_val, sizeof(stripe->old_val));
+
+ stripe->old_flags = entry->flags;
+ stripe->old_gen = entry->gen;
+
+ ucache_seq_announce(&stripe->seq, stripe->seq + 1);
+}
+
+void ucache_scalar_write_commit(ucache_hdr *hdr, uint32_t stripe_idx, ucache_entry *entry)
+{
+ ucache_scalar_write_stripe *stripe = &hdr->scalar_write_stripes[stripe_idx];
+
+ entry->gen = ucache_scalar_next_gen(hdr);
+
+ ucache_seq_publish(&stripe->seq, stripe->seq + 1);
+}
+
+void ucache_scalar_write_end(void)
+{
+ ucache_hdr *hdr = UC_G(scalar_write_hdr);
+ ucache_scalar_write_stripe *stripe;
+
+ if (hdr == NULL) {
+ return;
+ }
+
+ stripe = &hdr->scalar_write_stripes[UC_G(scalar_write_idx)];
+
+ ucache_scalar_write_undo_if_pending(hdr, stripe);
+
+ UC_G(scalar_write_hdr) = NULL;
+
+ ucache_atomic_store_32(&stripe->active, 0);
+
+ pthread_mutex_unlock(&stripe->mutex.mutex);
+}
+#else
+void ucache_scalar_write_enable_locked(ucache_hdr *hdr)
+{
+ (void) hdr;
+}
+
+bool ucache_scalar_write_begin(zend_ulong hash, ucache_hdr **hdr_ptr, uint32_t *stripe_idx)
+{
+ (void) hash;
+ (void) hdr_ptr;
+ (void) stripe_idx;
+
+ return false;
+}
+
+void ucache_scalar_write_prepare(ucache_hdr *hdr, uint32_t stripe_idx, uint32_t slot_idx)
+{
+ (void) hdr;
+ (void) stripe_idx;
+ (void) slot_idx;
+}
+
+void ucache_scalar_write_commit(ucache_hdr *hdr, uint32_t stripe_idx, ucache_entry *entry)
+{
+ (void) hdr;
+ (void) stripe_idx;
+ (void) entry;
+}
+
+void ucache_scalar_write_end(void)
+{
+}
+#endif /* UCACHE_HAVE_SCALAR_WRITE */
+
+void ucache_unlock(void)
+{
+ if (UC_G(lock_held_is_write)) {
+ ucache_write_section_leave();
+ }
+
+ UC_G(lock_held_is_write) = false;
+
+ ucache_unlock_impl();
+}
+
+void ucache_unlock_if_held(void)
+{
+ if (UC_G(scalar_write_hdr) != NULL) {
+ ucache_scalar_write_end();
+ }
+
+ if (UC_G(lock_held)) {
+ ucache_unlock();
+ }
+}
+
+uint32_t ucache_sleep_us(uint32_t interval_us)
+{
+ return ucache_platform.sleep_us(interval_us);
+}
diff --git a/ext/user_cache/user_cache_partition.c b/ext/user_cache/user_cache_partition.c
new file mode 100644
index 000000000000..6738c6a61441
--- /dev/null
+++ b/ext/user_cache/user_cache_partition.c
@@ -0,0 +1,1053 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_internal.h"
+
+#include "php_syslog.h"
+
+#define UCACHE_GRAPH_PIN_SLOT_GRANULARITY 64U
+#define UCACHE_GRAPH_PIN_SLOTS_PER_PROC 2U
+
+typedef struct _ucache_boundary_partition {
+ char *boundary;
+ size_t boundary_len;
+ zend_ulong boundary_hash;
+ php_ucache_partition *partition;
+ struct _ucache_boundary_partition *next;
+} ucache_boundary_partition;
+
+static ucache_boundary_partition *ucache_boundary_partitions = NULL;
+static uint64_t ucache_scope_seq;
+static uint32_t ucache_boundary_partition_count = 0;
+static bool ucache_boundary_creation_disabled = false;
+static atomic_bool ucache_boundary_startup_failed_logged = false;
+
+#ifdef ZTS
+MUTEX_T ucache_boundary_partitions_mutex = NULL;
+#endif
+php_ucache_partition *ucache_partitions = NULL;
+
+static bool ucache_init_partition_ctx(php_ucache_partition *partition, const char *name)
+{
+ ucache_ctx *ctx = &partition->ctx;
+
+ ctx->storage.lock_file = -1;
+ ctx->lock_name = ucache_ctx_state.lock_name;
+
+ if (name != NULL) {
+ partition->name = strdup(name);
+ if (partition->name == NULL) {
+ return false;
+ }
+
+ ctx->lock_name = partition->name;
+ }
+
+ return true;
+}
+
+static php_ucache_partition *ucache_partition_alloc(const char *name)
+{
+ php_ucache_partition *partition = calloc(1, sizeof(php_ucache_partition));
+
+ if (partition == NULL) {
+ return NULL;
+ }
+
+ if (!ucache_init_partition_ctx(partition, name)) {
+ free(partition);
+
+ return NULL;
+ }
+
+ return partition;
+}
+
+static void ucache_partition_link(php_ucache_partition *partition)
+{
+ partition->next = ucache_partitions;
+ ucache_partitions = partition;
+}
+
+static bool ucache_startup_storage_for_ctx(ucache_ctx *ctx)
+{
+ ucache_ctx *prev_ctx;
+
+ prev_ctx = ucache_activate_ctx(ctx);
+
+ ucache_reset_runtime();
+ if (ucache_active_runtime()->enabled && UC_G(enable)) {
+ if (!ucache_startup_storage_before_req()) {
+ ucache_restore_ctx(prev_ctx);
+
+ return false;
+ }
+ }
+
+ ucache_restore_ctx(prev_ctx);
+
+ return true;
+}
+
+static bool ucache_format_boundary_key_prefix(char *prefix, size_t prefix_size, size_t *prefix_len)
+{
+#if !defined(ZEND_WIN32) && defined(HAVE_UNISTD_H)
+ int n;
+
+ n = snprintf(
+ prefix,
+ prefix_size,
+ "uid:%ld:gid:%ld:",
+ (long) geteuid(),
+ (long) getegid()
+ );
+
+ if (n < 0 || (size_t) n >= prefix_size) {
+ return false;
+ }
+
+ *prefix_len = (size_t) n;
+#else
+ prefix[0] = '\0';
+ *prefix_len = 0;
+ (void) prefix_size;
+#endif
+
+ return true;
+}
+
+static char *ucache_build_boundary_key(
+ const char *boundary,
+ size_t boundary_len,
+ size_t *key_len)
+{
+ size_t prefix_len;
+ char *boundary_key, prefix[64];
+
+ if (!ucache_format_boundary_key_prefix(prefix, sizeof(prefix), &prefix_len)) {
+ return NULL;
+ }
+
+ if (boundary_len > SIZE_MAX - prefix_len - 1) {
+ return NULL;
+ }
+
+ boundary_key = malloc(prefix_len + boundary_len + 1);
+ if (boundary_key == NULL) {
+ return NULL;
+ }
+
+ memcpy(boundary_key, prefix, prefix_len);
+ memcpy(boundary_key + prefix_len, boundary, boundary_len);
+
+ boundary_key[prefix_len + boundary_len] = '\0';
+
+ *key_len = prefix_len + boundary_len;
+
+ return boundary_key;
+}
+
+static ucache_boundary_partition *ucache_find_boundary_partition(
+ const char *boundary,
+ size_t boundary_len,
+ zend_ulong boundary_hash)
+{
+ ucache_boundary_partition *entry;
+
+ for (entry = ucache_boundary_partitions; entry != NULL; entry = entry->next) {
+ if (entry->boundary_hash == boundary_hash &&
+ entry->boundary_len == boundary_len &&
+ memcmp(entry->boundary, boundary, boundary_len) == 0
+ ) {
+ return entry;
+ }
+ }
+
+ return NULL;
+}
+
+static void ucache_log_boundary_partition_limit(void)
+{
+ char limit_msg[256];
+
+ if (!php_ucache_is_enabled_by_ini()) {
+ return;
+ }
+
+ snprintf(
+ limit_msg,
+ sizeof(limit_msg),
+ "UserCache: boundary partition limit (%u) reached; creation of new partitions has been disabled "
+ "for this process; existing partitions remain available",
+ UCACHE_MAX_BOUNDARY_PARTITIONS
+ );
+
+ ucache_log_err(limit_msg);
+}
+
+static ucache_boundary_partition *ucache_create_boundary_partition(
+ const char *sapi_prefix,
+ const char *boundary,
+ size_t boundary_len,
+ zend_ulong boundary_hash,
+ bool *limit_reached)
+{
+ ucache_boundary_partition *entry;
+ char partition_name[128];
+
+ if (ucache_boundary_creation_disabled) {
+ return NULL;
+ }
+
+ if (ucache_boundary_partition_count >= UCACHE_MAX_BOUNDARY_PARTITIONS) {
+ ucache_boundary_creation_disabled = true;
+
+ *limit_reached = true;
+
+ return NULL;
+ }
+
+ entry = calloc(1, sizeof(*entry));
+ if (entry == NULL) {
+ return NULL;
+ }
+
+ entry->boundary = malloc(boundary_len + 1);
+ if (entry->boundary == NULL) {
+ free(entry);
+
+ return NULL;
+ }
+
+ memcpy(entry->boundary, boundary, boundary_len);
+
+ entry->boundary[boundary_len] = '\0';
+ entry->boundary_len = boundary_len;
+ entry->boundary_hash = boundary_hash;
+
+ snprintf(
+ partition_name,
+ sizeof(partition_name),
+ "%s:boundary:" ZEND_ULONG_FMT ":" ZEND_ULONG_FMT ":%zx",
+ sapi_prefix,
+ boundary_hash,
+ boundary_len > 1 ? zend_inline_hash_func(boundary + 1, boundary_len - 1) : boundary_hash,
+ boundary_len
+ );
+
+ entry->partition = ucache_partition_alloc(partition_name);
+ if (entry->partition == NULL) {
+ free(entry->boundary);
+ free(entry);
+
+ return NULL;
+ }
+
+ ucache_partition_link(entry->partition);
+
+ entry->partition->ctx.boundary_identity = entry->boundary;
+ entry->partition->ctx.boundary_identity_len = entry->boundary_len;
+
+ entry->next = ucache_boundary_partitions;
+
+ ucache_boundary_partitions = entry;
+ ucache_boundary_partition_count++;
+
+ return entry;
+}
+
+static void ucache_activate_req_unavailable(php_ucache_reason reason)
+{
+ UC_G(active_partition) = NULL;
+ UC_G(active_ctx_ptr) = NULL;
+ UC_G(req_unavailable_reason) = reason;
+ UC_G(runtime_resolved) = false;
+}
+
+static php_ucache_partition *ucache_boundary_partition_get(
+ const char *sapi_prefix,
+ const char *boundary,
+ size_t supplied_boundary_len)
+{
+ ucache_boundary_partition *entry;
+ zend_ulong boundary_hash;
+ size_t boundary_len;
+ char *boundary_key;
+ bool limit_reached = false;
+
+ boundary_key = ucache_build_boundary_key(boundary, supplied_boundary_len, &boundary_len);
+ if (boundary_key == NULL) {
+ return NULL;
+ }
+
+ boundary_hash = zend_inline_hash_func(boundary_key, boundary_len);
+
+ ucache_boundary_partitions_lock();
+
+ entry = ucache_find_boundary_partition(boundary_key, boundary_len, boundary_hash);
+ if (entry == NULL) {
+ entry = ucache_create_boundary_partition(
+ sapi_prefix,
+ boundary_key,
+ boundary_len,
+ boundary_hash,
+ &limit_reached
+ );
+ if (entry == NULL) {
+ ucache_boundary_partitions_unlock();
+
+ free(boundary_key);
+
+ if (limit_reached) {
+ ucache_log_boundary_partition_limit();
+ }
+
+ return NULL;
+ }
+ }
+
+ ucache_boundary_partitions_unlock();
+
+ free(boundary_key);
+
+ return entry->partition;
+}
+
+static bool ucache_boundary_partition_startup_storage(php_ucache_partition *partition)
+{
+ if (php_ucache_partition_startup_storage(partition)) {
+ return true;
+ }
+
+ if (php_ucache_is_enabled_by_ini() &&
+ !atomic_exchange(&ucache_boundary_startup_failed_logged, true)
+ ) {
+ ucache_log_err("UserCache: partition startup failed; UserCache will be unavailable");
+ }
+
+ return false;
+}
+
+static bool ucache_exec_is_live(void)
+{
+ return UC_G(exec_active) || EG(active) || UC_G(op_depth) != 0;
+}
+
+#ifndef ZEND_WIN32
+static bool ucache_normalize_absolute_path(const char *path, char *normalized)
+{
+ const char *component, *end;
+ size_t len = 0, size;
+
+ if (path[0] != '/') {
+ return false;
+ }
+
+ for (component = path; *component != '\0'; component = end) {
+ while (*component == '/') {
+ component++;
+ }
+
+ for (end = component; *end != '\0' && *end != '/'; end++);
+
+ size = (size_t) (end - component);
+ if (size == 0 || (size == 1 && component[0] == '.')) {
+ continue;
+ }
+
+ if (size == 2 && component[0] == '.' && component[1] == '.') {
+ while (len > 0 && normalized[len - 1] != '/') {
+ len--;
+ }
+
+ if (len > 0) {
+ len--;
+ }
+
+ continue;
+ }
+
+ if (size >= MAXPATHLEN - 1 - len) {
+ return false;
+ }
+
+ normalized[len++] = '/';
+ memcpy(normalized + len, component, size);
+ len += size;
+ }
+
+ if (len == 0) {
+ normalized[len++] = '/';
+ }
+
+ normalized[len] = '\0';
+
+ return true;
+}
+#endif
+
+static bool ucache_resolve_boundary_doc_root(const char *doc_root, char *resolved)
+{
+#ifdef ZEND_WIN32
+ DWORD attributes;
+ size_t len;
+#else
+ struct stat st;
+#endif
+
+ if (doc_root == NULL || doc_root[0] == '\0') {
+ return false;
+ }
+
+#ifdef ZEND_WIN32
+ if (_fullpath(resolved, doc_root, MAXPATHLEN) == NULL) {
+ return false;
+ }
+
+ for (len = strlen(resolved); len > 3 && (resolved[len - 1] == '\\' || resolved[len - 1] == '/'); len--) {
+ resolved[len - 1] = '\0';
+ }
+
+ attributes = GetFileAttributesA(resolved);
+
+ return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
+#else
+ return ucache_normalize_absolute_path(doc_root, resolved) &&
+ stat(resolved, &st) == 0 &&
+ S_ISDIR(st.st_mode)
+ ;
+#endif
+}
+
+static bool ucache_partition_switch_is_forbidden(void)
+{
+ return UC_G(persistent_exec) &&
+ (!UC_G(in_req_shutdown) || UC_G(exec_poisoned)) &&
+ (UC_G(exec_active) || EG(active))
+ ;
+}
+
+static void ucache_poison_exec(void)
+{
+ UC_G(exec_poisoned) = true;
+ UC_G(in_req_shutdown) = true;
+ UC_G(runtime_resolved) = false;
+}
+
+static void ucache_forget_unwound_ops(void)
+{
+ if (EG(current_execute_data) == NULL) {
+ UC_G(op_depth) = 0;
+ }
+}
+
+static bool ucache_logical_boundary_is_idle(void)
+{
+ return UC_G(op_depth) == 0 &&
+ !UC_G(lock_held) &&
+ UC_G(owned_decode_frame) == NULL &&
+ UC_G(decode_restore_queue) == NULL
+ ;
+}
+
+static zend_result ucache_finish_logical_lock_scope(void)
+{
+ UC_G(logical_req_ending) = true;
+ UC_G(runtime_resolved) = false;
+
+ zend_try {
+ ucache_release_req_entry_locks();
+ ucache_retry_deferred_entry_lock_releases();
+ } zend_catch {
+ ucache_poison_exec();
+ zend_bailout();
+ } zend_end_try();
+
+ if (UC_G(entry_lock_table) != NULL || ucache_active_ctx_has_deferred_entry_lock_releases()) {
+ ucache_poison_exec();
+
+ return FAILURE;
+ }
+
+ UC_G(logical_req_ending) = false;
+ UC_G(runtime_resolved) = false;
+ UC_G(access_now) = 0;
+ UC_G(access_now_touches) = 0;
+ UC_G(graph_pin_claim_failed_hdr) = NULL;
+ UC_G(reader_claim_failed_hdr) = NULL;
+
+ return SUCCESS;
+}
+
+static bool ucache_is_before_first_req(void)
+{
+ return !php_during_module_startup() && !atomic_load(&ucache_registration_closed);
+}
+
+static ZEND_COLD void ucache_sapi_log_warning(const char *format, va_list args)
+{
+ zend_string *msg, *line;
+
+ if (!PG(log_errors) || !(EG(error_reporting) & E_WARNING) || sapi_module.log_message == NULL) {
+ return;
+ }
+
+ msg = zend_vstrpprintf(0, format, args);
+ line = zend_strpprintf(0, "PHP Warning: %s", ZSTR_VAL(msg));
+
+ sapi_module.log_message(ZSTR_VAL(line), LOG_WARNING);
+
+ zend_string_release_ex(line, false);
+ zend_string_release_ex(msg, false);
+}
+
+void ucache_partitions_shutdown(void)
+{
+ php_ucache_partition *partition, *next;
+ ucache_ctx *prev_ctx;
+
+ prev_ctx = UC_G(active_ctx_ptr);
+
+ partition = ucache_partitions;
+ while (partition != NULL) {
+ next = partition->next;
+
+ ucache_activate_ctx(&partition->ctx);
+ ucache_shutdown_storage();
+ ucache_reset_runtime();
+
+ free(partition->name);
+ free(partition);
+
+ partition = next;
+ }
+
+ ucache_partitions = NULL;
+
+ ucache_restore_ctx(prev_ctx);
+}
+
+void ucache_boundary_partitions_shutdown(void)
+{
+ ucache_boundary_partition *entry, *next;
+
+ ucache_boundary_partitions_lock();
+
+ entry = ucache_boundary_partitions;
+ while (entry != NULL) {
+ next = entry->next;
+
+ free(entry->boundary);
+ free(entry);
+
+ entry = next;
+ }
+
+ ucache_boundary_partitions = NULL;
+ ucache_boundary_partition_count = 0;
+ ucache_boundary_creation_disabled = false;
+
+ atomic_store(&ucache_boundary_startup_failed_logged, false);
+
+ ucache_boundary_partitions_unlock();
+}
+
+void ucache_restore_exec_preparation(void)
+{
+ if (UC_G(exec_prepared)) {
+ UC_G(active_partition) = UC_G(exec_prev_partition);
+ UC_G(active_ctx_ptr) = UC_G(exec_prev_ctx);
+ UC_G(req_unavailable_reason)
+ = UC_G(exec_prev_unavailable_reason)
+ ;
+ }
+
+ UC_G(exec_prepared) = false;
+ UC_G(exec_prev_partition) = NULL;
+ UC_G(exec_prev_ctx) = NULL;
+ UC_G(exec_prev_unavailable_reason) = PHP_UCACHE_REASON_NONE;
+ UC_G(exec_partition) = UC_G(active_partition);
+ UC_G(persistent_exec) =
+ ucache_runtime_opted_in &&
+ ucache_registered_mode == PHP_UCACHE_MODE_PERSISTENT
+ ;
+ UC_G(runtime_resolved) = false;
+}
+
+void ucache_boundary_partitions_lock(void)
+{
+#ifdef ZTS
+ if (ucache_boundary_partitions_mutex != NULL) {
+ tsrm_mutex_lock(ucache_boundary_partitions_mutex);
+ }
+#endif
+}
+
+void ucache_boundary_partitions_unlock(void)
+{
+#ifdef ZTS
+ if (ucache_boundary_partitions_mutex != NULL) {
+ tsrm_mutex_unlock(ucache_boundary_partitions_mutex);
+ }
+#endif
+}
+
+ZEND_COLD void ucache_warn(const char *format, ...)
+{
+ va_list args;
+ zend_string *msg;
+
+ va_start(args, format);
+
+ if (ucache_is_before_first_req()) {
+ ucache_sapi_log_warning(format, args);
+ } else {
+ msg = zend_vstrpprintf(0, format, args);
+
+ zend_error_zstr(E_WARNING, msg);
+
+ zend_string_release_ex(msg, false);
+ }
+
+ va_end(args);
+}
+
+ZEND_COLD void ucache_warn_docref(const char *format, ...)
+{
+ va_list args;
+
+ va_start(args, format);
+
+ if (ucache_is_before_first_req()) {
+ ucache_sapi_log_warning(format, args);
+ } else {
+ php_verror(NULL, E_WARNING, format, args);
+ }
+
+ va_end(args);
+}
+
+ZEND_COLD void ucache_log_err(const char *msg)
+{
+ if (!ucache_is_before_first_req()) {
+ php_log_err(msg);
+ } else if (sapi_module.log_message != NULL) {
+ sapi_module.log_message(msg, LOG_NOTICE);
+ }
+}
+
+ZEND_API php_ucache_partition *php_ucache_partition_create(const char *name)
+{
+ php_ucache_partition *partition = ucache_partition_alloc(name);
+
+ if (partition == NULL) {
+ return NULL;
+ }
+
+ ucache_boundary_partitions_lock();
+
+ ucache_partition_link(partition);
+
+ ucache_boundary_partitions_unlock();
+
+ return partition;
+}
+
+ZEND_API void php_ucache_partition_set_max_procs(php_ucache_partition *partition, uint32_t max_procs)
+{
+ uint32_t slot_count = (uint32_t) MIN(
+ (uint64_t) max_procs * UCACHE_GRAPH_PIN_SLOTS_PER_PROC,
+ UCACHE_GRAPH_PIN_SLOTS_MAX
+ );
+
+ ZEND_ASSERT(!partition->ctx.storage.initialized);
+
+ partition->ctx.graph_pin_slot_count = MAX(
+ ZEND_MM_ALIGNED_SIZE_EX(slot_count, UCACHE_GRAPH_PIN_SLOT_GRANULARITY),
+ UCACHE_GRAPH_PIN_SLOT_GRANULARITY
+ );
+}
+
+ZEND_API bool php_ucache_partition_startup_storage(php_ucache_partition *partition)
+{
+ php_ucache_partition *prev_partition;
+ bool result;
+
+ if (partition == NULL) {
+ return true;
+ }
+
+ if (ucache_partition_switch_is_forbidden() &&
+ partition != (
+ UC_G(exec_active)
+ ? UC_G(exec_partition)
+ : UC_G(active_partition)
+ )
+ ) {
+ return false;
+ }
+
+ prev_partition = UC_G(active_partition);
+
+ UC_G(active_partition) = partition;
+
+ result = ucache_startup_storage_for_ctx(&partition->ctx);
+
+ UC_G(active_partition) = prev_partition;
+
+ return result;
+}
+
+ZEND_API bool php_ucache_startup_default_ctx_storage(void)
+{
+ return ucache_startup_storage_for_ctx(&ucache_ctx_state);
+}
+
+ZEND_API void php_ucache_partition_activate(php_ucache_partition *partition)
+{
+ if (!ucache_globals_allocated()) {
+ return;
+ }
+
+ if (ucache_partition_switch_is_forbidden() &&
+ partition != (
+ UC_G(exec_active)
+ ? UC_G(exec_partition)
+ : UC_G(active_partition)
+ )
+ ) {
+ zend_throw_error(NULL, "Cannot change the UserCache partition during a persistent PHP execution");
+
+ return;
+ }
+
+ UC_G(active_partition) = partition;
+ UC_G(active_ctx_ptr) = NULL;
+ UC_G(req_unavailable_reason) = PHP_UCACHE_REASON_NONE;
+ UC_G(runtime_resolved) = false;
+}
+
+ZEND_API void php_ucache_partition_detach_all_except(php_ucache_partition *keep)
+{
+ php_ucache_partition *partition;
+ ucache_ctx *prev_ctx;
+
+ if (!ucache_globals_allocated()) {
+ return;
+ }
+
+ prev_ctx = UC_G(active_ctx_ptr);
+
+ ucache_boundary_partitions_lock();
+
+ for (partition = ucache_partitions; partition != NULL; partition = partition->next) {
+ if (partition == keep) {
+ continue;
+ }
+
+ ucache_activate_ctx(&partition->ctx);
+ ucache_shutdown_storage();
+ }
+
+ ucache_boundary_partitions_unlock();
+
+ if (keep != NULL) {
+ ucache_activate_ctx(&ucache_ctx_state);
+ ucache_shutdown_storage();
+ }
+
+ ucache_restore_ctx(prev_ctx);
+}
+
+ZEND_API void php_ucache_fork_prepare(void)
+{
+ if (!ucache_globals_allocated() || !UC_G(exec_active) || UC_G(persistent_exec)) {
+ return;
+ }
+
+ ucache_sgraph_refs_transfer_before_fork();
+}
+
+ZEND_API void php_ucache_fork_cancel(void)
+{
+ if (!ucache_globals_allocated()) {
+ return;
+ }
+
+ ucache_sgraph_fork_transfers_undo();
+}
+
+ZEND_API void php_ucache_child_init(void)
+{
+ if (!ucache_globals_allocated()) {
+ return;
+ }
+
+ ucache_sgraph_refs_adopt_after_fork();
+}
+
+ZEND_API void php_ucache_activate_boundary_partition_by_id(
+ const char *sapi_prefix,
+ const char *boundary,
+ size_t boundary_len,
+ php_ucache_reason failure_reason)
+{
+ php_ucache_partition *partition = NULL;
+ bool storage_started;
+
+ if (!ucache_globals_allocated()) {
+ return;
+ }
+
+ if (ucache_partition_switch_is_forbidden()) {
+ zend_throw_error(NULL, "Cannot select a UserCache boundary during a persistent PHP execution");
+
+ return;
+ }
+
+ if (boundary != NULL && boundary_len != 0) {
+ partition = ucache_boundary_partition_get(sapi_prefix, boundary, boundary_len);
+ }
+
+ if (partition == NULL) {
+ ucache_activate_req_unavailable(failure_reason);
+
+ return;
+ }
+
+ storage_started = ucache_boundary_partition_startup_storage(partition);
+
+ php_ucache_partition_activate(partition);
+
+ if (!storage_started) {
+ UC_G(req_unavailable_reason) = PHP_UCACHE_REASON_SHM_INIT_FAILED;
+ }
+}
+
+ZEND_API void php_ucache_activate_boundary_partition(
+ const char *sapi_prefix,
+ const char *doc_root,
+ php_ucache_reason failure_reason)
+{
+ char resolved_doc_root[MAXPATHLEN];
+
+ if (!ucache_resolve_boundary_doc_root(doc_root, resolved_doc_root)) {
+ php_ucache_activate_boundary_partition_by_id(sapi_prefix, NULL, 0, failure_reason);
+
+ return;
+ }
+
+ php_ucache_activate_boundary_partition_by_id(
+ sapi_prefix,
+ resolved_doc_root,
+ strlen(resolved_doc_root),
+ failure_reason
+ );
+}
+
+bool ucache_exec_available(void)
+{
+ return !UC_G(exec_poisoned) &&
+ !UC_G(logical_req_ending) &&
+ (
+ ucache_registered_mode != PHP_UCACHE_MODE_HOST_MANAGED ||
+ UC_G(exec_prepared)
+ )
+ ;
+}
+
+ZEND_API zend_result php_ucache_opt_in(php_ucache_mode mode)
+{
+ if (mode < PHP_UCACHE_MODE_REQ ||
+ mode > PHP_UCACHE_MODE_HOST_MANAGED ||
+ php_during_module_startup() ||
+ !php_get_module_initialized() ||
+ atomic_load(&ucache_registration_closed) ||
+ ucache_exec_is_live()
+ ) {
+ return FAILURE;
+ }
+
+ if (ucache_runtime_opted_in) {
+ return ucache_registered_mode == mode ? SUCCESS : FAILURE;
+ }
+
+ ucache_registered_mode = mode;
+
+ if (mode == PHP_UCACHE_MODE_REQ) {
+ ucache_use_req_method_handlers();
+ }
+
+ ucache_runtime_opted_in = true;
+
+ UC_G(persistent_exec) = mode == PHP_UCACHE_MODE_PERSISTENT;
+ UC_G(runtime_resolved) = false;
+
+ return SUCCESS;
+}
+
+ZEND_API zend_result php_ucache_exec_prepare(
+ php_ucache_exec_mode mode,
+ php_ucache_partition *partition)
+{
+ php_ucache_partition *entry;
+ bool partition_valid = partition == NULL;
+
+ if (ucache_exec_is_live() || UC_G(in_req_shutdown)) {
+ return FAILURE;
+ }
+
+ ucache_restore_exec_preparation();
+
+ if (!ucache_runtime_opted_in ||
+ (
+ mode != PHP_UCACHE_EXEC_REQ &&
+ mode != PHP_UCACHE_EXEC_PERSISTENT
+ ) ||
+ (
+ ucache_registered_mode == PHP_UCACHE_MODE_REQ &&
+ mode != PHP_UCACHE_EXEC_REQ
+ ) ||
+ (
+ ucache_registered_mode == PHP_UCACHE_MODE_PERSISTENT &&
+ mode != PHP_UCACHE_EXEC_PERSISTENT
+ )
+ ) {
+ return FAILURE;
+ }
+
+ ucache_boundary_partitions_lock();
+
+ for (entry = ucache_partitions; entry != NULL && !partition_valid; entry = entry->next) {
+ partition_valid = entry == partition;
+ }
+
+ ucache_boundary_partitions_unlock();
+
+ if (!partition_valid) {
+ return FAILURE;
+ }
+
+ UC_G(exec_prev_partition) = UC_G(active_partition);
+ UC_G(exec_prev_ctx) = UC_G(active_ctx_ptr);
+ UC_G(exec_prev_unavailable_reason) = UC_G(req_unavailable_reason);
+ UC_G(active_partition) = partition;
+ UC_G(active_ctx_ptr) = NULL;
+ UC_G(req_unavailable_reason) = PHP_UCACHE_REASON_NONE;
+ UC_G(exec_partition) = partition;
+ UC_G(exec_prepared) = true;
+ UC_G(persistent_exec) = mode == PHP_UCACHE_EXEC_PERSISTENT;
+ UC_G(runtime_resolved) = false;
+
+ atomic_store(&ucache_registration_closed, true);
+
+ return SUCCESS;
+}
+
+ZEND_API zend_result php_ucache_exec_cancel_prepare(void)
+{
+ if (ucache_exec_is_live() ||
+ UC_G(in_req_shutdown)
+ ) {
+ return FAILURE;
+ }
+
+ ucache_restore_exec_preparation();
+
+ return SUCCESS;
+}
+
+ZEND_API zend_result php_ucache_logical_req_begin(php_ucache_scope_token *token)
+{
+ uint64_t prev;
+
+ if (token == NULL ||
+ !UC_G(exec_active) ||
+ !UC_G(persistent_exec) ||
+ !ucache_exec_available() ||
+ UC_G(in_req_shutdown) ||
+ UC_G(logical_scope_token) != 0 ||
+ !ucache_logical_boundary_is_idle()
+ ) {
+ return FAILURE;
+ }
+
+ prev = ucache_atomic_load_64(&ucache_scope_seq);
+ do {
+ if (prev == UINT64_MAX) {
+ return FAILURE;
+ }
+
+ if (ucache_atomic_cas_64(&ucache_scope_seq, prev, prev + 1)) {
+ break;
+ }
+
+ prev = ucache_atomic_load_64(&ucache_scope_seq);
+ } while (true);
+
+ if (ucache_finish_logical_lock_scope() == FAILURE) {
+ return FAILURE;
+ }
+
+ UC_G(logical_scope_token) = prev + 1;
+
+ *token = UC_G(logical_scope_token);
+
+ return SUCCESS;
+}
+
+ZEND_API zend_result php_ucache_logical_req_end(
+ php_ucache_scope_token token,
+ php_ucache_scope_outcome outcome)
+{
+ if (token == 0 ||
+ token != UC_G(logical_scope_token) ||
+ !UC_G(exec_active) ||
+ !UC_G(persistent_exec)
+ ) {
+ return FAILURE;
+ }
+
+ if (outcome == PHP_UCACHE_SCOPE_ABORTED) {
+ UC_G(logical_scope_token) = 0;
+
+ ucache_poison_exec();
+ ucache_forget_unwound_ops();
+
+ return SUCCESS;
+ }
+
+ if (outcome != PHP_UCACHE_SCOPE_COMPLETE ||
+ UC_G(logical_req_ending) ||
+ UC_G(exec_poisoned) ||
+ UC_G(in_req_shutdown) ||
+ !ucache_logical_boundary_is_idle()
+ ) {
+ return FAILURE;
+ }
+
+ if (ucache_finish_logical_lock_scope() == FAILURE) {
+ return FAILURE;
+ }
+
+ ucache_release_pool_status_snapshots();
+
+ ucache_retry_op_lease_releases();
+
+ ucache_expunge_expired_at_req_end();
+
+ UC_G(logical_scope_token) = 0;
+
+ return SUCCESS;
+}
diff --git a/ext/user_cache/user_cache_recovery.c b/ext/user_cache/user_cache_recovery.c
new file mode 100644
index 000000000000..2022cf8b2cf7
--- /dev/null
+++ b/ext/user_cache/user_cache_recovery.c
@@ -0,0 +1,760 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_storage.h"
+
+#define UCACHE_UNKNOWN_OWNER_EXIT_RECHECK_US (3U * 1000U)
+
+typedef struct {
+ char *key;
+ uint32_t key_len;
+ zend_ulong hash;
+ uint64_t owner_pid;
+ uint64_t owner_start_time;
+ uint64_t owner_token;
+ uint32_t expires_at;
+} ucache_recovered_entry_lock;
+
+static bool ucache_owner_is_dead_probe(uint64_t owner_pid, uint64_t owner_start_time)
+{
+ uint64_t cur_start_time;
+
+ if (ucache_platform.proc_has_exited(owner_pid)) {
+ if (owner_start_time != 0) {
+ return true;
+ }
+
+ ucache_platform.sleep_us(UCACHE_UNKNOWN_OWNER_EXIT_RECHECK_US);
+
+ return ucache_platform.proc_has_exited(owner_pid);
+ }
+
+ if (owner_start_time != 0) {
+ cur_start_time = ucache_proc_owner_token(owner_pid);
+ if (cur_start_time != 0 && cur_start_time != owner_start_time) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static bool ucache_recovery_lock_key_is_readable(
+ const ucache_hdr *hdr,
+ const ucache_entry_lock_record *record)
+{
+ if (record->key_offset == 0 || record->key_len == 0) {
+ return false;
+ }
+
+ return ucache_payload_in_bounds(hdr, record->key_offset, record->key_len);
+}
+
+static bool ucache_recovery_entry_lock_is_duplicate(
+ const ucache_entry_lock_record *record,
+ const ucache_recovered_entry_lock *locks,
+ uint32_t count)
+{
+ uint32_t i;
+
+ for (i = 0; i < count; i++) {
+ if (locks[i].hash == record->hash &&
+ locks[i].owner_token == record->owner_token &&
+ locks[i].owner_pid == record->owner_pid &&
+ locks[i].key_len == record->key_len &&
+ memcmp(locks[i].key, ucache_ptr(record->key_offset), record->key_len) == 0
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static bool ucache_entry_lock_layout_sane(
+ const ucache_storage *storage,
+ const ucache_hdr *hdr)
+{
+ uint32_t lock_capacity = hdr->entry_lock_capacity;
+
+ return lock_capacity >= UCACHE_ENTRY_LOCK_MIN_CAPACITY &&
+ lock_capacity <= UCACHE_ENTRY_LOCK_MAX_CAPACITY &&
+ (lock_capacity & (lock_capacity - 1)) == 0 &&
+ hdr->entry_lock_offset >= sizeof(ucache_hdr) &&
+ (size_t) hdr->entry_lock_offset <= storage->size &&
+ (size_t) lock_capacity * sizeof(ucache_entry_lock_record)
+ <= storage->size - hdr->entry_lock_offset
+ ;
+}
+
+static bool ucache_data_region_sane(
+ const ucache_storage *storage,
+ const ucache_hdr *hdr)
+{
+ uint64_t lock_table_end = (uint64_t) hdr->entry_lock_offset
+ + (uint64_t) hdr->entry_lock_capacity * sizeof(ucache_entry_lock_record)
+ ;
+
+ return (uint64_t) hdr->data_offset >= lock_table_end &&
+ ucache_hdr_data_bounds_match(storage, hdr)
+ ;
+}
+
+static void ucache_reset_data_region_locked(
+ const ucache_storage *storage,
+ ucache_hdr *hdr)
+{
+ hdr->next_free = 0;
+ hdr->free_list_bytes = 0;
+
+ if ((size_t) hdr->data_offset <= storage->size) {
+ hdr->data_size = (uint64_t) (storage->size - hdr->data_offset);
+ }
+}
+
+static bool ucache_free_bins_layout_sane(
+ const ucache_storage *storage,
+ const ucache_hdr *hdr)
+{
+ return hdr->free_bin_count != 0 &&
+ hdr->free_bin_count <= UCACHE_FREE_BINS &&
+ hdr->free_bins_offset >= sizeof(ucache_hdr) &&
+ (uint64_t) hdr->free_bins_offset + ucache_free_bins_bytes(hdr->free_bin_count) <= hdr->data_offset &&
+ (size_t) hdr->data_offset <= storage->size
+ ;
+}
+
+static bool ucache_collect_recovery_entry_locks_locked(
+ ucache_hdr *hdr,
+ ucache_recovered_entry_lock **locks_ptr,
+ uint32_t *count_ptr)
+{
+ ucache_recovered_entry_lock *locks;
+ ucache_entry_lock_record *record;
+ uint64_t now = ucache_time_rel(hdr, ucache_clock_now());
+ uint32_t i, count = 0;
+ char *key;
+
+ for (i = 0; i < hdr->entry_lock_capacity; i++) {
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+ if (ucache_entry_lock_record_is_active_locked(record, now) &&
+ ucache_recovery_lock_key_is_readable(hdr, record)
+ ) {
+ count++;
+ }
+ }
+
+ *locks_ptr = NULL;
+ *count_ptr = 0;
+
+ if (count == 0) {
+ return true;
+ }
+
+ locks = (ucache_recovered_entry_lock *) calloc(count, sizeof(*locks));
+ if (locks == NULL) {
+ return false;
+ }
+
+ for (i = 0; i < hdr->entry_lock_capacity; i++) {
+ record = &ucache_entry_lock_records_ptr(hdr)[i];
+ if (!ucache_entry_lock_record_is_active_locked(record, now) ||
+ !ucache_recovery_lock_key_is_readable(hdr, record) ||
+ ucache_recovery_entry_lock_is_duplicate(record, locks, *count_ptr)
+ ) {
+ continue;
+ }
+
+ ZEND_ASSERT(*count_ptr < count);
+
+ key = (char *) malloc(record->key_len);
+ if (key == NULL) {
+ while (*count_ptr != 0) {
+ free(locks[--*count_ptr].key);
+ }
+
+ free(locks);
+
+ return false;
+ }
+
+ memcpy(key, ucache_ptr(record->key_offset), record->key_len);
+
+ locks[*count_ptr].key = key;
+ locks[*count_ptr].key_len = record->key_len;
+ locks[*count_ptr].hash = record->hash;
+ locks[*count_ptr].owner_pid = record->owner_pid;
+ locks[*count_ptr].owner_start_time = record->owner_start_time;
+ locks[*count_ptr].owner_token = record->owner_token;
+ locks[*count_ptr].expires_at = record->expires_at;
+
+ (*count_ptr)++;
+ }
+
+ *locks_ptr = locks;
+
+ return true;
+}
+
+static void ucache_free_recovery_entry_locks(
+ ucache_recovered_entry_lock *locks,
+ uint32_t count)
+{
+ uint32_t i;
+
+ if (locks == NULL) {
+ return;
+ }
+
+ for (i = 0; i < count; i++) {
+ free(locks[i].key);
+ }
+
+ free(locks);
+}
+
+static void ucache_restore_recovery_entry_locks_locked(
+ ucache_hdr *hdr,
+ ucache_recovered_entry_lock *locks,
+ uint32_t count)
+{
+ ucache_entry_lock_record *record;
+ uint32_t i, probe, slot_idx, key_offset;
+
+ for (i = 0; i < count; i++) {
+ if (locks[i].key == NULL) {
+ continue;
+ }
+
+ for (probe = 0; probe < hdr->entry_lock_capacity; probe++) {
+ slot_idx = (ucache_entry_lock_table_idx(hdr, locks[i].hash) + probe) &
+ (hdr->entry_lock_capacity - 1)
+ ;
+
+ record = &ucache_entry_lock_records_ptr(hdr)[slot_idx];
+ if (record->state == UCACHE_ENTRY_LOCK_USED) {
+ continue;
+ }
+
+ key_offset = ucache_alloc_locked(locks[i].key_len, locks[i].key, UCACHE_BLOCK_OWNER_NONE);
+ if (key_offset == 0) {
+ break;
+ }
+
+ memset(record, 0, sizeof(*record));
+
+ record->hash = locks[i].hash;
+ record->owner_pid = locks[i].owner_pid;
+ record->owner_start_time = locks[i].owner_start_time;
+ record->owner_token = locks[i].owner_token;
+ record->expires_at = locks[i].expires_at;
+ record->key_offset = key_offset;
+ record->key_len = locks[i].key_len;
+ record->state = UCACHE_ENTRY_LOCK_USED;
+
+ hdr->entry_lock_count++;
+
+ break;
+ }
+ }
+}
+
+static bool ucache_recovery_blocked_by_live_ref(const ucache_hdr *hdr)
+{
+ const ucache_reader_slot *reader_slot;
+ ucache_graph_pin_slot *pin_slot;
+ uint64_t owner_start_time, owner_pid;
+ uint32_t i;
+
+ ucache_atomic_fence_seq_cst();
+
+ for (i = 0; i < UCACHE_READER_SLOTS; i++) {
+ reader_slot = &hdr->reader_slots[i];
+ if (ucache_atomic_load_32(&reader_slot->active) == 0) {
+ continue;
+ }
+
+ owner_pid = ucache_atomic_load_64(&reader_slot->owner_pid);
+ owner_start_time = ucache_atomic_load_64(&reader_slot->owner_start_time);
+ if (owner_pid != 0 &&
+ !ucache_owner_is_dead(owner_pid, owner_start_time)
+ ) {
+ return true;
+ }
+ }
+
+ for (i = 0; i < ucache_graph_pin_slot_count(hdr); i++) {
+ pin_slot = (ucache_graph_pin_slot *) &hdr->graph_pin_slots[i];
+ if (atomic_load(&pin_slot->pin_count) == 0) {
+ continue;
+ }
+
+ owner_pid = (uint64_t) (uint32_t) atomic_load(&pin_slot->owner_pid);
+ if (owner_pid != 0 &&
+ owner_pid != (uint64_t) (uint32_t) UCACHE_GRAPH_PIN_OWNER_ABANDONED &&
+ !ucache_owner_is_dead(owner_pid, ucache_atomic_load_64(&pin_slot->owner_start_time))
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static void ucache_recovery_release_dead_graph_pin_slots(ucache_hdr *hdr)
+{
+ ucache_graph_pin_slot *slot;
+ uint64_t owner_start_time;
+ uint32_t i;
+ int owner, pin_count;
+
+ for (i = 0; i < ucache_graph_pin_slot_count(hdr); i++) {
+ slot = &hdr->graph_pin_slots[i];
+
+ pin_count = atomic_load(&slot->pin_count);
+ if (pin_count == 0) {
+ continue;
+ }
+
+ owner_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ owner = atomic_load(&slot->owner_pid);
+ if (owner != UCACHE_GRAPH_PIN_OWNER_ABANDONED &&
+ (owner == 0 || !ucache_owner_is_dead((uint64_t) (uint32_t) owner, owner_start_time))
+ ) {
+ continue;
+ }
+
+ if (!atomic_compare_exchange_strong(&slot->pin_count, &pin_count, 0)) {
+ continue;
+ }
+
+ ucache_atomic_cas_64(&slot->owner_start_time, owner_start_time, 0);
+
+ atomic_compare_exchange_strong(&slot->owner_pid, &owner, 0);
+
+ hdr->graph_dead_pin_owners_reclaimed++;
+ }
+}
+
+#ifdef ZEND_WIN32
+uint32_t ucache_win32_sleep_us(uint32_t interval_us)
+{
+ zend_hrtime_t start;
+ DWORD interval_ms = interval_us / 1000U;
+ uint64_t elapsed_us;
+
+ if (interval_ms == 0) {
+ interval_ms = 1;
+ }
+
+ start = zend_hrtime();
+
+ Sleep(interval_ms);
+
+ elapsed_us = (zend_hrtime() - start) / 1000U;
+
+ return (uint32_t) MIN(MAX(elapsed_us, (uint64_t) interval_ms * 1000U), UINT32_MAX);
+}
+
+uint64_t ucache_win32_proc_start_time_token(uint64_t pid)
+{
+ FILETIME creation, exit_time, kernel_time, user_time;
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD) pid);
+ uint64_t start_time = 0;
+
+ if (process == NULL) {
+ return 0;
+ }
+
+ if (GetProcessTimes(process, &creation, &exit_time, &kernel_time, &user_time)) {
+ start_time = ((uint64_t) creation.dwHighDateTime << 32) | creation.dwLowDateTime;
+ }
+
+ CloseHandle(process);
+
+ return start_time;
+}
+
+bool ucache_win32_proc_has_exited(uint64_t pid)
+{
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD) pid);
+ DWORD exit_code = 0;
+
+ if (process == NULL) {
+ return GetLastError() == ERROR_INVALID_PARAMETER;
+ }
+
+ if (GetExitCodeProcess(process, &exit_code) && exit_code != STILL_ACTIVE) {
+ CloseHandle(process);
+
+ return true;
+ }
+
+ CloseHandle(process);
+
+ return false;
+}
+
+int ucache_win32_alloc_err_code(void)
+{
+ return (int) GetLastError();
+}
+
+void ucache_win32_log_alloc_failure(const char *failure, const char *err_in, int err_code)
+{
+ char *msg = php_win32_error_to_msg(err_code);
+
+ ucache_warn_docref(
+ "UserCache: %s: %s: %s (%d)",
+ failure,
+ err_in != NULL ? err_in : "unknown",
+ msg,
+ err_code
+ );
+
+ php_win32_error_msg_free(msg);
+}
+#else
+uint32_t ucache_posix_sleep_us(uint32_t interval_us)
+{
+#ifdef HAVE_UNISTD_H
+ usleep(interval_us);
+#endif
+
+ return interval_us;
+}
+
+uint64_t ucache_posix_proc_start_time_token(uint64_t pid)
+{
+#if defined(__linux__)
+ const char *p;
+ ssize_t stat_len;
+ int fd;
+ long long thread_count;
+ unsigned long long start_time;
+ char state;
+ char path[64], stat_buf[1024];
+
+ /* QEMU user mode fakes the start time in a process's own /proc//stat but not in its task entry. */
+ snprintf(path, sizeof(path), "/proc/%llu/task/%llu/stat", (unsigned long long) pid, (unsigned long long) pid);
+ fd = open(path, O_RDONLY);
+ if (fd < 0) {
+ return 0;
+ }
+
+ stat_len = read(fd, stat_buf, sizeof(stat_buf) - 1);
+
+ close(fd);
+
+ if (stat_len <= 0) {
+ return 0;
+ }
+
+ stat_buf[stat_len] = '\0';
+
+ p = strrchr(stat_buf, ')');
+ if (p == NULL) {
+ return 0;
+ }
+
+ if (sscanf(
+ p + 1,
+ " %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %lld %*d %llu",
+ &state,
+ &thread_count,
+ &start_time
+ ) != 3
+ ) {
+ return 0;
+ }
+
+ if (state == 'X' || (state == 'Z' && thread_count <= 1)) {
+ return UCACHE_START_TIME_EXITED;
+ }
+
+ return (uint64_t) start_time;
+#elif defined(__APPLE__) || defined(__FreeBSD__)
+ struct kinfo_proc info;
+ size_t size = sizeof(info);
+ int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, (int) pid };
+
+ if (sysctl(mib, 4, &info, &size, NULL, 0) != 0 || size < sizeof(info)) {
+ return 0;
+ }
+
+# ifdef __APPLE__
+# ifdef SZOMB
+ if (info.kp_proc.p_stat == SZOMB) {
+ return UCACHE_START_TIME_EXITED;
+ }
+# endif
+ return (((uint64_t) info.kp_proc.p_starttime.tv_sec << 20)
+ | (uint64_t) info.kp_proc.p_starttime.tv_usec) + 1
+ ;
+# else
+# ifdef SZOMB
+ if (info.ki_stat == SZOMB) {
+ return UCACHE_START_TIME_EXITED;
+ }
+# endif
+ return (((uint64_t) info.ki_start.tv_sec << 20)
+ | (uint64_t) info.ki_start.tv_usec) + 1
+ ;
+# endif
+#elif defined(__NetBSD__) || defined(__OpenBSD__)
+# ifdef __NetBSD__
+ struct kinfo_proc2 info;
+ int mib[6] = { CTL_KERN, KERN_PROC2, KERN_PROC_PID, (int) pid, (int) sizeof(info), 1 };
+# else
+ struct kinfo_proc info;
+ int mib[6] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, (int) pid, (int) sizeof(info), 1 };
+# endif
+ size_t size = sizeof(info);
+
+ if (sysctl(mib, 6, &info, &size, NULL, 0) != 0 || size < sizeof(info)) {
+ return 0;
+ }
+
+# ifdef SZOMB
+ if (info.p_stat == SZOMB) {
+ return UCACHE_START_TIME_EXITED;
+ }
+# endif
+ return (((uint64_t) info.p_ustart_sec << 20)
+ | (uint64_t) info.p_ustart_usec) + 1
+ ;
+#elif defined(__DragonFly__)
+ struct kinfo_proc info;
+ size_t size = sizeof(info);
+ int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, (int) pid };
+
+ if (sysctl(mib, 4, &info, &size, NULL, 0) != 0 || size < sizeof(info)) {
+ return 0;
+ }
+
+# ifdef SZOMB
+ if (info.kp_stat == SZOMB) {
+ return UCACHE_START_TIME_EXITED;
+ }
+# endif
+ return (((uint64_t) info.kp_start.tv_sec << 20)
+ | (uint64_t) info.kp_start.tv_usec) + 1
+ ;
+#elif defined(__sun)
+ psinfo_t info;
+ ssize_t info_len;
+ int fd;
+ char path[64];
+
+ snprintf(path, sizeof(path), "/proc/%llu/psinfo", (unsigned long long) pid);
+ fd = open(path, O_RDONLY);
+ if (fd < 0) {
+ return 0;
+ }
+
+ info_len = read(fd, &info, sizeof(info));
+
+ close(fd);
+
+ if (info_len != (ssize_t) sizeof(info)) {
+ return 0;
+ }
+
+ if (info.pr_nlwp == 0) {
+ return UCACHE_START_TIME_EXITED;
+ }
+
+ return (((uint64_t) info.pr_start.tv_sec << 20)
+ | (uint64_t) (info.pr_start.tv_nsec / 1000)) + 1
+ ;
+#elif defined(_AIX)
+ struct procentry64 info;
+ pid_t idx = (pid_t) pid;
+
+ if (getprocs64(&info, (int) sizeof(info), NULL, 0, &idx, 1) != 1 || (uint64_t) info.pi_pid != pid) {
+ return 0;
+ }
+
+# ifdef SZOMB
+ if (info.pi_state == SZOMB) {
+ return UCACHE_START_TIME_EXITED;
+ }
+# endif
+ return ((uint64_t) info.pi_start << 20) + 1;
+#else
+ (void) pid;
+
+ return 0;
+#endif
+}
+
+bool ucache_posix_proc_has_exited(uint64_t pid)
+{
+ return kill((pid_t) pid, 0) == -1 && errno == ESRCH;
+}
+
+int ucache_posix_alloc_err_code(void)
+{
+ return errno;
+}
+
+void ucache_posix_log_alloc_failure(const char *failure, const char *err_in, int err_code)
+{
+ ucache_warn_docref(
+ "UserCache: %s: %s: %s (%d)",
+ failure,
+ err_in != NULL ? err_in : "unknown",
+ strerror(err_code),
+ err_code
+ );
+}
+#endif /* ZEND_WIN32 */
+
+bool ucache_recover_after_owner_death_locked(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_recovered_entry_lock *locks = NULL;
+ ucache_reader_slot *slot;
+ uint64_t owner_pid, owner_start_time;
+ uint32_t lock_count = 0, i;
+ size_t table_span;
+
+ if (hdr == NULL || !ucache_hdr_is_initialized_locked()) {
+ return true;
+ }
+
+ if ((hdr->write_seq & 1) == 0) {
+ if (hdr->entry_lock_table_section_open == 0) {
+ return true;
+ }
+
+ ucache_seq_announce(&hdr->write_seq, hdr->write_seq + 1);
+ }
+
+ ZEND_ASSERT((hdr->write_seq & 1) != 0);
+
+ hdr->entry_lock_table_section_open = 0;
+
+ if (ucache_recovery_blocked_by_live_ref(hdr)) {
+ return false;
+ }
+
+ if (ucache_entry_lock_layout_sane(storage, hdr) &&
+ ucache_data_region_sane(storage, hdr) &&
+ !ucache_collect_recovery_entry_locks_locked(hdr, &locks, &lock_count)
+ ) {
+ return false;
+ }
+
+ table_span = (size_t) hdr->capacity * UCACHE_TABLE_SLOT_SIZE;
+ if (storage->size > sizeof(ucache_hdr) &&
+ table_span <= storage->size - sizeof(ucache_hdr)
+ ) {
+ memset(
+ ucache_entries_ptr(hdr),
+ 0,
+ (size_t) hdr->capacity * sizeof(ucache_entry)
+ );
+
+ ucache_access_stamps_reset(hdr);
+
+ ucache_pool_idx_reset_locked(hdr);
+ }
+
+ if (ucache_entry_lock_layout_sane(storage, hdr)) {
+ memset(
+ ucache_entry_lock_records_ptr(hdr),
+ 0,
+ (size_t) hdr->entry_lock_capacity * sizeof(ucache_entry_lock_record)
+ );
+ }
+
+ memset(hdr->orphaned_graphs, 0, sizeof(hdr->orphaned_graphs));
+
+ for (i = 0; i < UCACHE_READER_SLOTS; i++) {
+ slot = &hdr->reader_slots[i];
+
+ if (ucache_atomic_load_32(&slot->active) == 0) {
+ continue;
+ }
+
+ owner_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ owner_pid = ucache_atomic_load_64(&slot->owner_pid);
+
+ if (owner_pid != 0 &&
+ ucache_owner_is_dead(owner_pid, owner_start_time) &&
+ ucache_atomic_load_64(&slot->owner_pid) == owner_pid &&
+ ucache_atomic_load_64(&slot->owner_start_time) == owner_start_time
+ ) {
+ ucache_atomic_cas_32(&slot->active, 1, 0);
+ }
+ }
+
+ ucache_recovery_release_dead_graph_pin_slots(hdr);
+
+ hdr->count = 0;
+ hdr->expiring_count = 0;
+ hdr->expiry_floor = UCACHE_EXPIRY_FLOOR_NONE;
+ hdr->tombstone_count = 0;
+ hdr->entry_lock_count = 0;
+ hdr->entry_lock_tombstone_count = 0;
+
+ ucache_reset_data_region_locked(storage, hdr);
+
+ if (ucache_free_bins_layout_sane(storage, hdr)) {
+ memset(ucache_free_bins_ptr(hdr), 0, ucache_free_bins_bytes(hdr->free_bin_count));
+ }
+
+ hdr->orphaned_graphs_saturated = 0;
+#if ZEND_DEBUG
+ ucache_debug_reserve_data_below_4g_locked(hdr);
+#endif
+
+ ucache_restore_recovery_entry_locks_locked(hdr, locks, lock_count);
+
+ ucache_free_recovery_entry_locks(locks, lock_count);
+
+ ucache_bump_mutation_epoch_locked(hdr);
+
+ ucache_seq_publish(&hdr->write_seq, hdr->write_seq + 1);
+
+ return true;
+}
+
+bool ucache_owner_is_dead(uint64_t owner_pid, uint64_t owner_start_time)
+{
+ ucache_owner_probe *probe = &UC_G(owner_probes)[owner_pid % UCACHE_OWNER_PROBES];
+ uint64_t now = ucache_clock_now() / UCACHE_CLOCK_TICKS_PER_SEC;
+
+ if (probe->pid == owner_pid &&
+ probe->start_time == owner_start_time &&
+ probe->probed_at == now &&
+ !probe->dead
+ ) {
+ return false;
+ }
+
+ probe->dead = ucache_owner_is_dead_probe(owner_pid, owner_start_time);
+ probe->pid = owner_pid;
+ probe->start_time = owner_start_time;
+ probe->probed_at = now;
+
+ return probe->dead;
+}
+
+uint64_t ucache_self_start_time_token(void)
+{
+ return ucache_cached_self_start_time_token(ucache_cached_pid());
+}
diff --git a/ext/user_cache/user_cache_req_local.c b/ext/user_cache/user_cache_req_local.c
new file mode 100644
index 000000000000..53b00a460f6d
--- /dev/null
+++ b/ext/user_cache/user_cache_req_local.c
@@ -0,0 +1,1236 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_entries.h"
+
+#include "Zend/zend_objects.h"
+
+#define UCACHE_REQ_LOCAL_NO_DEEP_CLONE ((void *) 1)
+#define UCACHE_REQ_LOCAL_NEEDS_DEEP_CLONE ((void *) 2)
+
+typedef struct {
+ HashTable arrs;
+ HashTable objs;
+ HashTable refs;
+ HashTable *clone_verdicts;
+ bool *proto_rejected;
+ bool track_identity;
+} ucache_req_local_clone_ctx;
+
+static bool ucache_clone_req_local_val(
+ ucache_req_local_clone_ctx *ctx,
+ zval *dst,
+ zval *src);
+
+static bool ucache_collect_req_local_clone_verdicts_impl(
+ zval *val,
+ HashTable *seen_arrs,
+ HashTable *seen_objs,
+ HashTable *verdicts,
+ bool record_arr_result);
+
+static zend_always_inline ucache_req_local_slot *ucache_alloc_req_local_slot(
+ uint64_t gen,
+ bool no_aliases,
+ bool has_val,
+ bool needs_deep_clone)
+{
+ ucache_req_local_slot *slot;
+
+ slot = emalloc(needs_deep_clone
+ ? sizeof(ucache_req_local_slot_with_verdicts)
+ : sizeof(ucache_req_local_slot)
+ );
+
+ slot->gen = gen;
+ slot->ctx = ucache_active_ctx();
+ slot->needs_deep_clone = needs_deep_clone;
+ slot->has_clone_verdicts = false;
+ slot->no_aliases = no_aliases;
+ slot->has_val = has_val;
+ slot->proto_rejected = false;
+ slot->released_while_cloning = false;
+ slot->clone_depth = 0;
+
+ ZVAL_UNDEF(&slot->val);
+
+ return slot;
+}
+
+static zend_always_inline bool ucache_obj_has_deprecated_dynamic_props(const zend_object *obj)
+{
+ zval *prop;
+
+ if (obj->properties == NULL || (obj->ce->ce_flags & ZEND_ACC_ALLOW_DYNAMIC_PROPERTIES)) {
+ return false;
+ }
+
+ ZEND_HASH_MAP_FOREACH_VAL(obj->properties, prop) {
+ if (Z_TYPE_P(prop) != IS_INDIRECT) {
+ return true;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return false;
+}
+
+static zend_always_inline bool ucache_proto_rejects_obj(const zend_object *obj)
+{
+ if (obj->ce->destructor != NULL || ucache_obj_has_deprecated_dynamic_props(obj)) {
+ return true;
+ }
+
+ return obj->handlers == zend_get_std_object_handlers() &&
+ (
+ obj->ce->__unserialize != NULL ||
+ zend_hash_find_known_hash(
+ &obj->ce->function_table,
+ ZSTR_KNOWN(ZEND_STR_WAKEUP)
+ ) != NULL
+ )
+ ;
+}
+
+static void ucache_req_local_slot_dtor(zval *slot_zv)
+{
+ ucache_req_local_slot_free(Z_PTR_P(slot_zv));
+}
+
+static bool ucache_val_needs_req_local_deep_clone_impl(
+ zval *val,
+ HashTable *seen_arrs)
+{
+ zval *elem;
+
+ if (ucache_stack_exhausted()) {
+ return true;
+ }
+
+ switch (Z_TYPE_P(val)) {
+ case IS_REFERENCE:
+ case IS_OBJECT:
+ return true;
+ case IS_ARRAY:
+ if (GC_FLAGS(Z_ARRVAL_P(val)) & IS_ARRAY_IMMUTABLE) {
+ return false;
+ }
+
+ if (!ucache_seen_test_and_add(seen_arrs, Z_ARRVAL_P(val))) {
+ return false;
+ }
+
+ ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(val), elem) {
+ if (ucache_val_needs_req_local_deep_clone_impl(elem, seen_arrs)) {
+ return true;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return false;
+ default:
+ return false;
+ }
+}
+
+static bool ucache_val_needs_req_local_deep_clone(zval *val)
+{
+ HashTable seen_arrs;
+ bool result;
+
+ ZEND_ASSERT(Z_TYPE_P(val) == IS_ARRAY);
+
+ if (GC_FLAGS(Z_ARRVAL_P(val)) & IS_ARRAY_IMMUTABLE) {
+ return false;
+ }
+
+ zend_hash_init(&seen_arrs, 8, NULL, NULL, 0);
+
+ result = ucache_val_needs_req_local_deep_clone_impl(val, &seen_arrs);
+
+ zend_hash_destroy(&seen_arrs);
+
+ return result;
+}
+
+static void ucache_collect_req_local_obj_clone_verdict(
+ zend_object *obj,
+ HashTable *seen_arrs,
+ HashTable *seen_objs,
+ HashTable *verdicts)
+{
+ zend_ulong obj_key;
+ zval *prop, *src, *end;
+ bool members_need_clone = false;
+
+ if (ucache_stack_exhausted()) {
+ UC_G(req_local_slot_may_cycle) = true;
+
+ return;
+ }
+
+ if (obj == NULL) {
+ return;
+ }
+
+ obj_key = (zend_ulong) (uintptr_t) obj;
+
+ if (zend_hash_index_exists(verdicts, obj_key)) {
+ return;
+ }
+
+ if (!ucache_seen_test_and_add(seen_objs, obj)) {
+ UC_G(req_local_slot_may_cycle) = true;
+
+ return;
+ }
+
+ if (obj->ce->default_properties_count) {
+ src = obj->properties_table;
+ end = src + obj->ce->default_properties_count;
+
+ do {
+ if (ucache_collect_req_local_clone_verdicts_impl(
+ src,
+ seen_arrs,
+ seen_objs,
+ verdicts,
+ true
+ )
+ ) {
+ members_need_clone = true;
+ }
+
+ src++;
+ } while (src != end);
+ }
+
+ if (obj->properties != NULL && zend_hash_num_elements(obj->properties) != 0) {
+ members_need_clone = true;
+
+ ZEND_HASH_MAP_FOREACH_VAL(obj->properties, prop) {
+ if (Z_TYPE_P(prop) != IS_INDIRECT) {
+ ucache_collect_req_local_clone_verdicts_impl(
+ prop,
+ seen_arrs,
+ seen_objs,
+ verdicts,
+ true
+ );
+ }
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ zend_hash_index_update_ptr(
+ verdicts,
+ obj_key,
+ members_need_clone
+ ? UCACHE_REQ_LOCAL_NEEDS_DEEP_CLONE
+ : UCACHE_REQ_LOCAL_NO_DEEP_CLONE
+ );
+}
+
+static bool ucache_collect_req_local_clone_verdicts_impl(
+ zval *val,
+ HashTable *seen_arrs,
+ HashTable *seen_objs,
+ HashTable *verdicts,
+ bool record_arr_result)
+{
+ zend_ulong arr_key;
+ zval *elem;
+ bool needs_deep_clone = false;
+ void *flag;
+
+ if (ucache_stack_exhausted()) {
+ UC_G(req_local_slot_may_cycle) = true;
+
+ return true;
+ }
+
+ switch (Z_TYPE_P(val)) {
+ case IS_REFERENCE:
+ ucache_collect_req_local_clone_verdicts_impl(
+ &Z_REF_P(val)->val,
+ seen_arrs,
+ seen_objs,
+ verdicts,
+ true
+ );
+
+ return true;
+ case IS_OBJECT:
+ ucache_collect_req_local_obj_clone_verdict(
+ Z_OBJ_P(val),
+ seen_arrs,
+ seen_objs,
+ verdicts
+ );
+
+ return true;
+ case IS_ARRAY:
+ if (GC_FLAGS(Z_ARRVAL_P(val)) & IS_ARRAY_IMMUTABLE) {
+ return false;
+ }
+
+ arr_key = (zend_ulong) (uintptr_t) Z_ARRVAL_P(val);
+ flag = zend_hash_index_find_ptr(verdicts, arr_key);
+ if (flag != NULL) {
+ return flag == UCACHE_REQ_LOCAL_NEEDS_DEEP_CLONE;
+ }
+
+ if (!ucache_seen_test_and_add(seen_arrs, Z_ARRVAL_P(val))) {
+ UC_G(req_local_slot_may_cycle) = true;
+
+ return true;
+ }
+
+ ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(val), elem) {
+ if (ucache_collect_req_local_clone_verdicts_impl(
+ elem,
+ seen_arrs,
+ seen_objs,
+ verdicts,
+ false
+ )
+ ) {
+ needs_deep_clone = true;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ zend_hash_index_del(seen_arrs, arr_key);
+
+ if (needs_deep_clone || record_arr_result || GC_REFCOUNT(Z_ARRVAL_P(val)) > 1) {
+ zend_hash_index_update_ptr(
+ verdicts,
+ arr_key,
+ needs_deep_clone
+ ? UCACHE_REQ_LOCAL_NEEDS_DEEP_CLONE
+ : UCACHE_REQ_LOCAL_NO_DEEP_CLONE
+ );
+ }
+
+ return needs_deep_clone;
+ default:
+ return false;
+ }
+}
+
+static bool ucache_collect_req_local_clone_verdicts(
+ zval *val,
+ HashTable *verdicts)
+{
+ HashTable seen_arrs, seen_objs;
+ bool result;
+
+ zend_hash_init(&seen_arrs, 8, NULL, NULL, 0);
+ zend_hash_init(&seen_objs, 8, NULL, NULL, 0);
+
+ result = ucache_collect_req_local_clone_verdicts_impl(
+ val,
+ &seen_arrs,
+ &seen_objs,
+ verdicts,
+ true
+ );
+
+ zend_hash_destroy(&seen_objs);
+ zend_hash_destroy(&seen_arrs);
+
+ return result;
+}
+
+static void ucache_req_local_clone_arr_dtor(zval *zv)
+{
+ zend_array *arr = Z_PTR_P(zv);
+
+ zend_array_release(arr);
+}
+
+static void ucache_req_local_clone_linked_dtor(zval *zv)
+{
+ zend_refcounted *counted = Z_PTR_P(zv);
+
+ if (GC_DELREF(counted) == 0) {
+ rc_dtor_func(counted);
+ }
+}
+
+static void ucache_req_local_clone_ctx_init(
+ ucache_req_local_clone_ctx *ctx,
+ HashTable *verdicts,
+ bool track_identity,
+ bool *proto_rejected)
+{
+ if (track_identity) {
+ zend_hash_init(&ctx->arrs, 8, NULL, ucache_req_local_clone_arr_dtor, 0);
+ zend_hash_init(&ctx->objs, 8, NULL, ucache_obj_table_dtor, 0);
+ zend_hash_init(&ctx->refs, 8, NULL, ucache_ref_table_dtor, 0);
+ }
+
+ ctx->clone_verdicts = verdicts;
+ ctx->proto_rejected = proto_rejected;
+ ctx->track_identity = track_identity;
+}
+
+static void ucache_req_local_clone_ctx_destroy(
+ ucache_req_local_clone_ctx *ctx,
+ bool linked)
+{
+ if (ctx->track_identity) {
+ if (linked) {
+ ctx->refs.pDestructor = ucache_req_local_clone_linked_dtor;
+ ctx->objs.pDestructor = ucache_req_local_clone_linked_dtor;
+ }
+
+ zend_hash_destroy(&ctx->refs);
+ zend_hash_destroy(&ctx->objs);
+ zend_hash_destroy(&ctx->arrs);
+ }
+}
+
+static bool ucache_val_needs_req_local_deep_clone_cached(
+ ucache_req_local_clone_ctx *ctx,
+ zval *val)
+{
+ void *flag;
+
+ switch (Z_TYPE_P(val)) {
+ case IS_REFERENCE:
+ case IS_OBJECT:
+ return true;
+ case IS_ARRAY:
+ break;
+ default:
+ return false;
+ }
+
+ if (ctx->clone_verdicts != NULL) {
+ flag = zend_hash_index_find_ptr(ctx->clone_verdicts, (zend_ulong) (uintptr_t) Z_ARRVAL_P(val));
+ if (flag != NULL) {
+ return flag == UCACHE_REQ_LOCAL_NEEDS_DEEP_CLONE;
+ }
+ }
+
+ return ucache_val_needs_req_local_deep_clone(val);
+}
+
+static bool ucache_clone_req_local_arr(
+ ucache_req_local_clone_ctx *ctx,
+ zval *dst,
+ zval *src,
+ bool known_needs_deep_clone)
+{
+ zend_ulong key = (zend_ulong) (uintptr_t) Z_ARRVAL_P(src);
+ zend_array *arr;
+ zval *elem, cloned_elem;
+ void *flag;
+
+ if (!known_needs_deep_clone) {
+ if (GC_FLAGS(Z_ARRVAL_P(src)) & IS_ARRAY_IMMUTABLE) {
+ ZVAL_COPY(dst, src);
+
+ return true;
+ }
+
+ if (ctx->clone_verdicts != NULL) {
+ flag = zend_hash_index_find_ptr(ctx->clone_verdicts, key);
+ if (flag == UCACHE_REQ_LOCAL_NO_DEEP_CLONE) {
+ ZVAL_COPY(dst, src);
+
+ return true;
+ } else if (flag == UCACHE_REQ_LOCAL_NEEDS_DEEP_CLONE) {
+ known_needs_deep_clone = true;
+ }
+ }
+
+ if (!known_needs_deep_clone && !ucache_val_needs_req_local_deep_clone(src)) {
+ ZVAL_COPY(dst, src);
+
+ return true;
+ }
+ }
+
+ if (ctx->track_identity) {
+ arr = zend_hash_index_find_ptr(&ctx->arrs, key);
+ if (arr != NULL) {
+ GC_ADDREF(arr);
+
+ ZVAL_ARR(dst, arr);
+
+ return true;
+ }
+ }
+
+ arr = zend_array_dup(Z_ARRVAL_P(src));
+ if (ctx->track_identity) {
+ zend_hash_index_update_ptr(&ctx->arrs, key, arr);
+ }
+
+ ZEND_HASH_FOREACH_VAL(arr, elem) {
+ if (Z_TYPE_P(elem) == IS_INDIRECT ||
+ !ucache_val_needs_req_local_deep_clone_cached(ctx, elem)
+ ) {
+ continue;
+ }
+
+ if (!ucache_clone_req_local_val(ctx, &cloned_elem, elem)) {
+ if (!ctx->track_identity) {
+ zend_array_release(arr);
+ }
+
+ ZVAL_UNDEF(dst);
+
+ return false;
+ }
+
+ zval_ptr_dtor_nogc(elem);
+
+ ZVAL_COPY_VALUE(elem, &cloned_elem);
+ } ZEND_HASH_FOREACH_END();
+
+ if (ctx->track_identity) {
+ GC_ADDREF(arr);
+ }
+
+ ZVAL_ARR(dst, arr);
+
+ return true;
+}
+
+static bool ucache_clone_req_local_ref(
+ ucache_req_local_clone_ctx *ctx,
+ zval *dst,
+ zend_reference *src_ref)
+{
+ zend_ulong key = (zend_ulong) (uintptr_t) src_ref;
+ zend_reference *new_ref;
+ zval inner;
+
+ if (ctx->track_identity) {
+ new_ref = zend_hash_index_find_ptr(&ctx->refs, key);
+ if (new_ref != NULL) {
+ GC_ADDREF(new_ref);
+
+ ZVAL_REF(dst, new_ref);
+
+ UC_G(req_local_slot_may_cycle) = true;
+
+ return true;
+ }
+ }
+
+ ZVAL_NEW_EMPTY_REF(dst);
+ new_ref = Z_REF_P(dst);
+
+ ZVAL_UNDEF(&new_ref->val);
+
+ if (ctx->track_identity) {
+ zend_hash_index_update_ptr(&ctx->refs, key, new_ref);
+ }
+
+ if (!ucache_clone_req_local_val(ctx, &inner, &src_ref->val)) {
+ if (!ctx->track_identity) {
+ zval_ptr_dtor(dst);
+ }
+
+ ZVAL_UNDEF(dst);
+
+ return false;
+ }
+
+ ZVAL_COPY_VALUE(&new_ref->val, &inner);
+
+ if (ctx->track_identity) {
+ GC_ADDREF(new_ref);
+
+ ZVAL_REF(dst, new_ref);
+ }
+
+ return true;
+}
+
+static bool ucache_clone_req_local_obj_members(
+ ucache_req_local_clone_ctx *ctx,
+ zend_object *old_obj,
+ zend_object *new_obj)
+{
+ zend_ulong num_key;
+ zend_string *key;
+ zend_property_info *prop_info;
+ zval *src, *dst, *end, *prop, new_prop;
+ uint32_t prop_idx = 0;
+
+ if (old_obj->ce->default_properties_count) {
+ src = old_obj->properties_table;
+ dst = new_obj->properties_table;
+ end = src + old_obj->ce->default_properties_count;
+
+ do {
+ if (!ucache_clone_req_local_val(ctx, &new_prop, src)) {
+ return false;
+ }
+
+ zval_ptr_dtor(dst);
+
+ ZVAL_COPY_VALUE(dst, &new_prop);
+ Z_PROP_FLAG_P(dst) = Z_PROP_FLAG_P(src);
+
+ if (Z_ISREF_P(dst) && new_obj->ce->properties_info_table != NULL) {
+ prop_info = new_obj->ce->properties_info_table[prop_idx];
+ if (prop_info != NULL &&
+ prop_info != ZEND_WRONG_PROPERTY_INFO &&
+ ZEND_TYPE_IS_SET(prop_info->type)
+ ) {
+ ZEND_REF_ADD_TYPE_SOURCE(Z_REF_P(dst), prop_info);
+ }
+ }
+
+ src++;
+ dst++;
+ prop_idx++;
+ } while (src != end);
+ }
+
+ if (old_obj->properties != NULL && zend_hash_num_elements(old_obj->properties) != 0) {
+ if (new_obj->properties != NULL) {
+ zend_hash_clean(new_obj->properties);
+ zend_hash_extend(
+ new_obj->properties,
+ zend_hash_num_elements(old_obj->properties),
+ 0
+ );
+ } else {
+ new_obj->properties = zend_new_array(zend_hash_num_elements(old_obj->properties));
+
+ zend_hash_real_init_mixed(new_obj->properties);
+ }
+
+ HT_FLAGS(new_obj->properties) |=
+ HT_FLAGS(old_obj->properties) & HASH_FLAG_HAS_EMPTY_IND
+ ;
+
+ ZEND_HASH_MAP_FOREACH_KEY_VAL(old_obj->properties, num_key, key, prop) {
+ if (Z_TYPE_P(prop) == IS_INDIRECT) {
+ ZVAL_INDIRECT(
+ &new_prop,
+ new_obj->properties_table + (Z_INDIRECT_P(prop) - old_obj->properties_table)
+ );
+ } else if (!ucache_clone_req_local_val(ctx, &new_prop, prop)) {
+ return false;
+ }
+
+ if (key != NULL) {
+ _zend_hash_append(new_obj->properties, key, &new_prop);
+ } else {
+ zend_hash_index_add_new(new_obj->properties, num_key, &new_prop);
+ }
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ return true;
+}
+
+static bool ucache_clone_req_local_std_obj(
+ ucache_req_local_clone_ctx *ctx,
+ zend_object *old_obj,
+ zend_object **new_obj_ptr)
+{
+ zend_object *new_obj;
+ zval *src, *dst, *end;
+ void *member_flag = NULL;
+
+ new_obj = zend_objects_new(old_obj->ce);
+
+ if (ctx->track_identity) {
+ zend_hash_index_update_ptr(
+ &ctx->objs,
+ (zend_ulong) (uintptr_t) old_obj,
+ new_obj
+ );
+ }
+
+ if (ctx->clone_verdicts != NULL) {
+ member_flag = zend_hash_index_find_ptr(
+ ctx->clone_verdicts,
+ (zend_ulong) (uintptr_t) old_obj
+ );
+ }
+
+ if (member_flag == UCACHE_REQ_LOCAL_NO_DEEP_CLONE &&
+ old_obj->properties == NULL
+ ) {
+ if (old_obj->ce->default_properties_count) {
+ src = old_obj->properties_table;
+ dst = new_obj->properties_table;
+ end = src + old_obj->ce->default_properties_count;
+
+ memcpy(dst, src, sizeof(zval) * old_obj->ce->default_properties_count);
+
+ do {
+ if (Z_REFCOUNTED_P(src)) {
+ Z_ADDREF_P(src);
+ }
+
+ src++;
+ } while (src != end);
+ }
+ } else {
+ if (new_obj->ce->default_properties_count) {
+ dst = new_obj->properties_table;
+ end = dst + new_obj->ce->default_properties_count;
+
+ do {
+ ZVAL_UNDEF(dst);
+
+ dst++;
+ } while (dst != end);
+ }
+
+ if (!ucache_clone_req_local_obj_members(ctx, old_obj, new_obj)) {
+ if (!ctx->track_identity) {
+ OBJ_RELEASE(new_obj);
+ }
+
+ return false;
+ }
+ }
+
+ if (ctx->track_identity) {
+ GC_ADDREF(new_obj);
+ }
+
+ *new_obj_ptr = new_obj;
+
+ return true;
+}
+
+static bool ucache_clone_req_local_val_cb(
+ void *ctx,
+ zval *dst,
+ zval *src)
+{
+ return ucache_clone_req_local_val(
+ (ucache_req_local_clone_ctx *) ctx,
+ dst,
+ src
+ );
+}
+
+static bool ucache_clone_req_local_safe_direct_obj(
+ ucache_req_local_clone_ctx *ctx,
+ zend_object *old_obj,
+ zend_object **new_obj_ptr)
+{
+ php_ucache_safe_direct_copy_func_t copy_func;
+ zend_ulong key;
+ zend_class_entry *ce;
+ zend_object *new_obj;
+ zval new_zv;
+
+ ce = old_obj->ce;
+
+ copy_func = ucache_safe_direct_copy_func(ce);
+ if (copy_func == NULL) {
+ if (ctx->proto_rejected != NULL) {
+ *ctx->proto_rejected = true;
+ }
+
+ return false;
+ }
+
+ ZVAL_UNDEF(&new_zv);
+
+ if (object_init_ex(&new_zv, ce) != SUCCESS) {
+ return false;
+ }
+
+ new_obj = Z_OBJ(new_zv);
+ key = (zend_ulong) (uintptr_t) old_obj;
+
+ if (ctx->track_identity) {
+ zend_hash_index_update_ptr(&ctx->objs, key, new_obj);
+ }
+
+ if (!copy_func(
+ ctx,
+ new_obj,
+ old_obj,
+ ucache_clone_req_local_val_cb
+ )
+ ) {
+ if (ctx->proto_rejected != NULL) {
+ *ctx->proto_rejected = true;
+ }
+
+ if (!ctx->track_identity) {
+ OBJ_RELEASE(new_obj);
+ }
+
+ return false;
+ }
+
+ if (!ucache_clone_req_local_obj_members(ctx, old_obj, new_obj)) {
+ if (!ctx->track_identity) {
+ OBJ_RELEASE(new_obj);
+ }
+
+ return false;
+ }
+
+ if (ctx->track_identity) {
+ GC_ADDREF(new_obj);
+ }
+
+ *new_obj_ptr = new_obj;
+
+ return true;
+}
+
+static bool ucache_clone_req_local_obj(
+ ucache_req_local_clone_ctx *ctx,
+ zend_object *old_obj,
+ zend_object **new_obj_ptr)
+{
+ zend_ulong key;
+ zend_object *new_obj;
+
+ if (old_obj == NULL || zend_object_is_lazy(old_obj)) {
+ return false;
+ }
+
+ if (old_obj->ce->ce_flags & ZEND_ACC_ENUM) {
+ GC_ADDREF(old_obj);
+
+ *new_obj_ptr = old_obj;
+
+ return true;
+ }
+
+ if (ctx->track_identity) {
+ key = (zend_ulong) (uintptr_t) old_obj;
+ new_obj = zend_hash_index_find_ptr(&ctx->objs, key);
+
+ if (new_obj != NULL) {
+ GC_ADDREF(new_obj);
+
+ *new_obj_ptr = new_obj;
+
+ UC_G(req_local_slot_may_cycle) = true;
+
+ return true;
+ }
+ }
+
+ if (ctx->proto_rejected != NULL && ucache_proto_rejects_obj(old_obj)) {
+ *ctx->proto_rejected = true;
+
+ return false;
+ }
+
+ if (old_obj->handlers == zend_get_std_object_handlers()) {
+ return ucache_clone_req_local_std_obj(ctx, old_obj, new_obj_ptr);
+ }
+
+ return ucache_clone_req_local_safe_direct_obj(ctx, old_obj, new_obj_ptr);
+}
+
+static bool ucache_clone_req_local_val(
+ ucache_req_local_clone_ctx *ctx,
+ zval *dst,
+ zval *src)
+{
+ zend_object *obj;
+
+ if (ucache_stack_exhausted()) {
+ ZVAL_UNDEF(dst);
+
+ return false;
+ }
+
+ switch (Z_TYPE_P(src)) {
+ case IS_REFERENCE:
+ return ucache_clone_req_local_ref(ctx, dst, Z_REF_P(src));
+ case IS_ARRAY:
+ return ucache_clone_req_local_arr(ctx, dst, src, false);
+ case IS_OBJECT:
+ if (!ucache_clone_req_local_obj(ctx, Z_OBJ_P(src), &obj)) {
+ return false;
+ }
+
+ ZVAL_OBJ(dst, obj);
+
+ return true;
+ default:
+ ZVAL_COPY(dst, src);
+
+ return true;
+ }
+}
+
+static HashTable *ucache_req_local_slots(void)
+{
+ HashTable **slots_ptr = &UC_G(req_local_slot_table);
+
+ ucache_req_local_slots_check_fork();
+
+ if (*slots_ptr == NULL) {
+ ALLOC_HASHTABLE(*slots_ptr);
+
+ zend_hash_init(*slots_ptr, 0, NULL, ucache_req_local_slot_dtor, 0);
+
+ UC_G(req_local_slot_owner_pid) = ucache_cached_pid();
+ }
+
+ return *slots_ptr;
+}
+
+static void ucache_replace_req_local_slot(
+ zend_string *key,
+ ucache_req_local_slot *slot)
+{
+ ucache_req_local_slot *old_slot;
+ HashTable *slots = ucache_req_local_slots();
+ zval *entry, old_zv;
+
+ entry = zend_hash_lookup(slots, key);
+ if (Z_TYPE_P(entry) == IS_NULL) {
+ ZVAL_PTR(entry, slot);
+
+ return;
+ }
+
+ old_slot = Z_PTR_P(entry);
+
+ ZVAL_PTR(entry, slot);
+
+ ZVAL_PTR(&old_zv, old_slot);
+
+ ucache_req_local_slot_dtor(&old_zv);
+}
+
+static void ucache_release_req_local_slot_keys(
+ HashTable **slots_ptr,
+ HashTable *slots,
+ zend_string **keys,
+ uint32_t count)
+{
+ uint32_t i;
+
+ for (i = 0; i < count; i++) {
+ if (*slots_ptr == slots) {
+ zend_hash_del(slots, keys[i]);
+ }
+
+ zend_string_release(keys[i]);
+ }
+
+ efree(keys);
+
+ if (*slots_ptr == slots && zend_hash_num_elements(slots) == 0) {
+ ucache_release_req_local_slot_table(slots_ptr);
+ }
+}
+
+static bool ucache_req_local_slots_can_make_room(size_t bytes)
+{
+ size_t budget = ucache_req_cache_budget(), record_bytes;
+
+ ZEND_ASSERT(UC_G(record_val_bytes) >= UC_G(req_local_slot_bytes));
+
+ record_bytes = UC_G(record_val_bytes) - UC_G(req_local_slot_bytes);
+
+ return record_bytes <= budget && bytes <= budget - record_bytes;
+}
+
+static void ucache_reject_req_local_proto(zend_string *key, uint64_t gen)
+{
+ ucache_req_local_slot *slot = ucache_find_req_local_slot(key, gen);
+
+ if (slot != NULL && !slot->has_val) {
+ slot->proto_rejected = true;
+ }
+}
+
+static zend_never_inline void ucache_release_req_local_slot_impl(zend_string *key)
+{
+ HashTable *slots = UC_G(req_local_slot_table);
+ zval *entry, detached_slot;
+
+ if ((entry = zend_hash_find(slots, key)) == NULL) {
+ return;
+ }
+
+ ZVAL_COPY_VALUE(&detached_slot, entry);
+ ZVAL_PTR(entry, NULL);
+
+ zend_hash_del(slots, key);
+
+ if (zend_hash_num_elements(slots) == 0) {
+ ucache_release_req_local_slot_table(&UC_G(req_local_slot_table));
+ }
+
+ ucache_req_local_slot_dtor(&detached_slot);
+}
+
+void ucache_req_local_slot_free(ucache_req_local_slot *slot)
+{
+ if (slot == NULL) {
+ return;
+ }
+
+ if (slot->clone_depth != 0) {
+ slot->released_while_cloning = true;
+
+ return;
+ }
+
+ if (slot->has_clone_verdicts) {
+ zend_hash_destroy(ucache_req_local_slot_verdicts(slot));
+ }
+
+ if (slot->has_val) {
+ UC_G(record_val_bytes) -= Z_EXTRA(slot->val);
+ UC_G(req_local_slot_bytes) -= Z_EXTRA(slot->val);
+ }
+
+ if (!Z_ISUNDEF(slot->val)) {
+ zval_ptr_dtor(&slot->val);
+ }
+
+ efree(slot);
+}
+
+bool ucache_clone_req_local_slot_val_known(
+ zval *dst,
+ zval *src,
+ HashTable *verdicts,
+ bool no_aliases,
+ bool *proto_rejected)
+{
+ ucache_req_local_clone_ctx ctx;
+ zend_array *arr;
+ bool result;
+
+ ucache_req_local_clone_ctx_init(&ctx, verdicts, !no_aliases, proto_rejected);
+
+ if (Z_TYPE_P(src) == IS_ARRAY) {
+ result = ucache_clone_req_local_arr(&ctx, dst, src, true);
+ } else {
+ result = ucache_clone_req_local_val(&ctx, dst, src);
+ }
+
+ if (!result && proto_rejected != NULL && ctx.track_identity) {
+ ZEND_HASH_FOREACH_PTR(&ctx.arrs, arr) {
+ HT_ALLOW_COW_VIOLATION(arr);
+
+ zend_hash_clean(arr);
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ ucache_req_local_clone_ctx_destroy(&ctx, result);
+
+ return result;
+}
+
+zend_never_inline void ucache_req_local_slots_drop_oldest_half(void)
+{
+ zend_string *key, **keys;
+ HashTable *slots, **slots_ptr = &UC_G(req_local_slot_table);
+ uint32_t drop, count = 0;
+
+ slots = *slots_ptr;
+ if (slots == NULL) {
+ return;
+ }
+
+ drop = zend_hash_num_elements(slots) - zend_hash_num_elements(slots) / 2;
+ keys = safe_emalloc(drop, sizeof(zend_string *), 0);
+ ZEND_HASH_FOREACH_STR_KEY(slots, key) {
+ if (count == drop) {
+ break;
+ }
+
+ keys[count++] = zend_string_copy(key);
+ } ZEND_HASH_FOREACH_END();
+
+ ucache_release_req_local_slot_keys(slots_ptr, slots, keys, count);
+}
+
+zend_never_inline void ucache_store_req_local_slot(
+ zend_string *key,
+ uint64_t gen,
+ zval *val,
+ bool no_aliases,
+ uint32_t charged_bytes)
+{
+ ucache_req_local_slot *slot;
+ HashTable verdicts, *slot_verdicts;
+ zval slot_zv;
+ bool needs_deep_clone = false, has_verdicts = false, proto_rejected = false;
+
+ if (!ucache_record_val_fits(charged_bytes)) {
+ if (UC_G(req_local_slot_bytes) < ucache_req_cache_budget() / 2) {
+ return;
+ }
+
+ if (!ucache_req_local_slots_can_make_room(charged_bytes)) {
+ ucache_reject_req_local_proto(key, gen);
+
+ return;
+ }
+
+ ucache_req_local_slots_drop_oldest_half();
+
+ if (!ucache_record_val_fits(charged_bytes)) {
+ return;
+ }
+ }
+
+ ZVAL_DEREF(val);
+
+ if (Z_TYPE_P(val) == IS_ARRAY || Z_TYPE_P(val) == IS_OBJECT) {
+ zend_hash_init(&verdicts, 8, NULL, NULL, 0);
+
+ has_verdicts = true;
+ needs_deep_clone = ucache_collect_req_local_clone_verdicts(
+ val,
+ &verdicts
+ );
+ }
+
+ slot = ucache_alloc_req_local_slot(gen, no_aliases, true, needs_deep_clone);
+
+ if (!ucache_clone_req_local_slot_val_known(
+ &slot->val,
+ val,
+ has_verdicts ? &verdicts : NULL,
+ no_aliases,
+ &proto_rejected
+ )
+ ) {
+ if (has_verdicts) {
+ zend_hash_destroy(&verdicts);
+ }
+
+ slot->has_val = false;
+
+ ZVAL_PTR(&slot_zv, slot);
+
+ ucache_req_local_slot_dtor(&slot_zv);
+
+ if (proto_rejected) {
+ ucache_reject_req_local_proto(key, gen);
+ }
+
+ return;
+ }
+
+ if (has_verdicts) {
+ zend_hash_destroy(&verdicts);
+ }
+
+ if (needs_deep_clone) {
+ slot_verdicts = ucache_req_local_slot_verdicts(slot);
+
+ zend_hash_init(slot_verdicts, 8, NULL, NULL, 0);
+
+ ucache_collect_req_local_clone_verdicts(&slot->val, slot_verdicts);
+
+ slot->has_clone_verdicts = true;
+ }
+
+ if (!ucache_record_val_fits(charged_bytes)) {
+ slot->has_val = false;
+
+ ucache_req_local_slot_free(slot);
+
+ return;
+ }
+
+ Z_EXTRA(slot->val) = charged_bytes;
+
+ UC_G(record_val_bytes) += charged_bytes;
+ UC_G(req_local_slot_bytes) += charged_bytes;
+
+ ucache_replace_req_local_slot(key, slot);
+}
+
+void ucache_mark_req_local_slot(zend_string *key, uint64_t gen)
+{
+ ucache_req_local_slot *slot = ucache_alloc_req_local_slot(gen, true, false, false);
+
+ ucache_replace_req_local_slot(key, slot);
+}
+
+void ucache_obj_table_dtor(zval *zv)
+{
+ zend_object *obj = Z_PTR_P(zv);
+
+ OBJ_RELEASE(obj);
+}
+
+void ucache_ref_table_dtor(zval *zv)
+{
+ zval ref_zv;
+
+ ZVAL_REF(&ref_zv, (zend_reference *) Z_PTR_P(zv));
+
+ zval_ptr_dtor(&ref_zv);
+}
+
+void ucache_release_req_local_slots(void)
+{
+ ucache_release_req_local_slot_table(&UC_G(req_local_slot_table));
+}
+
+void ucache_release_req_local_slot(zend_string *key)
+{
+ if (UC_G(req_local_slot_table) != NULL) {
+ ucache_release_req_local_slot_impl(key);
+ }
+}
+
+void ucache_release_active_req_local_slots_by_prefix(zend_string *prefix)
+{
+ zend_string *key, **keys;
+ HashTable *slots, **slots_ptr = &UC_G(req_local_slot_table);
+ uint32_t slot_count, count = 0;
+
+ if (*slots_ptr == NULL) {
+ return;
+ }
+
+ slot_count = zend_hash_num_elements(*slots_ptr);
+ if (slot_count == 0) {
+ ucache_release_req_local_slot_table(slots_ptr);
+
+ return;
+ }
+
+ slots = *slots_ptr;
+ keys = safe_emalloc(slot_count, sizeof(zend_string *), 0);
+ ZEND_HASH_FOREACH_STR_KEY(slots, key) {
+ if (key != NULL &&
+ ZSTR_LEN(key) >= ZSTR_LEN(prefix) &&
+ memcmp(
+ ZSTR_VAL(key),
+ ZSTR_VAL(prefix),
+ ZSTR_LEN(prefix)
+ ) == 0
+ ) {
+ keys[count++] = zend_string_copy(key);
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ ucache_release_req_local_slot_keys(slots_ptr, slots, keys, count);
+}
diff --git a/ext/user_cache/user_cache_segment.c b/ext/user_cache/user_cache_segment.c
new file mode 100644
index 000000000000..2f528e061c3f
--- /dev/null
+++ b/ext/user_cache/user_cache_segment.c
@@ -0,0 +1,1961 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_storage.h"
+
+#include "ext/hash/php_hash.h"
+#include "ext/hash/php_hash_sha.h"
+#include "ext/random/php_random_csprng.h"
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+# define UCACHE_BOUNDARY_DIR_PREFIX ".PhpUserCacheBnd."
+# define UCACHE_BOUNDARY_SALT_NAME "salt"
+# define UCACHE_BOUNDARY_SHM_PREFIX "/PUC."
+# define UCACHE_BOUNDARY_LOCK_SUFFIX ".lock"
+# define UCACHE_BOUNDARY_TAG_PREFIX "PhpUserCache.boundary-tag|"
+# define UCACHE_BOUNDARY_NAME_SIZE 32U
+# define UCACHE_BOUNDARY_ATTACH_BYTE 1
+# define UCACHE_BOUNDARY_ATTACH_ATTEMPTS 8U
+# define UCACHE_BOUNDARY_DEADLOCK_RETRY_US 1000U
+# define UCACHE_BOUNDARY_IDLE_SEG_MAX UCACHE_MAX_BOUNDARY_PARTITIONS
+#endif
+
+#ifdef ZEND_WIN32
+# define UCACHE_WIN32_SALT_SIZE 32U
+# define UCACHE_WIN32_SALT_READ_ATTEMPTS 1000U
+# define UCACHE_WIN32_PRIVATE_DIR_PREFIX L"PhpUserCache."
+# define UCACHE_WIN32_SALT_NAME L"salt"
+# define UCACHE_WIN32_OWNER_ONLY_SDDL L"D:P(A;;GA;;;%ls)"
+#endif
+
+#ifdef ZEND_WIN32
+typedef struct {
+ union {
+ TOKEN_USER info;
+ uint8_t bytes[sizeof(TOKEN_USER) + SECURITY_MAX_SID_SIZE];
+ } user;
+ union {
+ TOKEN_OWNER info;
+ uint8_t bytes[sizeof(TOKEN_OWNER) + SECURITY_MAX_SID_SIZE];
+ } owner;
+} ucache_win32_token_sids;
+#endif
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+typedef struct {
+ uint8_t boundary_tag[32];
+ uint64_t last_used_us;
+ uint64_t superseded_at_us;
+} ucache_boundary_lock_record;
+
+typedef struct {
+ char id[UCACHE_BOUNDARY_ID_LEN + 1];
+ uint64_t last_used_us;
+} ucache_boundary_idle_seg;
+#endif
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+static bool ucache_boundary_dir_failure_logged = false;
+static atomic_bool ucache_boundary_dir_failure_pending = false;
+static char ucache_boundary_dir_failure_msg[MAXPATHLEN + 192];
+static ucache_boundary_seg *ucache_boundary_segs = NULL;
+#endif
+
+#ifdef ZEND_WIN32
+static uint8_t ucache_win32_salt[UCACHE_WIN32_SALT_SIZE];
+static bool ucache_win32_salt_loaded = false;
+#endif
+
+#ifdef UCACHE_USE_MMAP
+static int ucache_wrap_mapped_seg(
+ void *mapping,
+ size_t requested_size,
+ ucache_shm_seg **shared_seg_p,
+ const char **err_in)
+{
+ ucache_shm_seg *seg;
+
+ seg = (ucache_shm_seg *) calloc(1, sizeof(*seg));
+ if (seg == NULL) {
+ munmap(mapping, requested_size);
+
+ *err_in = "calloc";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ seg->p = mapping;
+ seg->size = requested_size;
+ seg->zero_filled = true;
+ *shared_seg_p = seg;
+
+ return UCACHE_ALLOC_SUCCESS;
+}
+
+static void ucache_munmap_detach_seg(ucache_shm_seg *shared_seg)
+{
+ munmap(shared_seg->p, shared_seg->size);
+}
+
+static int ucache_mmap_create_seg(
+ size_t requested_size,
+ ucache_shm_seg **shared_seg_p,
+ const char **err_in)
+{
+ void *mapping;
+
+ mapping = mmap(NULL, requested_size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
+ if (mapping == MAP_FAILED) {
+ *err_in = "mmap";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ return ucache_wrap_mapped_seg(mapping, requested_size, shared_seg_p, err_in);
+}
+#endif /* UCACHE_USE_MMAP */
+
+#if defined(UCACHE_HAVE_BOUNDARY_SHM) || defined(ZEND_WIN32)
+static void ucache_sha256_update_build_identity(PHP_SHA256_CTX *sha_ctx)
+{
+ const uint32_t *node_sizes;
+ const size_t layout[] = {
+ sizeof(ucache_hdr),
+ offsetof(ucache_hdr, pool_bucket_heads),
+ offsetof(ucache_hdr, boundary_identity_digest),
+ offsetof(ucache_hdr, graph_pin_slots),
+ offsetof(ucache_hdr, reader_slots),
+ offsetof(ucache_hdr, commit_failure_count),
+ offsetof(ucache_hdr, stale_tail_end),
+ sizeof(ucache_entry),
+ sizeof(ucache_pool_links),
+ sizeof(ucache_entry_lock_record),
+ UCACHE_OFFSET_SHIFT,
+ };
+ uint32_t node_count;
+
+ node_sizes = ucache_sgraph_node_sizes(&node_count);
+
+ PHP_SHA256Update(sha_ctx, (const uint8_t *) zend_system_id, sizeof(zend_system_id));
+ PHP_SHA256Update(sha_ctx, (const uint8_t *) layout, sizeof(layout));
+ PHP_SHA256Update(sha_ctx, (const uint8_t *) node_sizes, node_count * sizeof(*node_sizes));
+
+ if (UCACHE_DEBUG_FAULT("SIMULATE_OTHER_BUILD")) {
+ PHP_SHA256Update(sha_ctx, (const uint8_t *) "other-build", sizeof("other-build") - 1);
+ }
+}
+#endif
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+static void ucache_shared_boundary_digest_to_identity(
+ const uint8_t digest[32],
+ uint64_t *identity_high,
+ uint32_t *identity_low)
+{
+ *identity_high =
+ ((uint64_t) digest[0] << 56) |
+ ((uint64_t) digest[1] << 48) |
+ ((uint64_t) digest[2] << 40) |
+ ((uint64_t) digest[3] << 32) |
+ ((uint64_t) digest[4] << 24) |
+ ((uint64_t) digest[5] << 16) |
+ ((uint64_t) digest[6] << 8) |
+ (uint64_t) digest[7]
+ ;
+ *identity_low =
+ ((uint32_t) digest[8] << 24) |
+ ((uint32_t) digest[9] << 16) |
+ ((uint32_t) digest[10] << 8) |
+ (uint32_t) digest[11]
+ ;
+}
+
+static void ucache_shared_boundary_identity(
+ ucache_ctx *ctx,
+ size_t requested_size,
+ uint64_t *identity_high,
+ uint32_t *identity_low)
+{
+ ucache_shared_boundary_digest_to_identity(
+ ucache_shared_boundary_digest_memo(ctx, requested_size),
+ identity_high,
+ identity_low
+ );
+}
+
+static void ucache_shared_boundary_format_id(char id[UCACHE_BOUNDARY_ID_LEN + 1], size_t requested_size)
+{
+ uint64_t identity_high;
+ uint32_t identity_low;
+
+ ucache_shared_boundary_identity(
+ ucache_active_ctx(),
+ requested_size,
+ &identity_high,
+ &identity_low
+ );
+
+ snprintf(id, UCACHE_BOUNDARY_ID_LEN + 1, "%016" PRIx64 "%08" PRIx32, identity_high, identity_low);
+}
+
+static void ucache_shared_boundary_shm_name(char name[UCACHE_BOUNDARY_NAME_SIZE], const char *id)
+{
+ snprintf(name, UCACHE_BOUNDARY_NAME_SIZE, UCACHE_BOUNDARY_SHM_PREFIX "%s", id);
+}
+
+static void ucache_shared_boundary_lock_name(char name[UCACHE_BOUNDARY_NAME_SIZE], const char *id)
+{
+ snprintf(name, UCACHE_BOUNDARY_NAME_SIZE, "%s" UCACHE_BOUNDARY_LOCK_SUFFIX, id);
+}
+
+static bool ucache_shared_boundary_lock_name_to_id(const char *name, char id[UCACHE_BOUNDARY_ID_LEN + 1])
+{
+ uint32_t i;
+
+ if (strlen(name) != UCACHE_BOUNDARY_ID_LEN + sizeof(UCACHE_BOUNDARY_LOCK_SUFFIX) - 1 ||
+ strcmp(name + UCACHE_BOUNDARY_ID_LEN, UCACHE_BOUNDARY_LOCK_SUFFIX) != 0
+ ) {
+ return false;
+ }
+
+ for (i = 0; i < UCACHE_BOUNDARY_ID_LEN; i++) {
+ if (!((name[i] >= '0' && name[i] <= '9') || (name[i] >= 'a' && name[i] <= 'f'))) {
+ return false;
+ }
+
+ id[i] = name[i];
+ }
+
+ id[UCACHE_BOUNDARY_ID_LEN] = '\0';
+
+ return true;
+}
+
+static uint64_t ucache_shared_boundary_now_us(void)
+{
+ struct timeval tv;
+
+ if (gettimeofday(&tv, NULL) != 0) {
+ return 0;
+ }
+
+ return (uint64_t) tv.tv_sec * UINT64_C(1000000) + (uint64_t) tv.tv_usec;
+}
+
+static void ucache_shared_boundary_log_dir_failure(const char *dir_path, const char *reason)
+{
+ if (ucache_boundary_dir_failure_logged) {
+ return;
+ }
+
+ ucache_boundary_dir_failure_logged = true;
+
+ snprintf(
+ ucache_boundary_dir_failure_msg,
+ sizeof(ucache_boundary_dir_failure_msg),
+ "UserCache: boundary directory %s is unusable (%s); it must be a directory owned by uid %lu "
+ "with mode 0700 (see user_cache.lockfile_path)",
+ dir_path,
+ reason,
+ (unsigned long) geteuid()
+ );
+
+ atomic_store(&ucache_boundary_dir_failure_pending, true);
+}
+
+static bool ucache_shared_boundary_fd_is_trusted(int fd, struct stat *st)
+{
+ if (fstat(fd, st) != 0) {
+ return false;
+ }
+
+ return st->st_uid == geteuid() && (st->st_mode & 0077) == 0;
+}
+
+static int ucache_shared_boundary_open_private_dir(char *dir_path, size_t dir_path_size, const char **err_in)
+{
+ struct stat st;
+ int fd, saved_errno, len;
+
+ len = snprintf(
+ dir_path,
+ dir_path_size,
+ "%s/" UCACHE_BOUNDARY_DIR_PREFIX "%lu",
+ UC_G(lockfile_path),
+ (unsigned long) geteuid()
+ );
+ if (len < 0 || (size_t) len >= dir_path_size) {
+ errno = ENAMETOOLONG;
+
+ *err_in = "lockfile_path";
+
+ return -1;
+ }
+
+ if (mkdir(dir_path, 0700) != 0 && errno != EEXIST) {
+ saved_errno = errno;
+
+ ucache_shared_boundary_log_dir_failure(dir_path, strerror(saved_errno));
+
+ errno = saved_errno;
+
+ *err_in = "mkdir";
+
+ return -1;
+ }
+
+ fd = open(dir_path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
+ if (fd < 0) {
+ saved_errno = errno;
+
+ ucache_shared_boundary_log_dir_failure(dir_path, strerror(saved_errno));
+
+ errno = saved_errno;
+
+ *err_in = "open directory";
+
+ return -1;
+ }
+
+ if (!ucache_shared_boundary_fd_is_trusted(fd, &st) || !S_ISDIR(st.st_mode)) {
+ close(fd);
+
+ ucache_shared_boundary_log_dir_failure(dir_path, "not a private directory owned by this uid");
+
+ errno = EACCES;
+
+ *err_in = "directory ownership";
+
+ return -1;
+ }
+
+ return fd;
+}
+
+static bool ucache_shared_boundary_read_salt(
+ int dir_fd,
+ const char *dir_path,
+ uint8_t *salt,
+ bool *missing,
+ const char **err_in)
+{
+ struct stat st;
+ size_t got = 0;
+ ssize_t n;
+ int fd;
+
+ *missing = false;
+
+ fd = openat(dir_fd, UCACHE_BOUNDARY_SALT_NAME, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
+ if (fd < 0) {
+ *missing = errno == ENOENT;
+ *err_in = "open salt";
+
+ return false;
+ }
+
+ if (!ucache_shared_boundary_fd_is_trusted(fd, &st) ||
+ !S_ISREG(st.st_mode) ||
+ st.st_size != UCACHE_BOUNDARY_SALT_SIZE
+ ) {
+ close(fd);
+ ucache_shared_boundary_log_dir_failure(
+ dir_path,
+ "its " UCACHE_BOUNDARY_SALT_NAME " file is not a private regular file of the expected size owned by this uid"
+ );
+
+ errno = EACCES;
+
+ *err_in = "salt ownership";
+
+ return false;
+ }
+
+ while (got < UCACHE_BOUNDARY_SALT_SIZE) {
+ n = read(fd, salt + got, UCACHE_BOUNDARY_SALT_SIZE - got);
+ if (n < 0 && errno == EINTR) {
+ continue;
+ }
+
+ if (n <= 0) {
+ close(fd);
+ if (n == 0) {
+ errno = EIO;
+ }
+
+ *err_in = "read salt";
+
+ return false;
+ }
+
+ got += (size_t) n;
+ }
+
+ close(fd);
+
+ return true;
+}
+
+static bool ucache_shared_boundary_create_salt(int dir_fd, const char **err_in)
+{
+ uint8_t salt[UCACHE_BOUNDARY_SALT_SIZE];
+ size_t written = 0;
+ ssize_t n;
+ char tmp_name[64];
+ int fd, saved_errno;
+
+ snprintf(tmp_name, sizeof(tmp_name), UCACHE_BOUNDARY_SALT_NAME ".%lu", (unsigned long) getpid());
+
+ fd = openat(dir_fd, tmp_name, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
+ if (fd < 0 && errno == EEXIST) {
+ unlinkat(dir_fd, tmp_name, 0);
+ fd = openat(dir_fd, tmp_name, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
+ }
+
+ if (fd < 0) {
+ *err_in = "create salt";
+
+ return false;
+ }
+
+ if (php_random_bytes_silent(salt, sizeof(salt)) == FAILURE) {
+ close(fd);
+ unlinkat(dir_fd, tmp_name, 0);
+
+ errno = EIO;
+
+ *err_in = "php_random_bytes";
+
+ return false;
+ }
+
+ while (written < sizeof(salt)) {
+ n = write(fd, salt + written, sizeof(salt) - written);
+ if (n < 0 && errno == EINTR) {
+ continue;
+ }
+
+ if (n <= 0) {
+ saved_errno = n == 0 ? EIO : errno;
+ close(fd);
+ unlinkat(dir_fd, tmp_name, 0);
+
+ errno = saved_errno;
+
+ *err_in = "write salt";
+
+ return false;
+ }
+
+ written += (size_t) n;
+ }
+
+ close(fd);
+
+ if (linkat(dir_fd, tmp_name, dir_fd, UCACHE_BOUNDARY_SALT_NAME, 0) != 0 && errno != EEXIST) {
+ saved_errno = errno;
+ unlinkat(dir_fd, tmp_name, 0);
+
+ errno = saved_errno;
+
+ *err_in = "linkat";
+
+ return false;
+ }
+
+ unlinkat(dir_fd, tmp_name, 0);
+
+ return true;
+}
+
+static bool ucache_shared_boundary_load_salt(const char **err_in)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+ uint32_t attempt;
+ char dir_path[MAXPATHLEN];
+ int dir_fd, saved_errno;
+ bool missing, loaded = false;
+
+ if (storage->boundary_salt_loaded) {
+ return true;
+ }
+
+ dir_fd = ucache_shared_boundary_open_private_dir(dir_path, sizeof(dir_path), err_in);
+ if (dir_fd < 0) {
+ return false;
+ }
+
+ for (attempt = 0; attempt < 2 && !loaded; attempt++) {
+ loaded = ucache_shared_boundary_read_salt(dir_fd, dir_path, storage->boundary_salt, &missing, err_in);
+ if (loaded || !missing || !ucache_shared_boundary_create_salt(dir_fd, err_in)) {
+ break;
+ }
+ }
+
+ saved_errno = errno;
+
+ close(dir_fd);
+
+ errno = saved_errno;
+
+ storage->boundary_salt_loaded = loaded;
+
+ return loaded;
+}
+
+static int ucache_shared_boundary_open_shm_fd(
+ const char *shm_name,
+ size_t requested_size,
+ bool *created,
+ const char **err_in)
+{
+ struct stat st;
+ uint32_t attempts, create_attempts;
+ int fd;
+
+ *created = false;
+
+ for (create_attempts = 0; create_attempts < 2; create_attempts++) {
+ fd = shm_open(shm_name, O_RDWR | O_CREAT | O_EXCL, 0600);
+ if (fd >= 0) {
+ if (ftruncate(fd, (off_t) requested_size) != 0) {
+ close(fd);
+ shm_unlink(shm_name);
+
+ *err_in = "ftruncate";
+
+ return -1;
+ }
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_IN_BOUNDARY_SEGMENT_CREATE");
+
+ if (!ucache_shm_fd_reserve(fd, requested_size)) {
+ close(fd);
+ shm_unlink(shm_name);
+
+ *err_in = "posix_fallocate";
+
+ return -1;
+ }
+
+ *created = true;
+
+ return fd;
+ }
+
+ if (errno != EEXIST) {
+ *err_in = "shm_open";
+
+ return -1;
+ }
+
+ fd = shm_open(shm_name, O_RDWR, 0600);
+ if (fd < 0) {
+ *err_in = "shm_open";
+
+ return -1;
+ }
+
+ if (!ucache_shared_boundary_fd_is_trusted(fd, &st)) {
+ close(fd);
+
+ errno = EACCES;
+ *err_in = "shm ownership";
+
+ return -1;
+ }
+
+ for (attempts = 0; (size_t) st.st_size < requested_size && attempts < 1000; attempts++) {
+ if (st.st_size != 0) {
+ break;
+ }
+
+ usleep(1000);
+
+ if (fstat(fd, &st) != 0) {
+ break;
+ }
+ }
+
+ if ((size_t) st.st_size >= requested_size) {
+ if (!ucache_shm_fd_reserve(fd, requested_size)) {
+ close(fd);
+
+ *err_in = "posix_fallocate";
+
+ return -1;
+ }
+
+ return fd;
+ }
+
+ close(fd);
+
+ if (st.st_size == 0 && create_attempts == 0) {
+ shm_unlink(shm_name);
+
+ continue;
+ }
+
+ errno = EINVAL;
+ *err_in = "shm size";
+
+ return -1;
+ }
+
+ errno = EINVAL;
+ *err_in = "shm size";
+
+ return -1;
+}
+
+static void ucache_shared_boundary_tag(const ucache_ctx *ctx, uint8_t tag[32])
+{
+ PHP_SHA256_CTX sha_ctx;
+
+ ZEND_ASSERT(ucache_ctx_is_boundary(ctx) && ctx->storage.boundary_salt_loaded);
+
+ PHP_SHA256Init(&sha_ctx);
+ PHP_SHA256Update(
+ &sha_ctx,
+ (const uint8_t *) UCACHE_BOUNDARY_TAG_PREFIX,
+ sizeof(UCACHE_BOUNDARY_TAG_PREFIX) - 1
+ );
+ PHP_SHA256Update(&sha_ctx, (const uint8_t *) ctx->boundary_identity, ctx->boundary_identity_len);
+ PHP_SHA256Update(&sha_ctx, ctx->storage.boundary_salt, sizeof(ctx->storage.boundary_salt));
+ PHP_SHA256Final(tag, &sha_ctx);
+}
+
+static bool ucache_shared_boundary_set_attach_lock(int fd, short lock_type, bool wait)
+{
+ struct flock attach_lock;
+
+ attach_lock.l_type = lock_type;
+ attach_lock.l_whence = SEEK_SET;
+ attach_lock.l_start = UCACHE_BOUNDARY_ATTACH_BYTE;
+ attach_lock.l_len = 1;
+
+ while (fcntl(fd, wait ? F_SETLKW : F_SETLK, &attach_lock) != 0) {
+ if (wait && errno == EDEADLK) {
+ ucache_platform.sleep_us(UCACHE_BOUNDARY_DEADLOCK_RETRY_US);
+ } else if (errno != EINTR) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool ucache_shared_boundary_name_is_file(int dir_fd, const char *name, const struct stat *st)
+{
+ struct stat named;
+
+ return fstatat(dir_fd, name, &named, AT_SYMLINK_NOFOLLOW) == 0 &&
+ named.st_dev == st->st_dev &&
+ named.st_ino == st->st_ino
+ ;
+}
+
+static bool ucache_shared_boundary_pread(int fd, void *buf, size_t len, off_t offset)
+{
+ ssize_t n;
+
+ do {
+ n = pread(fd, buf, len, offset);
+ } while (n < 0 && errno == EINTR);
+
+ return n == (ssize_t) len;
+}
+
+static bool ucache_shared_boundary_pwrite(int fd, const void *buf, size_t len, off_t offset)
+{
+ ssize_t n;
+
+ do {
+ n = pwrite(fd, buf, len, offset);
+ } while (n < 0 && errno == EINTR);
+
+ return n == (ssize_t) len;
+}
+
+static bool ucache_shared_boundary_file_is_attached_here(const struct stat *st)
+{
+ const ucache_boundary_seg *seg;
+
+ for (seg = ucache_boundary_segs; seg != NULL; seg = seg->next) {
+ if (seg->lock_dev == (uint64_t) st->st_dev && seg->lock_ino == (uint64_t) st->st_ino) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static bool ucache_shared_boundary_attach_lock_file(ucache_boundary_seg *seg, const char **err_in)
+{
+ struct stat st;
+ char lock_name[UCACHE_BOUNDARY_NAME_SIZE];
+ uint32_t attempt;
+ int fd;
+
+ ucache_shared_boundary_lock_name(lock_name, seg->id);
+
+ for (attempt = 0; attempt < UCACHE_BOUNDARY_ATTACH_ATTEMPTS; attempt++) {
+ fd = openat(seg->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
+ if (fd < 0) {
+ *err_in = "open lock file";
+
+ return false;
+ }
+
+ if (!ucache_shared_boundary_fd_is_trusted(fd, &st) || !S_ISREG(st.st_mode)) {
+ close(fd);
+
+ errno = EACCES;
+ *err_in = "lock file ownership";
+
+ return false;
+ }
+
+ if (!ucache_shared_boundary_set_attach_lock(fd, F_RDLCK, true)) {
+ close(fd);
+
+ *err_in = "fcntl";
+
+ return false;
+ }
+
+ if (ucache_shared_boundary_name_is_file(seg->dir_fd, lock_name, &st)) {
+ seg->lock_fd = fd;
+ seg->lock_dev = (uint64_t) st.st_dev;
+ seg->lock_ino = (uint64_t) st.st_ino;
+
+ return true;
+ }
+
+ close(fd);
+ }
+
+ errno = EAGAIN;
+ *err_in = "lock file";
+
+ return false;
+}
+
+static void ucache_shared_boundary_write_record(const ucache_boundary_seg *seg, const uint8_t tag[32])
+{
+ ucache_boundary_lock_record record;
+
+ memcpy(record.boundary_tag, tag, sizeof(record.boundary_tag));
+
+ record.last_used_us = ucache_shared_boundary_now_us();
+ record.superseded_at_us = 0;
+
+ ucache_shared_boundary_pwrite(seg->lock_fd, &record, sizeof(record), 0);
+}
+
+static bool ucache_shared_boundary_seg_exists(const char *id)
+{
+ char shm_name[UCACHE_BOUNDARY_NAME_SIZE];
+ int fd;
+
+ ucache_shared_boundary_shm_name(shm_name, id);
+
+ fd = shm_open(shm_name, O_RDONLY, 0);
+ if (fd < 0) {
+ return errno != ENOENT;
+ }
+
+ close(fd);
+
+ return true;
+}
+
+static void ucache_shared_boundary_remove_idle_seg(
+ int dir_fd,
+ const char *id,
+ const struct stat *lock_st)
+{
+ char shm_name[UCACHE_BOUNDARY_NAME_SIZE], lock_name[UCACHE_BOUNDARY_NAME_SIZE];
+
+ ucache_shared_boundary_shm_name(shm_name, id);
+ ucache_shared_boundary_lock_name(lock_name, id);
+
+ if (!ucache_shared_boundary_name_is_file(dir_fd, lock_name, lock_st) ||
+ (shm_unlink(shm_name) != 0 && errno != ENOENT)
+ ) {
+ return;
+ }
+
+ unlinkat(dir_fd, lock_name, 0);
+}
+
+static int ucache_shared_boundary_open_foreign_lock_file(int dir_fd, const char *id, struct stat *st)
+{
+ char lock_name[UCACHE_BOUNDARY_NAME_SIZE];
+ int fd;
+
+ ucache_shared_boundary_lock_name(lock_name, id);
+
+ if (fstatat(dir_fd, lock_name, st, AT_SYMLINK_NOFOLLOW) != 0 ||
+ !S_ISREG(st->st_mode) ||
+ ucache_shared_boundary_file_is_attached_here(st)
+ ) {
+ return -1;
+ }
+
+ fd = openat(dir_fd, lock_name, O_RDWR | O_CLOEXEC | O_NOFOLLOW);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (!ucache_shared_boundary_fd_is_trusted(fd, st)) {
+ close(fd);
+
+ return -1;
+ }
+
+ return fd;
+}
+
+static bool ucache_shared_boundary_probe_idle_seg(
+ int dir_fd,
+ const char *id,
+ const uint8_t tag[32],
+ uint64_t *last_used_us)
+{
+ ucache_boundary_lock_record record;
+ struct stat st;
+ int fd;
+ bool has_record, same_boundary, idle;
+
+ fd = ucache_shared_boundary_open_foreign_lock_file(dir_fd, id, &st);
+ if (fd < 0) {
+ return false;
+ }
+
+ has_record = ucache_shared_boundary_pread(fd, &record, sizeof(record), 0);
+ same_boundary = has_record && memcmp(record.boundary_tag, tag, sizeof(record.boundary_tag)) == 0;
+ idle = ucache_shared_boundary_set_attach_lock(fd, F_WRLCK, false);
+
+ if (!idle && same_boundary && record.superseded_at_us == 0) {
+ record.superseded_at_us = ucache_shared_boundary_now_us();
+
+ ucache_shared_boundary_pwrite(
+ fd,
+ &record.superseded_at_us,
+ sizeof(record.superseded_at_us),
+ offsetof(ucache_boundary_lock_record, superseded_at_us)
+ );
+ }
+
+ if (idle && (same_boundary || !ucache_shared_boundary_seg_exists(id))) {
+ ucache_shared_boundary_remove_idle_seg(dir_fd, id, &st);
+
+ idle = false;
+ }
+
+ *last_used_us = has_record ? record.last_used_us : 0;
+
+ close(fd);
+
+ return idle;
+}
+
+static void ucache_shared_boundary_evict_idle_seg(int dir_fd, const char *id)
+{
+ struct stat st;
+ int fd;
+
+ fd = ucache_shared_boundary_open_foreign_lock_file(dir_fd, id, &st);
+ if (fd < 0) {
+ return;
+ }
+
+ if (ucache_shared_boundary_set_attach_lock(fd, F_WRLCK, false)) {
+ ucache_shared_boundary_remove_idle_seg(dir_fd, id, &st);
+ }
+
+ close(fd);
+}
+
+static int ucache_shared_boundary_idle_seg_cmp(const void *a, const void *b)
+{
+ uint64_t a_used = ((const ucache_boundary_idle_seg *) a)->last_used_us;
+ uint64_t b_used = ((const ucache_boundary_idle_seg *) b)->last_used_us;
+
+ return (a_used > b_used) - (a_used < b_used);
+}
+
+static void ucache_shared_boundary_idle_seg_swap(void *a, void *b)
+{
+ ucache_boundary_idle_seg tmp = *(ucache_boundary_idle_seg *) a;
+
+ *(ucache_boundary_idle_seg *) a = *(ucache_boundary_idle_seg *) b;
+ *(ucache_boundary_idle_seg *) b = tmp;
+}
+
+static bool ucache_shared_boundary_append_idle_seg(
+ ucache_boundary_idle_seg **idle,
+ size_t *count,
+ size_t *capacity,
+ const char *id,
+ uint64_t last_used_us)
+{
+ ucache_boundary_idle_seg *grown;
+ size_t new_capacity;
+
+ if (*count == *capacity) {
+ new_capacity = *capacity != 0 ? *capacity * 2 : UCACHE_BOUNDARY_IDLE_SEG_MAX * 2;
+ grown = realloc(*idle, new_capacity * sizeof(**idle));
+ if (grown == NULL) {
+ return false;
+ }
+
+ *idle = grown;
+ *capacity = new_capacity;
+ }
+
+ memcpy((*idle)[*count].id, id, sizeof((*idle)[*count].id));
+
+ (*idle)[*count].last_used_us = last_used_us;
+ (*count)++;
+
+ return true;
+}
+
+static void ucache_shared_boundary_sweep_idle_segs(const ucache_boundary_seg *self, const uint8_t tag[32])
+{
+ ucache_boundary_idle_seg *idle = NULL;
+ struct dirent *dirent;
+ uint64_t last_used_us;
+ size_t count = 0, capacity = 0, i;
+ char id[UCACHE_BOUNDARY_ID_LEN + 1];
+ DIR *dir;
+ int scan_fd;
+ bool listed = true;
+
+ scan_fd = openat(self->dir_fd, ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (scan_fd < 0) {
+ return;
+ }
+
+ dir = fdopendir(scan_fd);
+ if (dir == NULL) {
+ close(scan_fd);
+
+ return;
+ }
+
+ while (listed && (dirent = readdir(dir)) != NULL) {
+ if (ucache_shared_boundary_lock_name_to_id(dirent->d_name, id) &&
+ ucache_shared_boundary_probe_idle_seg(self->dir_fd, id, tag, &last_used_us)
+ ) {
+ listed = ucache_shared_boundary_append_idle_seg(&idle, &count, &capacity, id, last_used_us);
+ }
+ }
+
+ closedir(dir);
+
+ if (listed && count > UCACHE_BOUNDARY_IDLE_SEG_MAX) {
+ zend_sort(
+ idle,
+ count,
+ sizeof(*idle),
+ ucache_shared_boundary_idle_seg_cmp,
+ ucache_shared_boundary_idle_seg_swap
+ );
+
+ for (i = 0; i < count - UCACHE_BOUNDARY_IDLE_SEG_MAX; i++) {
+ ucache_shared_boundary_evict_idle_seg(self->dir_fd, idle[i].id);
+ }
+ }
+
+ free(idle);
+}
+
+static void ucache_shared_boundary_close_seg(ucache_boundary_seg *seg)
+{
+ if (seg->lock_fd >= 0) {
+ close(seg->lock_fd);
+ }
+
+ if (seg->dir_fd >= 0) {
+ close(seg->dir_fd);
+ }
+
+ free(seg);
+}
+
+static int ucache_shared_boundary_create_seg(
+ size_t requested_size,
+ ucache_shm_seg **shared_seg_p,
+ const char **err_in)
+{
+ ucache_boundary_seg *seg;
+ struct stat shm_st;
+ uint8_t tag[32];
+ char dir_path[MAXPATHLEN], shm_name[UCACHE_BOUNDARY_NAME_SIZE];
+ void *mapping;
+ int shm_fd;
+ bool created;
+
+ if (requested_size > (size_t) SSIZE_MAX) {
+ *err_in = "size overflow";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (!ucache_shared_boundary_load_salt(err_in)) {
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (UCACHE_DEBUG_FAULT("FAIL_BOUNDARY_SEGMENT")) {
+ *err_in = "mmap";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ seg = (ucache_boundary_seg *) calloc(1, sizeof(*seg));
+ if (seg == NULL) {
+ *err_in = "calloc";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ seg->lock_fd = -1;
+
+ ucache_shared_boundary_format_id(seg->id, requested_size);
+ ucache_shared_boundary_shm_name(shm_name, seg->id);
+
+ seg->dir_fd = ucache_shared_boundary_open_private_dir(dir_path, sizeof(dir_path), err_in);
+ if (seg->dir_fd < 0 || !ucache_shared_boundary_attach_lock_file(seg, err_in)) {
+ ucache_shared_boundary_close_seg(seg);
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ shm_fd = ucache_shared_boundary_open_shm_fd(shm_name, requested_size, &created, err_in);
+ if (shm_fd < 0) {
+ ucache_shared_boundary_close_seg(seg);
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (fstat(shm_fd, &shm_st) == 0) {
+ seg->shm_dev = (uint64_t) shm_st.st_dev;
+ seg->shm_ino = (uint64_t) shm_st.st_ino;
+ }
+
+ mapping = mmap(NULL, requested_size, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
+ close(shm_fd);
+
+ if (mapping == MAP_FAILED) {
+ if (created) {
+ shm_unlink(shm_name);
+ }
+
+ ucache_shared_boundary_close_seg(seg);
+
+ *err_in = "mmap";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ seg->seg.p = mapping;
+ seg->seg.size = requested_size;
+ seg->next = ucache_boundary_segs;
+
+ ucache_boundary_segs = seg;
+
+ ucache_shared_boundary_tag(ucache_active_ctx(), tag);
+ ucache_shared_boundary_write_record(seg, tag);
+ ucache_shared_boundary_sweep_idle_segs(seg, tag);
+
+ *shared_seg_p = &seg->seg;
+
+ return UCACHE_ALLOC_SUCCESS;
+}
+
+static void ucache_shared_boundary_detach_seg(ucache_shm_seg *shared_seg)
+{
+ ucache_boundary_seg *seg = (ucache_boundary_seg *) shared_seg, **link;
+ struct stat lock_st;
+ uint64_t now_us, superseded_at_us;
+
+ munmap(seg->seg.p, seg->seg.size);
+
+ for (link = &ucache_boundary_segs; *link != NULL; link = &(*link)->next) {
+ if (*link == seg) {
+ *link = seg->next;
+
+ break;
+ }
+ }
+
+ now_us = ucache_shared_boundary_now_us();
+
+ ucache_shared_boundary_pwrite(
+ seg->lock_fd,
+ &now_us,
+ sizeof(now_us),
+ offsetof(ucache_boundary_lock_record, last_used_us)
+ );
+
+ if (ucache_shared_boundary_pread(
+ seg->lock_fd,
+ &superseded_at_us,
+ sizeof(superseded_at_us),
+ offsetof(ucache_boundary_lock_record, superseded_at_us)
+ ) &&
+ superseded_at_us != 0 &&
+ ucache_shared_boundary_set_attach_lock(seg->lock_fd, F_WRLCK, false) &&
+ fstat(seg->lock_fd, &lock_st) == 0
+ ) {
+ ucache_shared_boundary_remove_idle_seg(seg->dir_fd, seg->id, &lock_st);
+ }
+
+ close(seg->lock_fd);
+ close(seg->dir_fd);
+}
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+
+#ifdef ZEND_WIN32
+static void ucache_win32_set_seg(
+ ucache_win32_seg *seg,
+ HANDLE memfile,
+ void *mapping_base,
+ size_t requested_size)
+{
+ seg->memfile = memfile;
+ seg->seg.p = mapping_base;
+ seg->seg.size = requested_size;
+}
+
+static bool ucache_win32_load_token_sids(ucache_win32_token_sids *sids)
+{
+ HANDLE token;
+ DWORD len;
+ bool loaded;
+
+ if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
+ return false;
+ }
+
+ loaded = GetTokenInformation(token, TokenUser, sids->user.bytes, sizeof(sids->user.bytes), &len) &&
+ GetTokenInformation(token, TokenOwner, sids->owner.bytes, sizeof(sids->owner.bytes), &len)
+ ;
+
+ CloseHandle(token);
+
+ return loaded;
+}
+
+static PSECURITY_DESCRIPTOR ucache_win32_owner_only_descriptor(void)
+{
+ ucache_win32_token_sids sids;
+ PSECURITY_DESCRIPTOR descriptor = NULL;
+ wchar_t *sid_str, sddl[256];
+ int sddl_len;
+
+ if (!ucache_win32_load_token_sids(&sids) ||
+ !ConvertSidToStringSidW(sids.user.info.User.Sid, &sid_str)
+ ) {
+ return NULL;
+ }
+
+ sddl_len = swprintf(sddl, sizeof(sddl) / sizeof(sddl[0]), UCACHE_WIN32_OWNER_ONLY_SDDL, sid_str);
+
+ LocalFree(sid_str);
+
+ if (sddl_len < 0 ||
+ !ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &descriptor, NULL)
+ ) {
+ return NULL;
+ }
+
+ return descriptor;
+}
+
+static bool ucache_win32_private_dir_is_trusted(const wchar_t *dir)
+{
+ DWORD attributes = GetFileAttributesW(dir);
+ HANDLE handle;
+ bool owned;
+
+ if (attributes == INVALID_FILE_ATTRIBUTES ||
+ (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 ||
+ (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0
+ ) {
+ SetLastError(ERROR_ACCESS_DENIED);
+
+ return false;
+ }
+
+ handle = CreateFileW(
+ dir,
+ READ_CONTROL,
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+ NULL,
+ OPEN_EXISTING,
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
+ NULL
+ );
+ if (handle == INVALID_HANDLE_VALUE) {
+ return false;
+ }
+
+ owned = ucache_win32_owned_by_cur_user(handle, SE_FILE_OBJECT);
+
+ CloseHandle(handle);
+
+ return owned;
+}
+
+static bool ucache_win32_open_private_dir(wchar_t *dir, size_t dir_size, SECURITY_ATTRIBUTES *attributes)
+{
+ ucache_win32_token_sids sids;
+ wchar_t *sid_str;
+ DWORD len;
+ int written;
+
+ len = GetTempPathW((DWORD) dir_size, dir);
+ if (len == 0 || len >= dir_size ||
+ !ucache_win32_load_token_sids(&sids) ||
+ !ConvertSidToStringSidW(sids.user.info.User.Sid, &sid_str)
+ ) {
+ return false;
+ }
+
+ written = swprintf(dir + len, dir_size - len, L"%ls%ls", UCACHE_WIN32_PRIVATE_DIR_PREFIX, sid_str);
+
+ LocalFree(sid_str);
+
+ if (written < 0 || (size_t) written >= dir_size - len) {
+ return false;
+ }
+
+ if (!CreateDirectoryW(dir, attributes) && GetLastError() != ERROR_ALREADY_EXISTS) {
+ return false;
+ }
+
+ return ucache_win32_private_dir_is_trusted(dir);
+}
+
+static bool ucache_win32_read_salt(const wchar_t *path, bool *missing)
+{
+ HANDLE file;
+ DWORD read_len = 0;
+ uint32_t attempt;
+ bool loaded = false;
+
+ *missing = false;
+
+ for (attempt = 0; attempt < UCACHE_WIN32_SALT_READ_ATTEMPTS; attempt++) {
+ file = CreateFileW(
+ path,
+ GENERIC_READ,
+ FILE_SHARE_READ,
+ NULL,
+ OPEN_EXISTING,
+ FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
+ NULL
+ );
+ if (file != INVALID_HANDLE_VALUE) {
+ break;
+ }
+
+ if (GetLastError() == ERROR_FILE_NOT_FOUND) {
+ *missing = true;
+
+ return false;
+ }
+
+ if (GetLastError() != ERROR_SHARING_VIOLATION) {
+ return false;
+ }
+
+ Sleep(1);
+ }
+
+ if (file == INVALID_HANDLE_VALUE) {
+ return false;
+ }
+
+ if (ucache_win32_owned_by_cur_user(file, SE_FILE_OBJECT) &&
+ ReadFile(file, ucache_win32_salt, sizeof(ucache_win32_salt), &read_len, NULL) &&
+ read_len == sizeof(ucache_win32_salt)
+ ) {
+ loaded = true;
+ }
+
+ CloseHandle(file);
+
+ return loaded;
+}
+
+static bool ucache_win32_create_salt(const wchar_t *dir, const wchar_t *path, SECURITY_ATTRIBUTES *attributes)
+{
+ HANDLE file;
+ DWORD written_len = 0;
+ uint8_t salt[UCACHE_WIN32_SALT_SIZE];
+ wchar_t tmp_path[MAXPATHLEN];
+ int written;
+ bool created;
+
+ written = swprintf(
+ tmp_path,
+ sizeof(tmp_path) / sizeof(tmp_path[0]),
+ L"%ls\\%ls.%lu",
+ dir,
+ UCACHE_WIN32_SALT_NAME,
+ (unsigned long) GetCurrentProcessId()
+ );
+ if (written < 0 || php_random_bytes_silent(salt, sizeof(salt)) == FAILURE) {
+ return false;
+ }
+
+ file = CreateFileW(tmp_path, GENERIC_WRITE, 0, attributes, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (file == INVALID_HANDLE_VALUE) {
+ return false;
+ }
+
+ created = WriteFile(file, salt, sizeof(salt), &written_len, NULL) && written_len == sizeof(salt);
+
+ CloseHandle(file);
+
+ if (created && !MoveFileExW(tmp_path, path, MOVEFILE_WRITE_THROUGH) && GetLastError() != ERROR_ALREADY_EXISTS) {
+ created = false;
+ }
+
+ DeleteFileW(tmp_path);
+
+ return created;
+}
+
+static HANDLE ucache_win32_create_owned_mutex(const char *name, const char **err_in)
+{
+ SECURITY_ATTRIBUTES attributes;
+ PSECURITY_DESCRIPTOR descriptor = ucache_win32_owner_only_descriptor();
+ HANDLE mutex;
+
+ if (descriptor == NULL) {
+ *err_in = "owner-only descriptor";
+
+ return NULL;
+ }
+
+ attributes.nLength = sizeof(attributes);
+ attributes.lpSecurityDescriptor = descriptor;
+ attributes.bInheritHandle = FALSE;
+
+ mutex = CreateMutexA(&attributes, FALSE, name);
+
+ LocalFree(descriptor);
+
+ if (mutex == NULL) {
+ *err_in = "CreateMutexA";
+
+ return NULL;
+ }
+
+ if (GetLastError() == ERROR_ALREADY_EXISTS && !ucache_win32_owned_by_cur_user(mutex, SE_KERNEL_OBJECT)) {
+ CloseHandle(mutex);
+
+ *err_in = "mutex owner";
+
+ return NULL;
+ }
+
+ return mutex;
+}
+
+static bool ucache_win32_view_spans(void *mapping_base, size_t requested_size)
+{
+ MEMORY_BASIC_INFORMATION info;
+ char *cursor = mapping_base;
+ size_t spanned = 0;
+
+ while (spanned < requested_size) {
+ if (VirtualQuery(cursor, &info, sizeof(info)) == 0 ||
+ info.AllocationBase != mapping_base ||
+ info.Type != MEM_MAPPED ||
+ info.RegionSize == 0
+ ) {
+ return false;
+ }
+
+ spanned += info.RegionSize;
+ cursor += info.RegionSize;
+ }
+
+ return true;
+}
+
+static int ucache_win32_reattach_seg(
+ ucache_win32_seg *seg,
+ HANDLE memfile,
+ size_t requested_size,
+ const char **err_in)
+{
+ void *mapping_base;
+
+ mapping_base = MapViewOfFileEx(memfile, FILE_MAP_ALL_ACCESS, 0, 0, 0, NULL);
+ if (mapping_base == NULL) {
+ *err_in = "MapViewOfFileEx";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (!ucache_win32_view_spans(mapping_base, requested_size)) {
+ UnmapViewOfFile(mapping_base);
+
+ *err_in = "VirtualQuery";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ ucache_win32_set_seg(seg, memfile, mapping_base, requested_size);
+
+ return UCACHE_ALLOC_SUCCESS;
+}
+
+static int ucache_win32_create_mapping(
+ ucache_win32_seg *seg,
+ const char *mapping_name,
+ size_t requested_size,
+ const char **err_in)
+{
+ SECURITY_ATTRIBUTES attributes;
+ PSECURITY_DESCRIPTOR descriptor = NULL;
+ HANDLE memfile;
+ DWORD size_high, size_low, last_error;
+ int result;
+ void *mapping_base;
+
+#if defined(_WIN64)
+ size_high = (DWORD) (requested_size >> 32);
+ size_low = (DWORD) (requested_size & 0xffffffff);
+#else
+ if (requested_size > UINT32_MAX) {
+ *err_in = "size overflow";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ size_high = 0;
+ size_low = (DWORD) requested_size;
+#endif /* _WIN64 */
+
+ if (mapping_name != NULL) {
+ descriptor = ucache_win32_owner_only_descriptor();
+ if (descriptor == NULL) {
+ *err_in = "owner-only descriptor";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ attributes.nLength = sizeof(attributes);
+ attributes.lpSecurityDescriptor = descriptor;
+ attributes.bInheritHandle = FALSE;
+ }
+
+ memfile = CreateFileMappingA(
+ INVALID_HANDLE_VALUE,
+ descriptor != NULL ? &attributes : NULL,
+ PAGE_READWRITE | SEC_RESERVE,
+ size_high,
+ size_low,
+ mapping_name
+ );
+ last_error = GetLastError();
+
+ if (descriptor != NULL) {
+ LocalFree(descriptor);
+ }
+
+ if (memfile == NULL) {
+ *err_in = "CreateFileMappingA";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (mapping_name != NULL && last_error == ERROR_ALREADY_EXISTS) {
+ if (!ucache_win32_owned_by_cur_user(memfile, SE_KERNEL_OBJECT)) {
+ CloseHandle(memfile);
+
+ *err_in = "mapping owner";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ result = ucache_win32_reattach_seg(seg, memfile, requested_size, err_in);
+ if (result != UCACHE_ALLOC_SUCCESS) {
+ CloseHandle(memfile);
+ }
+
+ return result;
+ }
+
+ mapping_base = MapViewOfFileEx(memfile, FILE_MAP_ALL_ACCESS, 0, 0, 0, NULL);
+ if (mapping_base == NULL) {
+ CloseHandle(memfile);
+
+ *err_in = "MapViewOfFileEx";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ ucache_win32_set_seg(seg, memfile, mapping_base, requested_size);
+
+ seg->seg.zero_filled = mapping_name == NULL;
+
+ return UCACHE_ALLOC_SUCCESS;
+}
+
+static int ucache_win32_create_seg(
+ size_t requested_size,
+ ucache_shm_seg **shared_seg_p,
+ const char **err_in)
+{
+ ucache_win32_seg *seg;
+ HANDLE mutex, memfile;
+ DWORD wait_result;
+ int result;
+ char mapping_name[MAXPATHLEN], mutex_name[MAXPATHLEN];
+
+ seg = (ucache_win32_seg *) calloc(1, sizeof(*seg));
+ if (seg == NULL) {
+ *err_in = "calloc";
+
+ return UCACHE_ALLOC_FAILURE;
+ }
+
+ if (ucache_win32_ctx_is_proc_private(ucache_active_ctx())) {
+ result = ucache_win32_create_mapping(seg, NULL, requested_size, err_in);
+ if (result != UCACHE_ALLOC_SUCCESS) {
+ goto bailout;
+ }
+
+ *shared_seg_p = &seg->seg;
+
+ return UCACHE_ALLOC_SUCCESS;
+ }
+
+ if (!ucache_win32_load_salt(err_in)) {
+ goto bailout;
+ }
+
+ ucache_win32_format_name(
+ mapping_name,
+ sizeof(mapping_name),
+ UCACHE_WIN32_MAPPING_NAME,
+ requested_size
+ );
+ ucache_win32_format_name(
+ mutex_name,
+ sizeof(mutex_name),
+ UCACHE_WIN32_MAPPING_MUTEX_NAME,
+ requested_size
+ );
+
+ mutex = ucache_win32_create_owned_mutex(mutex_name, err_in);
+ if (mutex == NULL) {
+ goto bailout;
+ }
+
+ wait_result = WaitForSingleObject(mutex, INFINITE);
+ if (wait_result != WAIT_OBJECT_0 && wait_result != WAIT_ABANDONED) {
+ CloseHandle(mutex);
+
+ *err_in = "WaitForSingleObject";
+
+ goto bailout;
+ }
+
+ memfile = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, mapping_name);
+ if (memfile != NULL && !ucache_win32_owned_by_cur_user(memfile, SE_KERNEL_OBJECT)) {
+ CloseHandle(memfile);
+
+ *err_in = "mapping owner";
+ result = UCACHE_ALLOC_FAILURE;
+ } else if (memfile != NULL) {
+ result = ucache_win32_reattach_seg(seg, memfile, requested_size, err_in);
+ if (result != UCACHE_ALLOC_SUCCESS) {
+ CloseHandle(memfile);
+ }
+ } else {
+ result = ucache_win32_create_mapping(seg, mapping_name, requested_size, err_in);
+ }
+
+ ReleaseMutex(mutex);
+ CloseHandle(mutex);
+
+ if (result != UCACHE_ALLOC_SUCCESS) {
+ goto bailout;
+ }
+
+ *shared_seg_p = &seg->seg;
+
+ return UCACHE_ALLOC_SUCCESS;
+
+bailout:
+ free(seg);
+
+ return UCACHE_ALLOC_FAILURE;
+}
+
+static void ucache_win32_detach_seg(ucache_shm_seg *shared_seg)
+{
+ ucache_win32_seg *seg = (ucache_win32_seg *) shared_seg;
+
+ UnmapViewOfFile(seg->seg.p);
+ CloseHandle(seg->memfile);
+}
+#endif /* ZEND_WIN32 */
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+void ucache_shared_boundary_digest(
+ const ucache_ctx *ctx,
+ size_t requested_size,
+ uint8_t digest[32])
+{
+ PHP_SHA256_CTX sha_ctx;
+ uint32_t lock_capacity;
+ size_t update_len = 0;
+ bool clamped;
+ int prefix_len;
+ char prefix[128];
+
+ ZEND_ASSERT(ucache_ctx_is_boundary(ctx));
+
+ lock_capacity = ucache_calc_entry_lock_capacity(
+ ucache_calc_capacity(requested_size, &clamped)
+ );
+
+ prefix_len = snprintf(
+ prefix,
+ sizeof(prefix),
+ "PhpUserCache.boundary-shm|%zu|" ZEND_LONG_FMT "|%u|%zu|",
+ requested_size,
+ UC_G(entries_hint),
+ lock_capacity,
+ ctx->boundary_identity_len
+ );
+
+ PHP_SHA256Init(&sha_ctx);
+
+ if (prefix_len > 0) {
+ update_len = (size_t) prefix_len;
+ if (update_len >= sizeof(prefix)) {
+ update_len = sizeof(prefix) - 1;
+ }
+
+ PHP_SHA256Update(&sha_ctx, (const uint8_t *) prefix, update_len);
+ }
+
+ ucache_sha256_update_build_identity(&sha_ctx);
+
+ PHP_SHA256Update(&sha_ctx, (const uint8_t *) ctx->boundary_identity, ctx->boundary_identity_len);
+
+ ZEND_ASSERT(ctx->storage.boundary_salt_loaded);
+
+ PHP_SHA256Update(&sha_ctx, ctx->storage.boundary_salt, sizeof(ctx->storage.boundary_salt));
+ PHP_SHA256Final(digest, &sha_ctx);
+}
+
+void ucache_shared_boundary_retire_seg_name(void)
+{
+ ucache_boundary_seg *seg = ucache_boundary_seg_of(&ucache_active_ctx()->storage);
+ struct stat st;
+ char shm_name[UCACHE_BOUNDARY_NAME_SIZE];
+ int fd;
+
+ ucache_shared_boundary_shm_name(shm_name, seg->id);
+
+ fd = shm_open(shm_name, O_RDONLY, 0);
+ if (fd < 0) {
+ return;
+ }
+
+ if (fstat(fd, &st) == 0 &&
+ seg->shm_ino != 0 &&
+ (uint64_t) st.st_dev == seg->shm_dev &&
+ (uint64_t) st.st_ino == seg->shm_ino
+ ) {
+ shm_unlink(shm_name);
+ }
+
+ close(fd);
+}
+
+void ucache_shared_boundary_flush_dir_failure_log(void)
+{
+ if (atomic_exchange(&ucache_boundary_dir_failure_pending, false)) {
+ ucache_log_err(ucache_boundary_dir_failure_msg);
+ }
+}
+
+void ucache_shared_boundary_segs_after_fork(void)
+{
+ const ucache_boundary_seg *seg;
+
+ for (seg = ucache_boundary_segs; seg != NULL; seg = seg->next) {
+ ucache_shared_boundary_set_attach_lock(seg->lock_fd, F_RDLCK, false);
+ }
+}
+
+const ucache_shm_handler_entry *ucache_shared_boundary_handler_entry(void)
+{
+ static const ucache_shm_handlers handlers = {
+ ucache_shared_boundary_create_seg,
+ ucache_shared_boundary_detach_seg
+ };
+ static const ucache_shm_handler_entry entry = {
+ "boundary-shm", &handlers
+ };
+
+ return &entry;
+}
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+
+#ifdef ZEND_WIN32
+bool ucache_win32_ctx_is_proc_private(const ucache_ctx *ctx)
+{
+ return ctx == &ucache_ctx_state;
+}
+
+bool ucache_win32_owned_by_cur_user(HANDLE handle, SE_OBJECT_TYPE type)
+{
+ ucache_win32_token_sids sids;
+ PSECURITY_DESCRIPTOR descriptor = NULL;
+ PSID owner = NULL;
+ bool owned;
+
+ if (handle == NULL || handle == INVALID_HANDLE_VALUE || !ucache_win32_load_token_sids(&sids) ||
+ GetSecurityInfo(handle, type, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, &descriptor) != ERROR_SUCCESS
+ ) {
+ return false;
+ }
+
+ owned = owner != NULL && (
+ EqualSid(owner, sids.user.info.User.Sid) ||
+ EqualSid(owner, sids.owner.info.Owner)
+ );
+
+ LocalFree(descriptor);
+
+ if (!owned) {
+ SetLastError(ERROR_ACCESS_DENIED);
+ }
+
+ return owned;
+}
+
+bool ucache_win32_load_salt(const char **err_in)
+{
+ SECURITY_ATTRIBUTES attributes;
+ PSECURITY_DESCRIPTOR descriptor;
+ wchar_t dir[MAXPATHLEN], path[MAXPATHLEN];
+ uint32_t attempt;
+ int written;
+ bool missing;
+
+ if (ucache_win32_salt_loaded) {
+ return true;
+ }
+
+ descriptor = ucache_win32_owner_only_descriptor();
+ if (descriptor == NULL) {
+ *err_in = "owner-only descriptor";
+
+ return false;
+ }
+
+ attributes.nLength = sizeof(attributes);
+ attributes.lpSecurityDescriptor = descriptor;
+ attributes.bInheritHandle = FALSE;
+
+ if (!ucache_win32_open_private_dir(dir, sizeof(dir) / sizeof(dir[0]), &attributes)) {
+ LocalFree(descriptor);
+
+ *err_in = "private directory";
+
+ return false;
+ }
+
+ written = swprintf(path, sizeof(path) / sizeof(path[0]), L"%ls\\%ls", dir, UCACHE_WIN32_SALT_NAME);
+
+ for (attempt = 0; written >= 0 && attempt < 2 && !ucache_win32_salt_loaded; attempt++) {
+ ucache_win32_salt_loaded = ucache_win32_read_salt(path, &missing);
+ if (ucache_win32_salt_loaded || !missing || !ucache_win32_create_salt(dir, path, &attributes)) {
+ break;
+ }
+ }
+
+ LocalFree(descriptor);
+
+ if (!ucache_win32_salt_loaded) {
+ *err_in = "salt";
+ }
+
+ return ucache_win32_salt_loaded;
+}
+
+void ucache_win32_format_name(char *buf, size_t buf_size, const char *name, size_t unique_id)
+{
+ const char *sapi_name = sapi_module.name != NULL ? sapi_module.name : "", *identity;
+ ucache_ctx *ctx = ucache_active_ctx();
+ PHP_SHA256_CTX sha_ctx;
+ DWORD user_name_len = UNLEN + 1;
+ uint8_t digest[32];
+ size_t identity_len;
+ wchar_t user_name[UNLEN + 1];
+ int layout_len;
+ char layout[256], name_id[40];
+ bool clamped;
+
+ identity = ctx->boundary_identity != NULL
+ ? ctx->boundary_identity
+ : (ctx->lock_name != NULL ? ctx->lock_name : "")
+ ;
+ identity_len = ctx->boundary_identity != NULL
+ ? ctx->boundary_identity_len
+ : strlen(identity)
+ ;
+
+ layout_len = snprintf(
+ layout,
+ sizeof(layout),
+ "PhpUserCache.win32|%zx|" ZEND_LONG_FMT "|%u|%.20s|%lu|%zu|",
+ unique_id,
+ UC_G(entries_hint),
+ ucache_calc_entry_lock_capacity(ucache_calc_capacity(unique_id, &clamped)),
+ sapi_name,
+ ucache_win32_ctx_is_proc_private(ctx) ? (unsigned long) GetCurrentProcessId() : 0UL,
+ identity_len
+ );
+
+ PHP_SHA256Init(&sha_ctx);
+
+ /* Several accounts can share one session namespace (e.g. IIS app pools). */
+ if (GetUserNameW(user_name, &user_name_len) && user_name_len != 0) {
+ PHP_SHA256Update(&sha_ctx, (const uint8_t *) user_name, (user_name_len - 1) * sizeof(wchar_t));
+ }
+
+ if (layout_len > 0) {
+ PHP_SHA256Update(&sha_ctx, (const uint8_t *) layout, MIN((size_t) layout_len, sizeof(layout) - 1));
+ }
+
+ ucache_sha256_update_build_identity(&sha_ctx);
+
+ if (!ucache_win32_ctx_is_proc_private(ctx) && ucache_win32_salt_loaded) {
+ PHP_SHA256Update(&sha_ctx, ucache_win32_salt, sizeof(ucache_win32_salt));
+ }
+
+ PHP_SHA256Update(&sha_ctx, (const uint8_t *) identity, identity_len);
+ PHP_SHA256Final(digest, &sha_ctx);
+
+ zend_bin2hex(name_id, digest, sizeof(name_id) / 2);
+
+ snprintf(buf, buf_size, "%s@%.40s", name, name_id);
+}
+
+bool ucache_win32_commit_to(ucache_win32_seg *seg, size_t end_bytes)
+{
+ size_t end;
+
+ if (end_bytes <= seg->committed_bytes) {
+ return true;
+ }
+
+ if (seg->committed_bytes != 0 && UCACHE_DEBUG_FAULT("FAIL_WIN32_COMMIT")) {
+ SetLastError(ERROR_COMMITMENT_LIMIT);
+
+ return false;
+ }
+
+ end = ZEND_MM_ALIGNED_SIZE_EX(end_bytes, UCACHE_WIN32_COMMIT_CHUNK);
+ if (end > seg->seg.size) {
+ end = seg->seg.size;
+ }
+
+ if (VirtualAlloc(
+ (char *) seg->seg.p + seg->committed_bytes,
+ end - seg->committed_bytes,
+ MEM_COMMIT,
+ PAGE_READWRITE
+ ) == NULL
+ ) {
+ return false;
+ }
+
+ seg->committed_bytes = end;
+
+ return true;
+}
+
+void ucache_win32_reset_range(ucache_win32_seg *seg, size_t start_bytes, size_t end_bytes)
+{
+ ZEND_ASSERT(start_bytes % UCACHE_WIN32_COMMIT_CHUNK == 0 && start_bytes < end_bytes);
+
+ if (ucache_win32_commit_to(seg, end_bytes)) {
+ VirtualAlloc((char *) seg->seg.p + start_bytes, end_bytes - start_bytes, MEM_RESET, PAGE_NOACCESS);
+ }
+}
+#endif /* ZEND_WIN32 */
+
+const ucache_shm_handler_entry *ucache_handler_table(void)
+{
+#ifdef UCACHE_USE_MMAP
+ static const ucache_shm_handlers mmap_handlers = {
+ ucache_mmap_create_seg,
+ ucache_munmap_detach_seg
+ };
+#endif /* UCACHE_USE_MMAP */
+#ifdef ZEND_WIN32
+ static const ucache_shm_handlers win32_handlers = {
+ ucache_win32_create_seg,
+ ucache_win32_detach_seg
+ };
+#endif /* ZEND_WIN32 */
+ static const ucache_shm_handler_entry handlers[] = {
+#ifdef UCACHE_USE_MMAP
+ { "mmap", &mmap_handlers },
+#endif /* UCACHE_USE_MMAP */
+#ifdef UCACHE_USE_SHM
+ { "shm", &ucache_alloc_shm_handlers },
+#endif /* UCACHE_USE_SHM */
+#ifdef UCACHE_USE_SHM_OPEN
+ { "posix", &ucache_alloc_posix_handlers },
+#endif /* UCACHE_USE_SHM_OPEN */
+#ifdef ZEND_WIN32
+ { "win32", &win32_handlers },
+#endif /* ZEND_WIN32 */
+ { NULL, NULL }
+ };
+
+ return handlers;
+}
+
+void ucache_cleanup_seg(const ucache_shm_handlers *handler, ucache_shm_seg *seg)
+{
+ if (handler == NULL || seg == NULL) {
+ return;
+ }
+
+ handler->detach_seg(seg);
+
+ free(seg);
+}
diff --git a/ext/user_cache/user_cache_serdes.c b/ext/user_cache/user_cache_serdes.c
new file mode 100644
index 000000000000..e63a27c55d7c
--- /dev/null
+++ b/ext/user_cache/user_cache_serdes.c
@@ -0,0 +1,217 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_internal.h"
+
+#include "Zend/zend_fibers.h"
+
+typedef struct {
+ struct php_serialize_data *serialize_data;
+ struct php_unserialize_data *unserialize_data;
+ unsigned serialize_level;
+ unsigned unserialize_level;
+ unsigned serialize_lock;
+} ucache_serdes_native_ctxs;
+
+static zend_always_inline void ucache_serdes_native_ctxs_isolate(ucache_serdes_native_ctxs *outer)
+{
+ zend_fiber_switch_block();
+
+ outer->serialize_data = BG(serialize).data;
+ outer->serialize_level = BG(serialize).level;
+ outer->unserialize_data = BG(unserialize).data;
+ outer->unserialize_level = BG(unserialize).level;
+ outer->serialize_lock = BG(serialize_lock);
+
+ BG(serialize).data = NULL;
+ BG(serialize).level = 0;
+ BG(unserialize).data = NULL;
+ BG(unserialize).level = 0;
+ BG(serialize_lock) = 0;
+}
+
+static zend_always_inline void ucache_serdes_native_ctxs_restore(const ucache_serdes_native_ctxs *outer)
+{
+ BG(serialize).data = outer->serialize_data;
+ BG(serialize).level = outer->serialize_level;
+ BG(unserialize).data = outer->unserialize_data;
+ BG(unserialize).level = outer->unserialize_level;
+ BG(serialize_lock) = outer->serialize_lock;
+
+ zend_fiber_switch_unblock();
+}
+
+static zend_always_inline void ucache_serdes_put_bytes(smart_str *buf, const char *bytes, uint32_t len)
+{
+ smart_str_appendl(buf, (const char *) &len, sizeof(len));
+ smart_str_appendl(buf, bytes, len);
+}
+
+static zend_always_inline bool ucache_serdes_get_bytes(
+ const uint8_t *data,
+ size_t len,
+ size_t *pos,
+ const uint8_t **bytes,
+ uint32_t *bytes_len)
+{
+ if (sizeof(*bytes_len) > len - *pos) {
+ return false;
+ }
+
+ memcpy(bytes_len, data + *pos, sizeof(*bytes_len));
+ *pos += sizeof(*bytes_len);
+
+ if (*bytes_len > len - *pos) {
+ return false;
+ }
+
+ *bytes = data + *pos;
+ *pos += *bytes_len;
+
+ return true;
+}
+
+static zend_class_entry *ucache_serdes_lookup_class(const uint8_t *name, uint32_t name_len)
+{
+ zend_string *class_name = zend_string_init((const char *) name, name_len, false);
+ zend_class_entry *ce;
+
+ BG(serialize_lock)++;
+ ce = zend_lookup_class(class_name);
+ BG(serialize_lock)--;
+
+ zend_string_release(class_name);
+
+ if (ce == NULL ||
+ ce->unserialize == NULL ||
+ (ce->ce_flags & ZEND_ACC_NOT_SERIALIZABLE) != 0 ||
+ !ucache_owned_decode_validate_proc()
+ ) {
+ return NULL;
+ }
+
+ return ce;
+}
+
+bool ucache_serdes_encode(zval *val, smart_str *buf)
+{
+ zend_class_entry *ce = Z_OBJCE_P(val);
+ ucache_serdes_native_ctxs outer;
+ php_serialize_data_t ser_data;
+ uint8_t *ser_buf = NULL;
+ size_t ser_len = 0;
+ bool result;
+
+ ZEND_ASSERT(ce->serialize != NULL && ce->unserialize != NULL);
+
+ ucache_serdes_native_ctxs_isolate(&outer);
+
+ PHP_VAR_SERIALIZE_INIT(ser_data);
+ zend_try {
+ result = ce->serialize(val, &ser_buf, &ser_len, (zend_serialize_data *) ser_data) == SUCCESS;
+ } zend_catch {
+ ucache_serdes_native_ctxs_restore(&outer);
+
+ zend_bailout();
+ } zend_end_try();
+ PHP_VAR_SERIALIZE_DESTROY(ser_data);
+
+ ucache_serdes_native_ctxs_restore(&outer);
+
+ if (!result || ser_len > UINT32_MAX || ZSTR_LEN(ce->name) > UINT32_MAX) {
+ if (ser_buf != NULL) {
+ efree(ser_buf);
+ }
+
+ if (!EG(exception)) {
+ zend_type_error(
+ "The %s object could not be serialized for the user cache",
+ ZSTR_VAL(ce->name)
+ );
+ }
+
+ return false;
+ }
+
+ ucache_serdes_put_bytes(buf, ZSTR_VAL(ce->name), (uint32_t) ZSTR_LEN(ce->name));
+ ucache_serdes_put_bytes(buf, (const char *) ser_buf, (uint32_t) ser_len);
+
+ if (ser_buf != NULL) {
+ efree(ser_buf);
+ }
+
+ return true;
+}
+
+bool ucache_serdes_decode(const uint8_t *data, size_t len, zval *dst)
+{
+ const uint8_t *class_name, *payload;
+ zend_string *owned_payload;
+ zend_class_entry *ce;
+ ucache_serdes_native_ctxs outer;
+ php_unserialize_data_t unser_data;
+ uint32_t class_name_len, payload_len;
+ size_t pos = 0;
+ bool result;
+
+ ZVAL_UNDEF(dst);
+
+ if (!ucache_serdes_get_bytes(data, len, &pos, &class_name, &class_name_len) ||
+ !ucache_serdes_get_bytes(data, len, &pos, &payload, &payload_len) ||
+ pos != len
+ ) {
+ return false;
+ }
+
+ owned_payload = zend_string_init((const char *) payload, payload_len, false);
+
+ ce = ucache_serdes_lookup_class(class_name, class_name_len);
+ if (ce == NULL) {
+ zend_string_release(owned_payload);
+
+ return false;
+ }
+
+ ucache_serdes_native_ctxs_isolate(&outer);
+
+ PHP_VAR_UNSERIALIZE_INIT(unser_data);
+ zend_try {
+ result = ce->unserialize(
+ dst,
+ ce,
+ (const unsigned char *) ZSTR_VAL(owned_payload),
+ ZSTR_LEN(owned_payload),
+ (zend_unserialize_data *) &unser_data
+ ) == SUCCESS;
+ } zend_catch {
+ ucache_serdes_native_ctxs_restore(&outer);
+
+ zend_bailout();
+ } zend_end_try();
+ PHP_VAR_UNSERIALIZE_DESTROY(unser_data);
+
+ ucache_serdes_native_ctxs_restore(&outer);
+
+ zend_string_release(owned_payload);
+
+ if (!result || EG(exception) || !ucache_owned_decode_validate_proc()) {
+ zval_ptr_dtor(dst);
+
+ ZVAL_UNDEF(dst);
+
+ return false;
+ }
+
+ return true;
+}
diff --git a/ext/user_cache/user_cache_shared_graph.c b/ext/user_cache/user_cache_shared_graph.c
new file mode 100644
index 000000000000..5efd06df6dbb
--- /dev/null
+++ b/ext/user_cache/user_cache_shared_graph.c
@@ -0,0 +1,1783 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_shared_graph.h"
+
+#include "ext/random/php_random.h"
+
+#define UCACHE_GRAPH_PIN_PROBE_INTERVAL_TICKS (8U * UCACHE_CLOCK_TICKS_PER_SEC)
+
+#define UCACHE_BLOCK_OWNER_RETIRED_GRAPH (UINT32_MAX - 1)
+
+#define UCACHE_RETIRED_SCAN_INLINE_BLOCKS 64U
+#define UCACHE_FORK_ADOPT_WAIT_US 20000U
+#define UCACHE_FORK_ADOPT_POLL_US 500U
+
+typedef struct {
+ uint32_t *block_offsets;
+ bool *referenced;
+ uint32_t count;
+ uint32_t capacity;
+ uint32_t inline_block_offsets[UCACHE_RETIRED_SCAN_INLINE_BLOCKS];
+ bool inline_referenced[UCACHE_RETIRED_SCAN_INLINE_BLOCKS];
+} ucache_retired_block_list;
+
+static zend_always_inline bool ucache_graph_pin_slot_in_bitmap(
+ const ucache_sgraph_hdr *hdr,
+ uint32_t slot_idx)
+{
+ return slot_idx / 32U < MIN(hdr->pin_word_count, UCACHE_GRAPH_PIN_WORDS_MAX);
+}
+
+static zend_always_inline bool ucache_graph_pin_claim_is_stale(
+ const ucache_graph_pin_claim *claim,
+ int my_pid32)
+{
+ return atomic_load(&claim->hdr->graph_pin_slots[claim->slot_idx].owner_pid) != my_pid32;
+}
+
+static zend_always_inline uint32_t ucache_sgraph_ref_slot_mask(void)
+{
+ return UC_G(sgraph_ref_capacity) * 2 - 1;
+}
+
+static PHP_UCACHE_HOT ucache_sgraph_hdr *ucache_sgraph_payload_hdr(uint32_t payload_offset)
+{
+ const ucache_hdr *seg_hdr = ucache_hdr_ptr();
+ const uint8_t *payload, *gbuf;
+ ucache_sgraph_hdr *hdr;
+ size_t buf_len, glen;
+
+ if (payload_offset == 0) {
+ return NULL;
+ }
+
+ buf_len = ucache_block_payload_capacity(seg_hdr, payload_offset);
+ if (buf_len == 0 || !ucache_payload_in_bounds(seg_hdr, payload_offset, buf_len)) {
+ return NULL;
+ }
+
+ payload = ucache_ptr(payload_offset);
+ gbuf = ucache_sgraph_locate(payload, buf_len, NULL);
+ if (gbuf == NULL) {
+ return NULL;
+ }
+
+ hdr = (ucache_sgraph_hdr *) gbuf;
+ glen = buf_len - (size_t) (gbuf - payload);
+ if (UCACHE_SGRAPH_HDR_SIZE(MIN(hdr->pin_word_count, UCACHE_GRAPH_PIN_WORDS_MAX)) > glen) {
+ return NULL;
+ }
+
+ return hdr;
+}
+
+static uint32_t ucache_graph_pin_popcount(uint32_t v)
+{
+ v = v - ((v >> 1) & 0x55555555U);
+ v = (v & 0x33333333U) + ((v >> 2) & 0x33333333U);
+
+ return (((v + (v >> 4)) & 0x0F0F0F0FU) * 0x01010101U) >> 24;
+}
+
+static void ucache_graph_pin_count_add(ucache_graph_pin_slot *slot, int delta)
+{
+ int expected = atomic_load(&slot->pin_count), desired;
+
+ for (;;) {
+ desired = expected + delta;
+ ZEND_ASSERT(desired >= 0 && "graph pin count underflow");
+ if (UNEXPECTED(desired < 0)) {
+ desired = 0;
+ }
+
+ if (atomic_compare_exchange_strong(&slot->pin_count, &expected, desired)) {
+ return;
+ }
+ }
+}
+
+static bool ucache_graph_pin_probe_due(void)
+{
+ return UC_G(graph_pin_probe_last_at) == 0 ||
+ ucache_clock_now() - UC_G(graph_pin_probe_last_at) >= UCACHE_GRAPH_PIN_PROBE_INTERVAL_TICKS
+ ;
+}
+
+static void ucache_graph_pin_bit_set(ucache_sgraph_hdr *hdr, uint32_t slot_idx)
+{
+ atomic_int *word = &hdr->pin_owners[slot_idx / 32U];
+ int mask = (int) (1U << (slot_idx % 32U)),
+ expected
+ ;
+
+ ZEND_ASSERT(ucache_graph_pin_slot_in_bitmap(hdr, slot_idx));
+
+ expected = atomic_load(word);
+
+ while ((expected & mask) == 0) {
+ if (atomic_compare_exchange_strong(word, &expected, expected | mask)) {
+ return;
+ }
+ }
+}
+
+static bool ucache_graph_pin_bit_clear(ucache_sgraph_hdr *hdr, uint32_t slot_idx)
+{
+ atomic_int *word = &hdr->pin_owners[slot_idx / 32U];
+ int mask = (int) (1U << (slot_idx % 32U)),
+ expected
+ ;
+
+ if (!ucache_graph_pin_slot_in_bitmap(hdr, slot_idx)) {
+ return false;
+ }
+
+ expected = atomic_load(word);
+
+ while ((expected & mask) != 0) {
+ if (atomic_compare_exchange_strong(word, &expected, expected & ~mask)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static void ucache_graph_pin_claims_recycle_idle(int my_pid32)
+{
+ ucache_graph_pin_claim *claims = UC_G(graph_pin_claims);
+ ucache_graph_pin_slot *slot;
+ uint32_t i;
+ int expected;
+
+ for (i = 0; i < UC_G(graph_pin_claim_count); i++) {
+ slot = &claims[i].hdr->graph_pin_slots[claims[i].slot_idx];
+ if (atomic_load(&slot->pin_count) != 0) {
+ continue;
+ }
+
+ claims[i] = claims[--UC_G(graph_pin_claim_count)];
+ expected = my_pid32;
+
+ ucache_atomic_store_64(&slot->owner_start_time, 0);
+
+ atomic_compare_exchange_strong(&slot->owner_pid, &expected, 0);
+
+ break;
+ }
+}
+
+static bool ucache_graph_pin_slot_is_idle_with_dead_owner(
+ ucache_graph_pin_slot *slot,
+ int owner,
+ uint64_t owner_start_time,
+ int my_pid32)
+{
+ if (owner == 0 ||
+ owner == my_pid32 ||
+ atomic_load(&slot->pin_count) != 0
+ ) {
+ return false;
+ }
+
+ return owner == UCACHE_GRAPH_PIN_OWNER_ABANDONED ||
+ ucache_owner_is_dead((uint64_t) (uint32_t) owner, owner_start_time)
+ ;
+}
+
+static int32_t ucache_graph_pin_slot_take(ucache_hdr *hdr, int my_pid32)
+{
+ ucache_graph_pin_slot *slot;
+ uint64_t owner_start_time;
+ uint32_t i;
+ int32_t found = -1;
+ int expected;
+
+ for (i = 0; i < ucache_graph_pin_slot_count(hdr); i++) {
+ slot = &hdr->graph_pin_slots[i];
+ expected = 0;
+
+ if (atomic_load(&slot->owner_pid) == 0 &&
+ atomic_compare_exchange_strong(&slot->owner_pid, &expected, my_pid32)
+ ) {
+ found = (int32_t) i;
+
+ break;
+ }
+ }
+
+ for (i = 0; found < 0 && i < ucache_graph_pin_slot_count(hdr); i++) {
+ slot = &hdr->graph_pin_slots[i];
+ owner_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ expected = atomic_load(&slot->owner_pid);
+
+ if (!ucache_graph_pin_slot_is_idle_with_dead_owner(slot, expected, owner_start_time, my_pid32) ||
+ !ucache_atomic_cas_64(&slot->owner_start_time, owner_start_time, 0) ||
+ !atomic_compare_exchange_strong(&slot->owner_pid, &expected, my_pid32)
+ ) {
+ continue;
+ }
+
+ found = (int32_t) i;
+
+ UCACHE_DEBUG_SIMULATE_KILL("EXIT_IN_GRAPH_PIN_SLOT_RECLAIM");
+ }
+
+ if (found >= 0) {
+ ucache_atomic_store_64(&slot->owner_start_time, ucache_self_start_time_token());
+ }
+
+ return found;
+}
+
+static int32_t ucache_graph_pin_slot_find(ucache_hdr *hdr, bool claim)
+{
+ ucache_graph_pin_claim *claims = UC_G(graph_pin_claims);
+ uint64_t my_pid = ucache_cached_pid();
+ uint32_t i = 0;
+ int32_t found = -1;
+ int my_pid32 = (int) (uint32_t) my_pid;
+
+ while (i < UC_G(graph_pin_claim_count)) {
+ if (ucache_graph_pin_claim_is_stale(&claims[i], my_pid32)) {
+ claims[i] = claims[--UC_G(graph_pin_claim_count)];
+
+ continue;
+ }
+
+ if (claims[i].hdr == hdr) {
+ return (int32_t) claims[i].slot_idx;
+ }
+
+ i++;
+ }
+
+ if (claim && UC_G(graph_pin_claim_count) == UCACHE_GRAPH_PIN_CLAIM_MAX) {
+ ucache_graph_pin_claims_recycle_idle(my_pid32);
+ }
+
+ if (!claim || UC_G(graph_pin_claim_count) == UCACHE_GRAPH_PIN_CLAIM_MAX) {
+ return -1;
+ }
+
+ found = ucache_graph_pin_slot_take(hdr, my_pid32);
+
+ if (found >= 0) {
+ claims[UC_G(graph_pin_claim_count)].hdr = hdr;
+ claims[UC_G(graph_pin_claim_count)].slot_idx = (uint32_t) found;
+
+ UC_G(graph_pin_claim_count)++;
+ }
+
+ return found;
+}
+
+static int32_t ucache_graph_pin_slot_claim(ucache_hdr *hdr)
+{
+ int32_t slot;
+
+ if (UNEXPECTED(UC_G(graph_pin_claim_failed_hdr) == hdr) &&
+ UC_G(graph_pin_claim_failed_pid) == ucache_cached_pid()
+ ) {
+ return -1;
+ }
+
+ slot = ucache_graph_pin_slot_find(hdr, true);
+ if (UNEXPECTED(slot < 0) && UC_G(graph_pin_claim_count) < UCACHE_GRAPH_PIN_CLAIM_MAX) {
+ UC_G(graph_pin_claim_failed_hdr) = hdr;
+ UC_G(graph_pin_claim_failed_pid) = ucache_cached_pid();
+ }
+
+ return slot;
+}
+
+static void ucache_graph_pin_strip_payload_locked(
+ ucache_sgraph_hdr *ghdr,
+ const uint32_t *dead_pin_mask)
+{
+ uint32_t w, drop, cleared = 0;
+ int expected, desired, refcount;
+
+ for (w = 0; w < MIN(ghdr->pin_word_count, UCACHE_GRAPH_PIN_WORDS_MAX); w++) {
+ if (dead_pin_mask[w] == 0) {
+ continue;
+ }
+
+ expected = atomic_load(&ghdr->pin_owners[w]);
+
+ for (;;) {
+ desired = expected & ~(int) dead_pin_mask[w];
+ if (desired == expected) {
+ break;
+ }
+
+ if (atomic_compare_exchange_strong(&ghdr->pin_owners[w], &expected, desired)) {
+ cleared += ucache_graph_pin_popcount((uint32_t) expected & dead_pin_mask[w]);
+
+ break;
+ }
+ }
+ }
+
+ if (cleared == 0) {
+ return;
+ }
+
+ expected = atomic_load(&ghdr->ref_state);
+
+ for (;;) {
+ refcount = expected & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK;
+ drop = (uint32_t) refcount < cleared
+ ? (uint32_t) refcount
+ : cleared
+ ;
+ desired = (expected & UCACHE_SGRAPH_REF_STATE_RETIRED) | (refcount - (int) drop);
+
+ if (atomic_compare_exchange_strong(&ghdr->ref_state, &expected, desired)) {
+ break;
+ }
+ }
+
+ ucache_hdr_ptr()->graph_dead_pins_stripped += cleared;
+}
+
+static void ucache_sgraph_mark_retired_block_locked(uint32_t payload_offset)
+{
+ ucache_block_ptr(ucache_payload_block_offset(payload_offset))->owner =
+ UCACHE_BLOCK_OWNER_RETIRED_GRAPH
+ ;
+}
+
+static void ucache_sgraph_forget_orphan_locked(ucache_hdr *hdr, uint32_t payload_offset)
+{
+ uint32_t i;
+
+ for (i = 0; i < UCACHE_ORPHANED_GRAPH_SLOTS; i++) {
+ if (hdr->orphaned_graphs[i] == payload_offset) {
+ hdr->orphaned_graphs[i] = 0;
+ }
+ }
+}
+
+static uint32_t ucache_sgraph_free_retired_block_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_forget_orphan_locked(ucache_hdr_ptr(), payload_offset);
+
+ ucache_block_ptr(ucache_payload_block_offset(payload_offset))->owner =
+ UCACHE_BLOCK_OWNER_NONE
+ ;
+
+ return ucache_free_locked(payload_offset);
+}
+
+static ucache_sgraph_hdr *ucache_sgraph_retired_block_hdr_locked(uint32_t payload_offset)
+{
+ ucache_block *block = ucache_block_ptr(ucache_payload_block_offset(payload_offset));
+
+ if (ucache_block_is_free(block) || block->owner != UCACHE_BLOCK_OWNER_RETIRED_GRAPH) {
+ return NULL;
+ }
+
+ return ucache_sgraph_payload_hdr(payload_offset);
+}
+
+static void ucache_sgraph_force_retire_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ int state, expected;
+
+ if (hdr == NULL) {
+ return;
+ }
+
+ ucache_sgraph_mark_retired_block_locked(payload_offset);
+
+ for (;;) {
+ state = atomic_load(&hdr->ref_state);
+
+ if ((state & UCACHE_SGRAPH_REF_STATE_RETIRED) != 0) {
+ return;
+ }
+
+ expected = state;
+ if (atomic_compare_exchange_strong(
+ &hdr->ref_state, &expected, state | UCACHE_SGRAPH_REF_STATE_RETIRED
+ )) {
+ return;
+ }
+ }
+}
+
+static void ucache_retired_block_list_init(ucache_retired_block_list *list)
+{
+ list->block_offsets = list->inline_block_offsets;
+ list->referenced = list->inline_referenced;
+ list->count = 0;
+ list->capacity = UCACHE_RETIRED_SCAN_INLINE_BLOCKS;
+}
+
+static void ucache_retired_block_list_free(ucache_retired_block_list *list)
+{
+ if (list->block_offsets != list->inline_block_offsets) {
+ free(list->block_offsets);
+ free(list->referenced);
+ }
+}
+
+static bool ucache_retired_block_list_grow(ucache_retired_block_list *list)
+{
+ uint32_t *block_offsets, capacity;
+ bool *referenced;
+
+ if (list->capacity > UINT32_MAX / 2) {
+ return false;
+ }
+
+ capacity = list->capacity * 2;
+ block_offsets = malloc((size_t) capacity * sizeof(*block_offsets));
+ referenced = malloc((size_t) capacity * sizeof(*referenced));
+ if (block_offsets == NULL || referenced == NULL) {
+ free(block_offsets);
+ free(referenced);
+
+ return false;
+ }
+
+ memcpy(block_offsets, list->block_offsets, (size_t) list->count * sizeof(*block_offsets));
+ memcpy(referenced, list->referenced, (size_t) list->count * sizeof(*referenced));
+
+ ucache_retired_block_list_free(list);
+
+ list->block_offsets = block_offsets;
+ list->referenced = referenced;
+ list->capacity = capacity;
+
+ return true;
+}
+
+static bool ucache_retired_block_list_add(ucache_retired_block_list *list, uint32_t block_offset)
+{
+ ZEND_ASSERT(list->count == 0 || list->block_offsets[list->count - 1] < block_offset);
+
+ if (list->count == list->capacity && !ucache_retired_block_list_grow(list)) {
+ return false;
+ }
+
+ list->block_offsets[list->count] = block_offset;
+ list->referenced[list->count] = false;
+ list->count++;
+
+ return true;
+}
+
+static void ucache_retired_block_list_mark_locked(ucache_retired_block_list *list, uint32_t live_offset)
+{
+ uint32_t lo = 0, hi = list->count, mid, block_offset;
+
+ while (lo < hi) {
+ mid = lo + (hi - lo) / 2;
+
+ if (ucache_block_payload_offset(list->block_offsets[mid]) <= live_offset) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+
+ if (lo == 0) {
+ return;
+ }
+
+ block_offset = list->block_offsets[lo - 1];
+ if (live_offset < ucache_offset_after(block_offset, ucache_block_size(ucache_block_ptr(block_offset)))) {
+ list->referenced[lo - 1] = true;
+ }
+}
+
+static void ucache_retired_block_list_mark_live_locked(
+ ucache_retired_block_list *list,
+ ucache_hdr *hdr)
+{
+ const ucache_entry_lock_record *lock_record;
+ const ucache_entry *entry;
+ uint32_t i, val_offset;
+
+ for (i = 0; i < hdr->capacity; i++) {
+ entry = &ucache_entries_ptr(hdr)[i];
+ if (!ucache_entry_is_used(entry)) {
+ continue;
+ }
+
+ if (entry->key_offset != 0) {
+ ucache_retired_block_list_mark_locked(list, entry->key_offset);
+ }
+
+ val_offset = ucache_entry_val_offset(entry);
+ if (val_offset != 0) {
+ ucache_retired_block_list_mark_locked(list, val_offset);
+ }
+ }
+
+ for (i = 0; i < hdr->entry_lock_capacity; i++) {
+ lock_record = &ucache_entry_lock_records_ptr(hdr)[i];
+ if (lock_record->state == UCACHE_ENTRY_LOCK_USED && lock_record->key_offset != 0) {
+ ucache_retired_block_list_mark_locked(list, lock_record->key_offset);
+ }
+ }
+}
+
+static bool ucache_retired_block_list_collect_locked(
+ ucache_retired_block_list *list,
+ ucache_hdr *hdr)
+{
+ ucache_block *block;
+ uint32_t used_end, offset, block_size;
+
+ used_end = ucache_used_end_offset_locked(hdr);
+ offset = ucache_offset_from_bytes(hdr->data_offset);
+ while (offset < used_end) {
+ block = ucache_block_ptr(offset);
+ block_size = ucache_block_size(block);
+
+ if (block_size < UCACHE_BLOCK_MIN_SIZE ||
+ (block_size >> UCACHE_OFFSET_SHIFT) > used_end - offset
+ ) {
+ return false;
+ }
+
+ if (ucache_sgraph_retired_block_hdr_locked(ucache_block_payload_offset(offset)) != NULL &&
+ !ucache_retired_block_list_add(list, offset)
+ ) {
+ return false;
+ }
+
+ offset = ucache_offset_after(offset, block_size);
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_payload_is_referenced_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+
+ return hdr != NULL &&
+ (atomic_load(&hdr->ref_state) & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK) != 0
+ ;
+}
+
+static bool ucache_sgraph_block_survives_clear_locked(
+ const ucache_hdr *hdr,
+ const ucache_entry *entries,
+ const ucache_block *block,
+ uint32_t block_offset)
+{
+ const ucache_entry *entry;
+ uint32_t payload_offset = ucache_block_payload_offset(block_offset);
+
+ if (block->owner == UCACHE_BLOCK_OWNER_RETIRED_GRAPH) {
+ return ucache_sgraph_payload_is_referenced_locked(payload_offset);
+ }
+
+ if (block->owner >= hdr->capacity) {
+ return true;
+ }
+
+ entry = &entries[block->owner];
+
+ return ucache_entry_is_used(entry) &&
+ ucache_entry_val_type(entry) == UCACHE_VAL_SGRAPH &&
+ ucache_entry_val_offset(entry) == payload_offset &&
+ ucache_sgraph_payload_is_referenced_locked(payload_offset)
+ ;
+}
+
+static bool ucache_sgraph_reclaim_orphaned_by_scan_locked(
+ ucache_hdr *hdr,
+ const uint32_t *dead_pin_mask,
+ bool *completed)
+{
+ ucache_retired_block_list retired;
+ ucache_sgraph_hdr *ghdr;
+ uint32_t i, payload_offset;
+ bool reclaimed = false;
+
+ *completed = false;
+
+ ucache_retired_block_list_init(&retired);
+
+ if (!ucache_retired_block_list_collect_locked(&retired, hdr)) {
+ ucache_retired_block_list_free(&retired);
+
+ return false;
+ }
+
+ if (retired.count != 0) {
+ ucache_retired_block_list_mark_live_locked(&retired, hdr);
+ }
+
+ for (i = 0; i < retired.count; i++) {
+ if (retired.referenced[i]) {
+ continue;
+ }
+
+ payload_offset = ucache_block_payload_offset(retired.block_offsets[i]);
+ ghdr = ucache_sgraph_retired_block_hdr_locked(payload_offset);
+ if (ghdr == NULL) {
+ continue;
+ }
+
+ if (dead_pin_mask != NULL) {
+ ucache_graph_pin_strip_payload_locked(ghdr, dead_pin_mask);
+ }
+
+ if (atomic_load(&ghdr->ref_state) == UCACHE_SGRAPH_REF_STATE_RETIRED) {
+ ucache_sgraph_free_retired_block_locked(payload_offset);
+
+ reclaimed = true;
+ }
+ }
+
+ ucache_retired_block_list_free(&retired);
+
+ *completed = true;
+
+ return reclaimed;
+}
+
+static void ucache_destroy_sgraph_ref_slots(void)
+{
+ if (UC_G(sgraph_ref_slots) != NULL) {
+ efree(UC_G(sgraph_ref_slots));
+
+ UC_G(sgraph_ref_slots) = NULL;
+ }
+}
+
+static void ucache_sgraph_refs_free(void)
+{
+ if (UC_G(sgraph_refs) != NULL) {
+ efree(UC_G(sgraph_refs));
+
+ UC_G(sgraph_refs) = NULL;
+ }
+
+ UC_G(sgraph_ref_count) = 0;
+ UC_G(sgraph_ref_capacity) = 0;
+ UC_G(sgraph_ref_bytes) = 0;
+
+ ucache_destroy_sgraph_ref_slots();
+}
+
+static void ucache_sgraph_refs_check_fork(void)
+{
+ uint64_t pid = ucache_cached_pid();
+
+ if (UC_G(sgraph_ref_owner_pid) == pid) {
+ return;
+ }
+
+ if (UC_G(sgraph_ref_owner_pid) != 0) {
+ ucache_sgraph_refs_free();
+
+ ucache_decode_payload_addr_caches_release();
+ }
+
+ UC_G(sgraph_ref_owner_pid) = pid;
+}
+
+static int32_t ucache_fork_transfer_slot_lookup(const ucache_hdr *hdr, uint32_t fork_seq)
+{
+ const ucache_fork_transfer *transfers = UC_G(fork_transfers);
+ uint32_t i;
+
+ for (i = 0; i < UC_G(fork_transfer_count); i++) {
+ if (transfers[i].hdr == hdr && transfers[i].fork_seq == fork_seq) {
+ return (int32_t) transfers[i].slot_idx;
+ }
+ }
+
+ return -1;
+}
+
+static int32_t ucache_fork_transfer_slot(ucache_ctx *ctx, ucache_hdr *hdr, int my_pid32, uint32_t fork_seq)
+{
+ ucache_fork_transfer *transfers = UC_G(fork_transfers);
+ int32_t slot_idx = ucache_fork_transfer_slot_lookup(hdr, fork_seq);
+
+ if (slot_idx >= 0) {
+ return slot_idx;
+ }
+
+ if (UC_G(fork_transfer_count) == UCACHE_FORK_TRANSFER_MAX) {
+ return -1;
+ }
+
+ slot_idx = ucache_graph_pin_slot_take(hdr, my_pid32);
+ if (slot_idx < 0) {
+ return -1;
+ }
+
+ transfers[UC_G(fork_transfer_count)].ctx = ctx;
+ transfers[UC_G(fork_transfer_count)].hdr = hdr;
+ transfers[UC_G(fork_transfer_count)].slot_idx = (uint32_t) slot_idx;
+ transfers[UC_G(fork_transfer_count)].fork_seq = fork_seq;
+ UC_G(fork_transfer_count)++;
+
+ return slot_idx;
+}
+
+static bool ucache_sgraph_transfer_ref(
+ uint32_t payload_offset,
+ ucache_hdr *cache_hdr,
+ uint32_t slot_idx)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ int state, refcount, expected;
+
+ if (hdr == NULL || !ucache_graph_pin_slot_in_bitmap(hdr, slot_idx)) {
+ return false;
+ }
+
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[slot_idx], 1);
+
+ for (;;) {
+ state = atomic_load(&hdr->ref_state);
+ refcount = state & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK;
+ expected = state;
+
+ if (refcount == 0 || refcount == UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK) {
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[slot_idx], -1);
+
+ return false;
+ }
+
+ if (atomic_compare_exchange_strong(&hdr->ref_state, &expected, state + 1)) {
+ break;
+ }
+ }
+
+ ucache_graph_pin_bit_set(hdr, slot_idx);
+
+ return true;
+}
+
+static void ucache_sgraph_untransfer_ref(
+ uint32_t payload_offset,
+ ucache_hdr *cache_hdr,
+ uint32_t slot_idx)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ int state, expected;
+
+ if (hdr == NULL || !ucache_graph_pin_bit_clear(hdr, slot_idx)) {
+ return;
+ }
+
+ for (;;) {
+ state = atomic_load(&hdr->ref_state);
+ expected = state;
+
+ if ((state & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK) == 0 ||
+ atomic_compare_exchange_strong(&hdr->ref_state, &expected, state - 1)
+ ) {
+ break;
+ }
+ }
+
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[slot_idx], -1);
+}
+
+static void ucache_fork_transfer_slot_release(ucache_fork_transfer *transfer, int my_pid32, int new_owner)
+{
+ ucache_graph_pin_slot *slot = &transfer->hdr->graph_pin_slots[transfer->slot_idx];
+ int expected = my_pid32;
+
+ ucache_atomic_cas_64(&slot->owner_start_time, ucache_self_start_time_token(), 0);
+
+ atomic_compare_exchange_strong(&slot->owner_pid, &expected, new_owner);
+}
+
+static bool ucache_fork_transfer_adopt_locked(const ucache_fork_transfer *transfer, int parent_pid32, int my_pid32)
+{
+ ucache_graph_pin_slot *slot = &transfer->hdr->graph_pin_slots[transfer->slot_idx];
+ uint64_t parent_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ int expected = parent_pid32;
+
+ if (atomic_load(&slot->owner_pid) != parent_pid32 ||
+ !ucache_atomic_cas_64(&slot->owner_start_time, parent_start_time, 0) ||
+ !atomic_compare_exchange_strong(&slot->owner_pid, &expected, my_pid32)
+ ) {
+ return false;
+ }
+
+ UCACHE_DEBUG_PAUSE("PAUSE_IN_FORK_ADOPTION");
+
+ ucache_atomic_store_64(&slot->owner_start_time, ucache_self_start_time_token());
+
+ return true;
+}
+
+static bool ucache_fork_transfer_adopt(const ucache_fork_transfer *transfer, int parent_pid32, int my_pid32)
+{
+ ucache_ctx *prev_ctx;
+ bool adopted = false;
+
+ UCACHE_DEBUG_PAUSE("PAUSE_BEFORE_FORK_ADOPTION");
+
+ prev_ctx = ucache_activate_ctx(transfer->ctx);
+
+ if (ucache_rlock_for_pin_adoption()) {
+ adopted = ucache_fork_transfer_adopt_locked(transfer, parent_pid32, my_pid32);
+
+ ucache_unlock();
+ }
+
+ ucache_restore_ctx(prev_ctx);
+
+ return adopted;
+}
+
+static void ucache_sgraph_fork_transfers_settle(bool final)
+{
+ ucache_fork_transfer *transfers = UC_G(fork_transfers);
+ ucache_graph_pin_slot *slot;
+ uint32_t i = 0, waited_us;
+ int my_pid32 = (int) (uint32_t) ucache_cached_pid();
+
+ while (i < UC_G(fork_transfer_count)) {
+ slot = &transfers[i].hdr->graph_pin_slots[transfers[i].slot_idx];
+
+ for (waited_us = 0;
+ final && waited_us < UCACHE_FORK_ADOPT_WAIT_US && atomic_load(&slot->owner_pid) == my_pid32;
+ waited_us += ucache_sleep_us(UCACHE_FORK_ADOPT_POLL_US)
+ );
+
+ if (atomic_load(&slot->owner_pid) == my_pid32) {
+ if (!final) {
+ i++;
+
+ continue;
+ }
+
+ ucache_fork_transfer_slot_release(&transfers[i], my_pid32, UCACHE_GRAPH_PIN_OWNER_ABANDONED);
+ }
+
+ transfers[i] = transfers[--UC_G(fork_transfer_count)];
+ }
+}
+
+static void ucache_sgraph_fork_transfers_await_adoption(void)
+{
+ uint32_t waited_us;
+
+ for (waited_us = 0;
+ waited_us < UCACHE_FORK_ADOPT_WAIT_US && UC_G(fork_transfer_count) == UCACHE_FORK_TRANSFER_MAX;
+ waited_us += ucache_sleep_us(UCACHE_FORK_ADOPT_POLL_US)
+ ) {
+ ucache_sgraph_fork_transfers_settle(false);
+ }
+}
+
+static uint32_t ucache_sgraph_fork_transfers_count_seq(uint32_t fork_seq)
+{
+ const ucache_fork_transfer *transfers = UC_G(fork_transfers);
+ uint32_t i, count = 0;
+
+ for (i = 0; i < UC_G(fork_transfer_count); i++) {
+ count += transfers[i].fork_seq == fork_seq;
+ }
+
+ return count;
+}
+
+static void ucache_sgraph_fork_adoption_failed(void)
+{
+ ucache_sgraph_refs_check_fork();
+
+ UC_G(in_req_shutdown) = true;
+ UC_G(runtime_resolved) = false;
+
+ ucache_warn("UserCache: values fetched before fork() could not be retained in the child process; the cache is disabled for the rest of this request");
+}
+
+static void ucache_sgraph_fork_transfers_undo_refs(uint32_t ref_count, int my_pid32, uint32_t fork_seq)
+{
+ ucache_fork_transfer *transfers = UC_G(fork_transfers);
+ ucache_req_graph_ref *ref;
+ ucache_ctx *prev_ctx;
+ ucache_hdr *hdr;
+ uint32_t i = 0;
+ int32_t slot_idx;
+
+ for (i = 0; i < ref_count; i++) {
+ ref = &UC_G(sgraph_refs)[i];
+ if (ref->ctx == NULL) {
+ continue;
+ }
+
+ prev_ctx = ucache_activate_ctx(ref->ctx);
+ hdr = ucache_hdr_ptr();
+ slot_idx = ucache_fork_transfer_slot_lookup(hdr, fork_seq);
+ if (slot_idx >= 0) {
+ ucache_sgraph_untransfer_ref(ref->payload_offset, hdr, (uint32_t) slot_idx);
+ }
+
+ ucache_restore_ctx(prev_ctx);
+ }
+
+ i = 0;
+ while (i < UC_G(fork_transfer_count)) {
+ if (transfers[i].fork_seq != fork_seq) {
+ i++;
+
+ continue;
+ }
+
+ ucache_fork_transfer_slot_release(&transfers[i], my_pid32, 0);
+
+ transfers[i] = transfers[--UC_G(fork_transfer_count)];
+ }
+}
+
+static uint32_t ucache_sgraph_ref_first_slot(
+ const ucache_ctx *ctx,
+ uint32_t payload_offset)
+{
+ return (uint32_t) php_random_splitmix64_mix((uint64_t) (uintptr_t) ctx ^ payload_offset) &
+ ucache_sgraph_ref_slot_mask()
+ ;
+}
+
+static void ucache_sgraph_ref_slots_insert(uint32_t idx)
+{
+ const ucache_req_graph_ref *ref = &UC_G(sgraph_refs)[idx];
+ uint32_t slot = ucache_sgraph_ref_first_slot(ref->ctx, ref->payload_offset);
+
+ while (UC_G(sgraph_ref_slots)[slot] != 0) {
+ slot = (slot + 1) & ucache_sgraph_ref_slot_mask();
+ }
+
+ UC_G(sgraph_ref_slots)[slot] = idx + 1;
+}
+
+static void ucache_grow_sgraph_refs(void)
+{
+ uint32_t capacity = UC_G(sgraph_ref_capacity), i, *slots;
+
+ if (UNEXPECTED(capacity > UINT32_MAX / 4)) {
+ zend_error_noreturn(E_ERROR, "UserCache: shared graph reference capacity exceeded");
+ }
+
+ capacity = capacity == 0 ? 8 : capacity * 2;
+
+ UC_G(sgraph_refs) = safe_erealloc(
+ UC_G(sgraph_refs),
+ capacity,
+ sizeof(ucache_req_graph_ref),
+ 0
+ );
+
+ slots = ecalloc((size_t) capacity * 2, sizeof(uint32_t));
+
+ ucache_destroy_sgraph_ref_slots();
+
+ UC_G(sgraph_ref_slots) = slots;
+ UC_G(sgraph_ref_capacity) = capacity;
+
+ for (i = 0; i < UC_G(sgraph_ref_count); i++) {
+ ucache_sgraph_ref_slots_insert(i);
+ }
+}
+
+static bool ucache_sgraph_release_ref_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ ucache_hdr *cache_hdr = ucache_hdr_ptr();
+ int32_t pin_slot = -1;
+ bool released = false, bit_cleared = false;
+ int state, refcount, expected, desired = 0;
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ if (cache_hdr != NULL) {
+ pin_slot = ucache_graph_pin_slot_find(cache_hdr, false);
+ if (pin_slot >= 0) {
+ bit_cleared = ucache_graph_pin_bit_clear(hdr, (uint32_t) pin_slot);
+ }
+ }
+
+ for (;;) {
+ state = atomic_load(&hdr->ref_state);
+ refcount = state & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK;
+ expected = state;
+
+ if (refcount == 0) {
+ break;
+ }
+
+ desired = (state & UCACHE_SGRAPH_REF_STATE_RETIRED) | (refcount - 1);
+ if (atomic_compare_exchange_strong(&hdr->ref_state, &expected, desired)) {
+ released = true;
+
+ break;
+ }
+ }
+
+ if (bit_cleared) {
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[pin_slot], -1);
+ }
+
+ return released &&
+ (desired & UCACHE_SGRAPH_REF_STATE_RETIRED) != 0 &&
+ (desired & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK) == 0
+ ;
+}
+
+static bool ucache_sgraph_release_ref_unlocked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ ucache_hdr *cache_hdr = ucache_hdr_ptr();
+ int32_t pin_slot;
+ int state, expected;
+ bool bit_cleared = false;
+
+ if (hdr == NULL || cache_hdr == NULL) {
+ return false;
+ }
+
+ state = atomic_load(&hdr->ref_state);
+ if (state & UCACHE_SGRAPH_REF_STATE_RETIRED) {
+ return false;
+ }
+
+ pin_slot = ucache_graph_pin_slot_find(cache_hdr, false);
+ if (pin_slot >= 0) {
+ bit_cleared = ucache_graph_pin_bit_clear(hdr, (uint32_t) pin_slot);
+ }
+
+ while (!(state & UCACHE_SGRAPH_REF_STATE_RETIRED)) {
+ ZEND_ASSERT((state & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK) != 0);
+
+ expected = state;
+
+ if (atomic_compare_exchange_strong(&hdr->ref_state, &expected, state - 1)) {
+ if (bit_cleared) {
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[pin_slot], -1);
+ }
+
+ return true;
+ }
+
+ state = expected;
+ }
+
+ if (bit_cleared) {
+ ucache_graph_pin_bit_set(hdr, (uint32_t) pin_slot);
+ }
+
+ return false;
+}
+
+bool ucache_sgraph_release_op_ref(uint32_t payload_offset)
+{
+ bool recovered;
+
+ if (ucache_sgraph_payload_hdr(payload_offset) == NULL || ucache_hdr_ptr() == NULL) {
+ return false;
+ }
+
+ if (ucache_sgraph_release_ref_unlocked(payload_offset)) {
+ return true;
+ }
+
+ if (!ucache_wlock_for_ref_release(&recovered)) {
+ return false;
+ }
+
+ if (ucache_sgraph_release_ref_locked(payload_offset) &&
+ recovered &&
+ ucache_hdr_is_initialized_locked()
+ ) {
+ if (ucache_quiesce_graph_payloads_locked()) {
+ ucache_sgraph_free_retired_block_locked(payload_offset);
+ } else {
+ ucache_sgraph_orphan_payload_locked(payload_offset);
+ }
+ }
+
+ ucache_unlock();
+
+ return true;
+}
+
+const uint32_t *ucache_sgraph_node_sizes(uint32_t *count)
+{
+ static const uint32_t node_sizes[] = {
+ UCACHE_SGRAPH_HDR_SIZE(0),
+ sizeof(ucache_sgraph_val),
+ sizeof(ucache_sgraph_prop),
+ sizeof(ucache_sgraph_arr_elem),
+ sizeof(ucache_sgraph_arr),
+ sizeof(ucache_sgraph_arr_shape_elem),
+ sizeof(ucache_sgraph_arr_shape),
+ sizeof(ucache_sgraph_shaped_arr),
+ sizeof(ucache_sgraph_obj),
+ sizeof(ucache_sgraph_state_schema),
+ sizeof(ucache_sgraph_shaped_state_obj),
+ sizeof(ucache_sgraph_safe_direct_obj),
+ sizeof(ucache_sgraph_serialized_obj),
+ sizeof(ucache_sgraph_serdes_obj),
+ sizeof(ucache_sgraph_ref),
+ sizeof(ucache_sgraph_enum),
+ };
+
+ *count = sizeof(node_sizes) / sizeof(node_sizes[0]);
+
+ return node_sizes;
+}
+
+uint32_t ucache_sgraph_payload_flags(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr =
+ ucache_sgraph_payload_hdr(payload_offset)
+ ;
+
+ if (hdr == NULL) {
+ return UCACHE_SGRAPH_FLAG_HAS_SHARED_IDENTITY
+ | UCACHE_SGRAPH_FLAG_HAS_OBJ
+ | UCACHE_SGRAPH_FLAG_PREFERS_PROTO
+ ;
+ }
+
+ return hdr->flags;
+}
+
+bool ucache_sgraph_payload_has_refs_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr =
+ ucache_sgraph_payload_hdr(payload_offset)
+ ;
+
+ return hdr != NULL && atomic_load(&hdr->ref_state) != 0;
+}
+
+bool ucache_sgraph_quiesce_for_overwrite_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr =
+ ucache_sgraph_payload_hdr(payload_offset)
+ ;
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ if (!ucache_quiesce_graph_payloads_locked()) {
+ return false;
+ }
+
+ return atomic_load(&hdr->ref_state) == 0;
+}
+
+void ucache_sgraph_orphan_payload_locked(uint32_t payload_offset)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ uint32_t i;
+
+ ZEND_ASSERT(payload_offset != 0);
+
+ if (hdr == NULL) {
+ return;
+ }
+
+ ucache_sgraph_force_retire_locked(payload_offset);
+
+ for (i = 0; i < UCACHE_ORPHANED_GRAPH_SLOTS; i++) {
+ if (hdr->orphaned_graphs[i] == payload_offset) {
+ return;
+ }
+
+ if (hdr->orphaned_graphs[i] == 0) {
+ hdr->orphaned_graphs[i] = payload_offset;
+
+ return;
+ }
+ }
+
+ hdr->orphaned_graphs_saturated = 1;
+}
+
+void ucache_sgraph_reclaim_orphaned_locked(void)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_sgraph_hdr *ghdr;
+ uint32_t i, payload_offset;
+ bool checked_quiescent = false, scan_completed;
+
+ if (hdr == NULL) {
+ return;
+ }
+
+ for (i = 0; i < UCACHE_ORPHANED_GRAPH_SLOTS; i++) {
+ payload_offset = hdr->orphaned_graphs[i];
+ if (payload_offset == 0) {
+ continue;
+ }
+
+ if (!checked_quiescent) {
+ if (!ucache_quiesce_graph_payloads_locked()) {
+ return;
+ }
+
+ checked_quiescent = true;
+ }
+
+ ghdr = ucache_sgraph_retired_block_hdr_locked(
+ payload_offset
+ );
+
+ if (ghdr == NULL) {
+ hdr->orphaned_graphs[i] = 0;
+
+ continue;
+ }
+
+ if (atomic_load(&ghdr->ref_state) !=
+ UCACHE_SGRAPH_REF_STATE_RETIRED
+ ) {
+ hdr->orphaned_graphs[i] = 0;
+
+ continue;
+ }
+
+ hdr->orphaned_graphs[i] = 0;
+
+ ucache_sgraph_free_retired_block_locked(payload_offset);
+ }
+
+ if (hdr->orphaned_graphs_saturated != 0) {
+ if (!checked_quiescent && !ucache_quiesce_graph_payloads_locked()) {
+ return;
+ }
+
+ ucache_sgraph_reclaim_orphaned_by_scan_locked(hdr, NULL, &scan_completed);
+ if (scan_completed) {
+ hdr->orphaned_graphs_saturated = 0;
+ }
+ }
+}
+
+bool ucache_sgraph_strip_dead_pins_locked(bool force)
+{
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ ucache_graph_pin_slot *slot;
+ ucache_entry *entries, *entry;
+ ucache_sgraph_hdr *ghdr;
+ uint64_t owner_pid, owner_start_time;
+ uint32_t i, dead_mask[UCACHE_GRAPH_PIN_WORDS_MAX];
+ bool any_dead = false, scan_completed;
+ int val, owner;
+
+ if (hdr == NULL || !ucache_hdr_is_initialized_locked()) {
+ return false;
+ }
+
+ if (!force) {
+ if (!ucache_graph_pin_probe_due()) {
+ return false;
+ }
+
+ UC_G(graph_pin_probe_last_at) = ucache_clock_now();
+ }
+
+ memset(dead_mask, 0, sizeof(dead_mask));
+
+ for (i = 0; i < ucache_graph_pin_slot_count(hdr); i++) {
+ slot = &hdr->graph_pin_slots[i];
+
+ val = atomic_load(&slot->owner_pid);
+ if (val == 0 || atomic_load(&slot->pin_count) == 0) {
+ continue;
+ }
+
+ if (val != UCACHE_GRAPH_PIN_OWNER_ABANDONED) {
+ owner_pid = (uint64_t) (uint32_t) val;
+ if (owner_pid == ucache_cached_pid() ||
+ !ucache_owner_is_dead(
+ owner_pid,
+ ucache_atomic_load_64(
+ &slot->owner_start_time
+ )
+ )
+ ) {
+ continue;
+ }
+ }
+
+ dead_mask[i / 32U] |= 1U << (i % 32U);
+
+ any_dead = true;
+ }
+
+ if (!any_dead) {
+ return false;
+ }
+
+ UCACHE_DEBUG_PAUSE("PAUSE_IN_DEAD_PIN_STRIP");
+
+ if (!ucache_quiesce_graph_payloads_locked()) {
+ return false;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ for (i = 0; i < hdr->capacity; i++) {
+ entry = &entries[i];
+
+ if (!ucache_entry_is_used(entry) ||
+ ucache_entry_val_type(entry) != UCACHE_VAL_SGRAPH ||
+ entry->val_offset == 0
+ ) {
+ continue;
+ }
+
+ ghdr = ucache_sgraph_payload_hdr(entry->val_offset);
+ if (ghdr != NULL) {
+ ucache_graph_pin_strip_payload_locked(ghdr, dead_mask);
+ }
+ }
+
+ any_dead = ucache_sgraph_reclaim_orphaned_by_scan_locked(hdr, dead_mask, &scan_completed);
+ if (!scan_completed) {
+ return any_dead;
+ }
+
+ for (i = 0; i < ucache_graph_pin_slot_count(hdr); i++) {
+ if ((dead_mask[i / 32U] & (1U << (i % 32U))) == 0) {
+ continue;
+ }
+
+ slot = &hdr->graph_pin_slots[i];
+ owner_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ owner = atomic_load(&slot->owner_pid);
+
+ val = atomic_load(&slot->pin_count);
+ while (val != 0) {
+ if (atomic_compare_exchange_strong(&slot->pin_count, &val, 0)) {
+ break;
+ }
+ }
+
+ ucache_atomic_cas_64(&slot->owner_start_time, owner_start_time, 0);
+
+ atomic_compare_exchange_strong(&slot->owner_pid, &owner, 0);
+
+ hdr->graph_dead_pin_owners_reclaimed++;
+ }
+
+ return any_dead;
+}
+
+bool ucache_sgraph_acquire_ref(uint32_t payload_offset, bool *resource_limited)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ ucache_hdr *cache_hdr = ucache_hdr_ptr();
+ int32_t pin_slot = -1;
+ int state, refcount, expected;
+
+ if (resource_limited != NULL) {
+ *resource_limited = false;
+ }
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ pin_slot = cache_hdr != NULL ? ucache_graph_pin_slot_claim(cache_hdr) : -1;
+ if (pin_slot < 0 || !ucache_graph_pin_slot_in_bitmap(hdr, (uint32_t) pin_slot)) {
+ if (resource_limited != NULL) {
+ *resource_limited = true;
+ }
+
+ return false;
+ }
+
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[pin_slot], 1);
+
+ for (;;) {
+ state = atomic_load(&hdr->ref_state);
+ refcount = state & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK;
+ expected = state;
+
+ if ((state & UCACHE_SGRAPH_REF_STATE_RETIRED) != 0 ||
+ refcount == UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK
+ ) {
+ ucache_graph_pin_count_add(&cache_hdr->graph_pin_slots[pin_slot], -1);
+
+ if (resource_limited != NULL &&
+ refcount == UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK
+ ) {
+ *resource_limited = true;
+ }
+
+ return false;
+ }
+
+ if (atomic_compare_exchange_strong(&hdr->ref_state, &expected, state + 1)) {
+ break;
+ }
+ }
+
+ ucache_graph_pin_bit_set(hdr, (uint32_t) pin_slot);
+
+ return true;
+}
+
+size_t ucache_sgraph_largest_space_after_clear_locked(ucache_hdr *hdr)
+{
+ ucache_entry *entries = ucache_entries_ptr(hdr);
+ ucache_block *block;
+ uint32_t used_end, offset, block_size;
+ size_t run = 0, largest = 0;
+
+ used_end = ucache_used_end_offset_locked(hdr);
+ offset = ucache_offset_from_bytes(hdr->data_offset);
+ while (offset < used_end) {
+ block = ucache_block_ptr(offset);
+ block_size = ucache_block_size(block);
+
+ if (block_size < UCACHE_BLOCK_MIN_SIZE ||
+ (block_size >> UCACHE_OFFSET_SHIFT) > used_end - offset
+ ) {
+ return SIZE_MAX;
+ }
+
+ if (!ucache_block_is_free(block) &&
+ ucache_sgraph_block_survives_clear_locked(hdr, entries, block, offset)
+ ) {
+ largest = MAX(largest, run);
+ run = 0;
+ } else {
+ run += block_size;
+ }
+
+ offset = ucache_offset_after(offset, block_size);
+ }
+
+ if ((uint64_t) hdr->data_offset + ucache_data_tail_limit_locked(hdr) > ucache_offset_bytes(used_end)) {
+ run += (size_t) ((uint64_t) hdr->data_offset + ucache_data_tail_limit_locked(hdr) - ucache_offset_bytes(used_end));
+ }
+
+ return MAX(largest, run);
+}
+
+bool ucache_sgraph_retire_payload_locked(uint32_t payload_offset)
+{
+ ucache_sgraph_hdr *hdr = ucache_sgraph_payload_hdr(payload_offset);
+ int state, refcount, expected;
+
+ if (hdr == NULL) {
+ return true;
+ }
+
+ for (;;) {
+ state = atomic_load(&hdr->ref_state);
+ refcount = state & UCACHE_SGRAPH_REF_STATE_REFCOUNT_MASK;
+ expected = state;
+
+ if (refcount == 0) {
+ return true;
+ }
+
+ if ((state & UCACHE_SGRAPH_REF_STATE_RETIRED) != 0 ||
+ atomic_compare_exchange_strong(
+ &hdr->ref_state,
+ &expected,
+ (state | UCACHE_SGRAPH_REF_STATE_RETIRED)
+ )
+ ) {
+ ucache_sgraph_mark_retired_block_locked(payload_offset);
+
+ return false;
+ }
+ }
+}
+
+uint32_t ucache_req_sgraph_ref_idx(uint32_t payload_offset)
+{
+ const ucache_req_graph_ref *ref;
+ ucache_ctx *ctx;
+ uint32_t slot;
+
+ ucache_sgraph_refs_check_fork();
+
+ if (UC_G(sgraph_ref_count) == 0) {
+ return UINT32_MAX;
+ }
+
+ ctx = ucache_active_ctx();
+
+ for (slot = ucache_sgraph_ref_first_slot(ctx, payload_offset);
+ UC_G(sgraph_ref_slots)[slot] != 0;
+ slot = (slot + 1) & ucache_sgraph_ref_slot_mask()
+ ) {
+ ref = &UC_G(sgraph_refs)[UC_G(sgraph_ref_slots)[slot] - 1];
+ if (ref->ctx == ctx && ref->payload_offset == payload_offset) {
+ return UC_G(sgraph_ref_slots)[slot] - 1;
+ }
+ }
+
+ return UINT32_MAX;
+}
+
+void ucache_sgraph_ref_reserve(void)
+{
+ ucache_sgraph_refs_check_fork();
+
+ if (UC_G(sgraph_ref_count) == UC_G(sgraph_ref_capacity)) {
+ ucache_grow_sgraph_refs();
+ }
+}
+
+uint32_t ucache_register_sgraph_ref(uint32_t payload_offset, uint32_t payload_len)
+{
+ uint32_t idx;
+
+ ZEND_ASSERT(UC_G(sgraph_ref_owner_pid) == ucache_cached_pid());
+ ZEND_ASSERT(UC_G(sgraph_ref_count) < UC_G(sgraph_ref_capacity));
+ ZEND_ASSERT(payload_offset != 0);
+
+ idx = UC_G(sgraph_ref_count)++;
+
+ UC_G(sgraph_refs)[idx].ctx = ucache_active_ctx();
+ UC_G(sgraph_refs)[idx].payload_offset = payload_offset;
+ UC_G(sgraph_ref_bytes) += payload_len;
+
+ ucache_sgraph_ref_slots_insert(idx);
+
+ return idx;
+}
+
+void ucache_release_req_sgraph_refs(void)
+{
+ ucache_req_graph_ref *ref;
+ ucache_ctx *ctx, *prev_ctx;
+ uint32_t i, inner;
+ bool recovered, maintenance_due, needs_lock;
+
+ UC_G(graph_pin_claim_failed_hdr) = NULL;
+
+ ucache_sgraph_fork_transfers_settle(true);
+
+ ucache_sgraph_refs_check_fork();
+
+ if (UC_G(sgraph_ref_count) == 0) {
+ ucache_sgraph_refs_free();
+
+ return;
+ }
+
+ maintenance_due = ucache_graph_pin_probe_due();
+
+ for (i = 0; i < UC_G(sgraph_ref_count); i++) {
+ ctx = UC_G(sgraph_refs)[i].ctx;
+
+ if (ctx == NULL) {
+ continue;
+ }
+
+ prev_ctx = ucache_activate_ctx(ctx);
+ needs_lock = maintenance_due;
+
+ for (inner = i; inner < UC_G(sgraph_ref_count); inner++) {
+ ref = &UC_G(sgraph_refs)[inner];
+
+ if (ref->ctx != ctx) {
+ continue;
+ }
+
+ if (ucache_sgraph_release_ref_unlocked(ref->payload_offset)) {
+ ref->ctx = NULL;
+ } else {
+ needs_lock = true;
+ }
+ }
+
+ if (needs_lock && ucache_wlock_for_ref_release(&recovered)) {
+ if (!recovered) {
+ for (inner = i; inner < UC_G(sgraph_ref_count); inner++) {
+ ref = &UC_G(sgraph_refs)[inner];
+
+ if (ref->ctx != ctx) {
+ continue;
+ }
+
+ (void) ucache_sgraph_release_ref_locked(ref->payload_offset);
+
+ ref->ctx = NULL;
+ }
+ } else if (ucache_hdr_is_initialized_locked()) {
+ for (inner = i; inner < UC_G(sgraph_ref_count); inner++) {
+ ref = &UC_G(sgraph_refs)[inner];
+
+ if (ref->ctx != ctx) {
+ continue;
+ }
+
+ if (ucache_sgraph_release_ref_locked(ref->payload_offset)) {
+ if (ucache_quiesce_graph_payloads_locked()) {
+ ucache_sgraph_free_retired_block_locked(ref->payload_offset);
+ } else {
+ ucache_sgraph_orphan_payload_locked(ref->payload_offset);
+ }
+ }
+
+ ref->ctx = NULL;
+ }
+
+ ucache_sgraph_reclaim_orphaned_locked();
+ (void) ucache_sgraph_strip_dead_pins_locked(false);
+ }
+
+ ucache_unlock();
+ }
+
+ for (inner = i; inner < UC_G(sgraph_ref_count); inner++) {
+ if (UC_G(sgraph_refs)[inner].ctx == ctx) {
+ UC_G(sgraph_refs)[inner].ctx = NULL;
+ }
+ }
+
+ ucache_restore_ctx(prev_ctx);
+ }
+
+ if (maintenance_due) {
+ UC_G(graph_pin_probe_last_at) = ucache_clock_now();
+ }
+
+ ucache_sgraph_refs_free();
+}
+
+void ucache_sgraph_refs_transfer_before_fork(void)
+{
+ ucache_req_graph_ref *ref;
+ ucache_ctx *prev_ctx;
+ ucache_hdr *hdr;
+ uint32_t i, fork_seq;
+ int32_t slot_idx;
+ int my_pid32 = (int) (uint32_t) ucache_cached_pid();
+ bool failed = false;
+
+ ucache_sgraph_fork_transfers_settle(false);
+ ucache_sgraph_refs_check_fork();
+
+ UC_G(fork_transfer_pid) = ucache_cached_pid();
+ fork_seq = ++UC_G(fork_seq);
+
+ if (UC_G(sgraph_ref_count) == 0) {
+ return;
+ }
+
+ for (i = 0; i < UC_G(sgraph_ref_count) && !failed; i++) {
+ ref = &UC_G(sgraph_refs)[i];
+ if (ref->ctx == NULL) {
+ continue;
+ }
+
+ if (UC_G(fork_transfer_count) == UCACHE_FORK_TRANSFER_MAX) {
+ ucache_sgraph_fork_transfers_await_adoption();
+ }
+
+ prev_ctx = ucache_activate_ctx(ref->ctx);
+ hdr = ucache_hdr_ptr();
+ slot_idx = ucache_fork_transfer_slot(ref->ctx, hdr, my_pid32, fork_seq);
+ failed = slot_idx < 0 || !ucache_sgraph_transfer_ref(ref->payload_offset, hdr, (uint32_t) slot_idx);
+ ucache_restore_ctx(prev_ctx);
+ }
+
+ if (failed) {
+ ucache_sgraph_fork_transfers_undo_refs(i - 1, my_pid32, fork_seq);
+ }
+}
+
+void ucache_sgraph_fork_transfers_undo(void)
+{
+ ucache_sgraph_fork_transfers_undo_refs(UC_G(sgraph_ref_count), (int) (uint32_t) ucache_cached_pid(), UC_G(fork_seq));
+}
+
+void ucache_sgraph_refs_adopt_after_fork(void)
+{
+ ucache_fork_transfer *transfers = UC_G(fork_transfers);
+ ucache_graph_pin_claim claim;
+ uint64_t pid = ucache_cached_pid();
+ uint32_t i, fork_seq = UC_G(fork_seq), expected_count, adopted = 0;
+ int my_pid32 = (int) (uint32_t) pid, parent_pid32 = (int) (uint32_t) UC_G(fork_transfer_pid);
+
+ if (UC_G(sgraph_ref_owner_pid) == pid) {
+ return;
+ }
+
+ expected_count = UC_G(fork_transfer_pid) == UC_G(sgraph_ref_owner_pid) ? ucache_sgraph_fork_transfers_count_seq(fork_seq) : 0;
+
+ if (expected_count == 0) {
+ UC_G(fork_transfer_count) = 0;
+
+ if (UC_G(sgraph_ref_count) > 0) {
+ ucache_sgraph_fork_adoption_failed();
+ } else {
+ ucache_sgraph_refs_check_fork();
+ }
+
+ return;
+ }
+
+ for (i = 0; i < UC_G(fork_transfer_count); i++) {
+ if (transfers[i].fork_seq != fork_seq ||
+ !ucache_fork_transfer_adopt(&transfers[i], parent_pid32, my_pid32)
+ ) {
+ continue;
+ }
+
+ if (ucache_graph_pin_slot_find(transfers[i].hdr, false) < 0 &&
+ UC_G(graph_pin_claim_count) < UCACHE_GRAPH_PIN_CLAIM_MAX
+ ) {
+ claim.hdr = transfers[i].hdr;
+ claim.slot_idx = transfers[i].slot_idx;
+ UC_G(graph_pin_claims)[UC_G(graph_pin_claim_count)++] = claim;
+ }
+
+ adopted++;
+ }
+
+ if (adopted == expected_count) {
+ UC_G(fork_transfer_count) = 0;
+ UC_G(sgraph_ref_owner_pid) = pid;
+
+ ucache_decode_payload_addr_caches_release();
+
+ return;
+ }
+
+ for (i = 0; i < UC_G(fork_transfer_count); i++) {
+ if (transfers[i].fork_seq == fork_seq) {
+ ucache_fork_transfer_slot_release(&transfers[i], my_pid32, UCACHE_GRAPH_PIN_OWNER_ABANDONED);
+ }
+ }
+
+ UC_G(fork_transfer_count) = 0;
+
+ ucache_sgraph_fork_adoption_failed();
+}
diff --git a/ext/user_cache/user_cache_shared_graph.h b/ext/user_cache/user_cache_shared_graph.h
new file mode 100644
index 000000000000..deefe8ab2784
--- /dev/null
+++ b/ext/user_cache/user_cache_shared_graph.h
@@ -0,0 +1,255 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef UCACHE_SHARED_GRAPH_H
+#define UCACHE_SHARED_GRAPH_H
+
+#include "user_cache_internal.h"
+
+#define UCACHE_SGRAPH_SHAPE_NEXT_FREE_UNSET UINT32_MAX
+#define UCACHE_SGRAPH_ROOT_OFFSET(pin_word_count) \
+ ((uint32_t) UCACHE_ALIGNED_SIZE(UCACHE_SGRAPH_HDR_SIZE(pin_word_count)))
+
+typedef struct {
+ uint8_t type;
+ uint8_t flags;
+ uint16_t sleep_slot_plus_one;
+ union {
+ int32_t long_val;
+ uint32_t offset;
+ };
+} ucache_sgraph_val;
+
+typedef struct {
+ uint32_t name_offset;
+ ucache_sgraph_val val;
+} ucache_sgraph_prop;
+
+typedef struct {
+ ucache_sgraph_val val;
+ uint32_t key;
+ uint32_t h_hi;
+} ucache_sgraph_arr_elem;
+
+typedef struct {
+ uint32_t count;
+ uint32_t next_free;
+ uint32_t elems_offset;
+ uint32_t flags;
+} ucache_sgraph_arr;
+
+typedef struct {
+ uint32_t key_offset;
+} ucache_sgraph_arr_shape_elem;
+
+typedef struct {
+ uint32_t count;
+ uint32_t elems_offset;
+} ucache_sgraph_arr_shape;
+
+typedef struct {
+ uint32_t count;
+ uint32_t next_free;
+ uint32_t shape_offset;
+} ucache_sgraph_shaped_arr;
+
+typedef struct {
+ uint32_t class_name_offset;
+ uint32_t prop_count;
+ uint32_t props_offset;
+ uint32_t flags;
+} ucache_sgraph_obj;
+
+typedef struct {
+ uint32_t class_name_offset;
+ uint32_t shape_offset;
+ uint32_t count;
+} ucache_sgraph_state_schema;
+
+typedef struct {
+ uint32_t state_schema_offset;
+ uint32_t flags;
+ uint32_t state_vals_offset;
+ uint32_t state_next_free;
+} ucache_sgraph_shaped_state_obj;
+
+typedef struct {
+ uint32_t class_name_offset;
+ uint32_t prop_count;
+ uint32_t props_offset;
+ uint32_t flags;
+ ucache_sgraph_val state;
+} ucache_sgraph_safe_direct_obj;
+
+typedef struct {
+ uint32_t class_name_offset;
+ uint32_t flags;
+ ucache_sgraph_val state;
+} ucache_sgraph_serialized_obj;
+
+typedef struct {
+ uint32_t blob_len;
+ uint32_t flags;
+} ucache_sgraph_serdes_obj;
+
+typedef struct {
+ uint32_t flags;
+ ucache_sgraph_val inner;
+} ucache_sgraph_ref;
+
+typedef struct {
+ uint32_t class_name_offset;
+ uint32_t case_name_offset;
+} ucache_sgraph_enum;
+
+zend_property_info *ucache_sgraph_declared_prop_info(
+ zend_class_entry *ce,
+ zend_string *name);
+#if ZEND_DEBUG
+void ucache_sgraph_check_rebase_complete(
+ const uint8_t *dst_base,
+ size_t glen,
+ const uint8_t *src_base);
+#endif
+
+static zend_always_inline size_t ucache_sgraph_alignment_padding(const void *buf)
+{
+ uintptr_t raw_addr, aligned_addr;
+
+ raw_addr = (uintptr_t) buf;
+ aligned_addr = (uintptr_t) ZEND_MM_ALIGNED_SIZE(raw_addr);
+
+ return (size_t) (aligned_addr - raw_addr);
+}
+
+static zend_always_inline const uint8_t *ucache_sgraph_locate(
+ const uint8_t *buf,
+ size_t buf_len,
+ size_t *glen)
+{
+ size_t padding;
+
+ padding = ucache_sgraph_alignment_padding(buf);
+ if (padding > buf_len || buf_len - padding < UCACHE_SGRAPH_HDR_SIZE(0)) {
+ return NULL;
+ }
+
+ buf += padding;
+ buf_len -= padding;
+
+ if (glen != NULL) {
+ *glen = buf_len;
+ }
+
+ return buf;
+}
+
+static zend_always_inline uint32_t ucache_sgraph_shaped_arr_vals_offset(uint32_t arr_offset)
+{
+ return arr_offset + (uint32_t) UCACHE_ALIGNED_SIZE(sizeof(ucache_sgraph_shaped_arr));
+}
+
+static zend_always_inline bool ucache_sgraph_can_restore_direct(zend_class_entry *ce)
+{
+ if (ce->ce_flags & ZEND_ACC_NOT_SERIALIZABLE) {
+ return false;
+ }
+
+ if (ce->type != ZEND_USER_CLASS && ce->create_object != NULL) {
+ return false;
+ }
+
+ return true;
+}
+
+static zend_always_inline bool ucache_class_has_serialize_handlers(const zend_class_entry *ce)
+{
+ return ce->serialize != NULL && ce->unserialize != NULL;
+}
+
+static zend_always_inline bool ucache_sgraph_wakeup_rebuilds_state(zend_class_entry *ce)
+{
+ if ((ce->ce_flags & (ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_ENUM)) != 0 ||
+ ce->__unserialize != NULL ||
+ ucache_class_has_serialize_handlers(ce)
+ ) {
+ return false;
+ }
+
+ return zend_hash_find_known_hash(&ce->function_table, ZSTR_KNOWN(ZEND_STR_WAKEUP)) != NULL;
+}
+
+static zend_always_inline bool ucache_sgraph_can_restore_props(zend_class_entry *ce, bool sleep_obj)
+{
+ return ucache_sgraph_can_restore_direct(ce) ||
+ (sleep_obj && ucache_sgraph_wakeup_rebuilds_state(ce))
+ ;
+}
+
+static zend_always_inline bool ucache_sgraph_ptr_in_range(
+ const void *ptr,
+ const uint8_t *base,
+ size_t len)
+{
+ uintptr_t addr, start;
+
+ if (ptr == NULL || base == NULL || len == 0) {
+ return false;
+ }
+
+ addr = (uintptr_t) ptr;
+ start = (uintptr_t) base;
+
+ return addr >= start && addr - start < len;
+}
+
+static zend_always_inline bool ucache_stack_overflowed(void)
+{
+ bool overflowed = ucache_stack_exhausted();
+
+ if (overflowed) {
+ UC_G(stack_overflowed) = true;
+ }
+
+ return overflowed;
+}
+
+static inline bool ucache_class_overrides_safe_direct_magic_serialize(
+ zend_class_entry *ce,
+ const php_ucache_safe_direct_handlers *handlers,
+ zend_class_entry *base_ce)
+{
+ if (ce->type != ZEND_USER_CLASS ||
+ ce->__serialize == NULL ||
+ ce->__unserialize == NULL ||
+ (ce->ce_flags & (ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_ENUM)) != 0 ||
+ handlers == NULL ||
+ base_ce == ce
+ ) {
+ return false;
+ }
+
+ return
+ (
+ ce->__serialize->type == ZEND_USER_FUNCTION ||
+ ce->__unserialize->type == ZEND_USER_FUNCTION
+ ) &&
+ (
+ ce->__serialize->common.scope != base_ce ||
+ ce->__unserialize->common.scope != base_ce
+ )
+ ;
+}
+
+#endif /* UCACHE_SHARED_GRAPH_H */
diff --git a/ext/user_cache/user_cache_shared_graph_decode.c b/ext/user_cache/user_cache_shared_graph_decode.c
new file mode 100644
index 000000000000..36f76baa1a66
--- /dev/null
+++ b/ext/user_cache/user_cache_shared_graph_decode.c
@@ -0,0 +1,3961 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_shared_graph.h"
+
+#include "Zend/zend_interfaces.h"
+#include "Zend/zend_fibers.h"
+#include "Zend/zend_operators.h"
+
+#define UCACHE_DECODE_CACHE_MAX_ENTRIES 4096U
+#define UCACHE_DECODE_MAX_DEPTH 8192U
+#define UCACHE_VERBATIM_VERDICT_ADDR_SHIFT 4
+#define UCACHE_VERBATIM_VERDICT_ADDR_MIX_SHIFT 14
+#ifdef ZEND_CHECK_STACK_LIMIT
+# define UCACHE_DECODE_COUNTS_NESTING 0
+#else
+# define UCACHE_DECODE_COUNTS_NESTING 1
+#endif
+#define UCACHE_DECODE_CLASS_HAS_WAKEUP ((uintptr_t) 1)
+#define UCACHE_DECODE_RESOLVE_KIND_SHIFT 1
+#define UCACHE_DECODE_RESOLVE_KIND_CLASS ((uintptr_t) 0 << UCACHE_DECODE_RESOLVE_KIND_SHIFT)
+#define UCACHE_DECODE_RESOLVE_KIND_ENUM_CASE ((uintptr_t) 1 << UCACHE_DECODE_RESOLVE_KIND_SHIFT)
+#define UCACHE_DECODE_RESOLVE_KIND_MASK ((uintptr_t) 1 << UCACHE_DECODE_RESOLVE_KIND_SHIFT)
+#define UCACHE_DECODE_RESOLVE_TAG_MASK (UCACHE_DECODE_CLASS_HAS_WAKEUP | UCACHE_DECODE_RESOLVE_KIND_MASK)
+#define UCACHE_VERBATIM_ARR_GC_TYPE_INFO \
+ (GC_ARRAY | ((IS_ARRAY_IMMUTABLE | GC_NOT_COLLECTABLE) << GC_FLAGS_SHIFT))
+#define UCACHE_VERBATIM_ARR_HT_FLAGS_ALLOWED \
+ (HASH_FLAG_PACKED | HASH_FLAG_STATIC_KEYS | HASH_FLAG_ALLOW_COW_VIOLATION)
+
+#if ZEND_DEBUG
+# define UCACHE_HT_DISALLOW_COW_VIOLATION(ht) HT_FLAGS(ht) &= ~HASH_FLAG_ALLOW_COW_VIOLATION
+# define UCACHE_ASSERT_RESOLVED_WAKEUP(obj_zv, has_wakeup) \
+ ZEND_ASSERT((has_wakeup) == (zend_hash_find_known_hash(&Z_OBJCE_P(obj_zv)->function_table, ZSTR_KNOWN(ZEND_STR_WAKEUP)) != NULL))
+#else
+# define UCACHE_HT_DISALLOW_COW_VIOLATION(ht)
+# define UCACHE_ASSERT_RESOLVED_WAKEUP(obj_zv, has_wakeup)
+#endif
+
+#define UCACHE_DEFINE_DECODE_MAP(name, ctype, dtor, release_on_fail) \
+ static zend_always_inline void ucache_decode_##name##_map_teardown(void) \
+ { \
+ HashTable *map = UC_G(decode_##name##_map); \
+ if (map != NULL) { \
+ UC_G(decode_##name##_map) = NULL; \
+ zend_hash_destroy(map); \
+ efree(map); \
+ } \
+ } \
+ \
+ static zend_always_inline bool ucache_decode_##name##_map_insert(uint32_t offset, ctype *entry) \
+ { \
+ if (UC_G(decode_##name##_map) == NULL) { \
+ UC_G(decode_##name##_map) = emalloc(sizeof(HashTable)); \
+ zend_hash_init(UC_G(decode_##name##_map), 8, NULL, dtor, 0); \
+ } \
+ \
+ GC_ADDREF(entry); \
+ if (zend_hash_index_add_ptr(UC_G(decode_##name##_map), offset, entry) == NULL) { \
+ release_on_fail; \
+ \
+ return false; \
+ } \
+ \
+ return true; \
+ } \
+ \
+ static zend_always_inline ctype *ucache_decode_##name##_map_find(uint32_t offset) \
+ { \
+ if (UC_G(decode_##name##_map) == NULL) { \
+ return NULL; \
+ } \
+ \
+ return zend_hash_index_find_ptr(UC_G(decode_##name##_map), offset); \
+ }
+
+#if ZEND_DEBUG
+typedef struct {
+ const uint8_t *old_base;
+ const uint8_t *new_base;
+ size_t len;
+ HashTable *seen;
+} ucache_sgraph_rebase_ctx;
+#endif
+
+typedef struct {
+ const uint8_t *buf;
+ const uint8_t *snapshot_origin;
+ size_t buf_len;
+ HashTable seen;
+ uint32_t depth;
+} ucache_verbatim_check_ctx;
+
+typedef struct {
+ zval obj;
+ zval state;
+} ucache_restore_call;
+
+struct _ucache_owned_decode_frame {
+ ucache_owned_decode_frame *prev;
+ uint64_t owner_pid;
+ HashTable *strs;
+ HashTable *arrs;
+ HashTable *resolve;
+ HashTable *shapes;
+ HashTable *saved_identity_map;
+ HashTable *saved_ref_map;
+ ucache_sgraph_snapshot *snapshot;
+};
+
+struct _ucache_sgraph_snapshot {
+ const uint8_t *origin;
+ size_t len;
+ uint8_t data[1];
+};
+
+struct _ucache_restore_queue {
+ ucache_restore_queue *prev;
+ ucache_restore_call *calls;
+ HashTable *saved_identity_map;
+ HashTable *saved_ref_map;
+ uint32_t depth;
+ uint32_t count;
+ uint32_t capacity;
+ uint32_t next;
+};
+
+static_assert(
+ alignof(zend_class_entry) > UCACHE_DECODE_RESOLVE_TAG_MASK,
+ "resolved class entries must keep their tag bits clear"
+);
+static_assert(
+ alignof(zend_object) > UCACHE_DECODE_RESOLVE_TAG_MASK,
+ "resolved enum cases must keep their tag bits clear"
+);
+
+static void ucache_owned_str_dtor(zval *val);
+static void ucache_decode_shape_proto_dtor(zval *zv);
+static bool ucache_restore_queue_finish(ucache_restore_queue *queue, bool result);
+static void ucache_restore_queue_destroy(ucache_restore_queue *queue);
+static bool ucache_verbatim_arr_check(ucache_verbatim_check_ctx *ctx, const zend_array *arr);
+static zend_never_inline bool ucache_decode_verbatim_arr_validate(
+ const uint8_t *buf,
+ size_t buf_len,
+ const uint8_t *snapshot_origin,
+ const zend_array *arr,
+ const void *shm_arr);
+static zend_never_inline void ucache_decode_save_maps(void);
+static ZEND_COLD zend_never_inline void ucache_decode_release_keeping_overflow(zval *dst);
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_val(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst);
+
+static zend_always_inline void ucache_restore_queue_detach_next(
+ ucache_restore_queue *queue,
+ zval *obj,
+ zval *state)
+{
+ ucache_restore_call *call = &queue->calls[queue->next];
+
+ ZVAL_COPY_VALUE(obj, &call->obj);
+ ZVAL_COPY_VALUE(state, &call->state);
+
+ queue->next++;
+}
+
+static zend_always_inline bool ucache_restore_queue_finish_impl(
+ ucache_restore_queue *queue,
+ bool result,
+ bool owned)
+{
+ ucache_restore_call *call;
+ zend_object *obj;
+ zend_function *wakeup;
+ zval retval, obj_zv, state;
+
+ while (queue->next < queue->count) {
+ call = &queue->calls[queue->next];
+ obj = Z_OBJ(call->obj);
+
+ ZVAL_COPY_VALUE(&state, &call->state);
+
+ if (result && !EG(exception) && (!owned || ucache_owned_decode_validate_proc_impl())) {
+ if (Z_ISUNDEF(state)) {
+ wakeup = zend_hash_find_ptr(&obj->ce->function_table, ZSTR_KNOWN(ZEND_STR_WAKEUP));
+ BG(serialize_lock)++;
+
+ zend_call_known_instance_method(wakeup, obj, &retval, 0, NULL);
+ BG(serialize_lock)--;
+
+ result = !EG(exception) && (!owned || ucache_owned_decode_validate_proc_impl());
+
+ zval_ptr_dtor(&retval);
+ } else {
+ zend_object_set_properties_reinitable(obj, true);
+ BG(serialize_lock)++;
+
+ zend_call_known_instance_method_with_1_params(obj->ce->__unserialize, obj, NULL, &state);
+ BG(serialize_lock)--;
+
+ zend_object_set_properties_reinitable(obj, false);
+
+ result = !EG(exception) && (!owned || ucache_owned_decode_validate_proc_impl());
+ }
+ } else {
+ result = false;
+ }
+
+ if (!result) {
+ GC_ADD_FLAGS(obj, IS_OBJ_DESTRUCTOR_CALLED);
+ }
+
+ ucache_restore_queue_detach_next(queue, &obj_zv, &state);
+
+ zval_ptr_dtor(&obj_zv);
+ zval_ptr_dtor(&state);
+ }
+
+ return result &&
+ !EG(exception) &&
+ (!owned || ucache_owned_decode_validate_proc_impl())
+ ;
+}
+
+static zend_always_inline bool ucache_decode_range_ok(
+ size_t buf_len,
+ uint32_t offset,
+ size_t need)
+{
+ return offset <= buf_len && need <= buf_len - offset;
+}
+
+static zend_always_inline bool ucache_decode_arr_range_ok(
+ size_t buf_len,
+ uint32_t offset,
+ uint32_t count,
+ size_t elem_size)
+{
+ if (offset > buf_len) {
+ return false;
+ }
+
+ return count <= (buf_len - offset) / elem_size;
+}
+
+static zend_always_inline bool ucache_decode_fail_zval(zval *dst)
+{
+ ucache_decode_release_keeping_overflow(dst);
+
+ return false;
+}
+
+static zend_always_inline bool ucache_decode_str_hdr_ok(const zend_string *str)
+{
+ return GC_TYPE(str) == IS_STRING && (GC_FLAGS(str) & IS_STR_INTERNED) != 0;
+}
+
+static zend_always_inline zend_string *ucache_decode_str_at_raw(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint32_t offset)
+{
+ zend_string *str;
+
+ if (!ucache_decode_range_ok(buf_len, offset, _ZSTR_HEADER_SIZE)) {
+ return NULL;
+ }
+
+ str = (zend_string *) (void *) (buf + offset);
+ if (!ucache_decode_str_hdr_ok(str) ||
+ ZSTR_LEN(str) > buf_len ||
+ !ucache_decode_range_ok(
+ buf_len,
+ offset,
+ _ZSTR_STRUCT_SIZE(ZSTR_LEN(str))
+ )
+ ) {
+ return NULL;
+ }
+
+ return str;
+}
+
+static zend_always_inline bool ucache_owned_decode_frame_reads_inherited_lease(
+ const ucache_owned_decode_frame *frame)
+{
+ return frame->owner_pid != ucache_cached_pid() && frame->snapshot == NULL;
+}
+
+static zend_always_inline zend_string *ucache_owned_str_dup(const zend_string *src)
+{
+ zend_string *str = zend_string_init(ZSTR_VAL(src), ZSTR_LEN(src), 0);
+
+ ZSTR_H(str) = ZSTR_H(src);
+
+ if (ZSTR_IS_VALID_UTF8(src)) {
+ GC_ADD_FLAGS(str, IS_STR_VALID_UTF8);
+ }
+
+ return str;
+}
+
+static zend_always_inline zend_string *ucache_owned_decode_str(zend_string *src)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+ zend_string *str;
+ zend_ulong key = (zend_ulong) (uintptr_t) src;
+
+ ZEND_ASSERT(frame != NULL);
+ if (frame->strs == NULL) {
+ frame->strs = emalloc(sizeof(HashTable));
+
+ zend_hash_init(frame->strs, 8, NULL, ucache_owned_str_dtor, 0);
+ } else {
+ str = zend_hash_index_find_ptr(frame->strs, key);
+ if (str != NULL) {
+ return str;
+ }
+ }
+
+ str = ucache_owned_str_dup(src);
+
+ zend_hash_index_add_new_ptr(frame->strs, key, str);
+
+ return str;
+}
+
+static zend_always_inline zend_string *ucache_decode_str_at(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint32_t offset)
+{
+ zend_string *str = ucache_decode_str_at_raw(buf, buf_len, offset);
+
+ if (str != NULL && UNEXPECTED(UC_G(owned_decode_frame) != NULL)) {
+ return ucache_owned_decode_str(str);
+ }
+
+ return str;
+}
+
+static zend_always_inline bool ucache_owned_verbatim_val_is_borrowed(const zval *val)
+{
+ return Z_TYPE_P(val) == IS_ARRAY || Z_TYPE_P(val) == IS_STRING;
+}
+
+static zend_always_inline size_t ucache_decode_node_hdr_size(uint8_t type)
+{
+ switch (type) {
+ case UCACHE_SGRAPH_VAL_DYNAMIC_ARR:
+ return sizeof(ucache_sgraph_arr);
+ case UCACHE_SGRAPH_VAL_SHAPED_ARR:
+ return sizeof(ucache_sgraph_shaped_arr);
+ case UCACHE_SGRAPH_VAL_OBJ:
+ case UCACHE_SGRAPH_VAL_SLEEP_OBJ:
+ return sizeof(ucache_sgraph_obj);
+ case UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ:
+ return sizeof(ucache_sgraph_safe_direct_obj);
+ case UCACHE_SGRAPH_VAL_SERIALIZED_OBJ:
+ return sizeof(ucache_sgraph_serialized_obj);
+ case UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ:
+ case UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ:
+ return sizeof(ucache_sgraph_shaped_state_obj);
+ case UCACHE_SGRAPH_VAL_SERDES_OBJ:
+ return sizeof(ucache_sgraph_serdes_obj);
+ case UCACHE_SGRAPH_VAL_ENUM:
+ return sizeof(ucache_sgraph_enum);
+ case UCACHE_SGRAPH_VAL_REF:
+ return sizeof(ucache_sgraph_ref);
+ default:
+ return 0;
+ }
+}
+
+static zend_always_inline uintptr_t ucache_decode_resolved_of_kind(uintptr_t resolved, uintptr_t kind)
+{
+ ZEND_ASSERT((kind & ~UCACHE_DECODE_RESOLVE_KIND_MASK) == 0);
+
+ return (resolved & UCACHE_DECODE_RESOLVE_KIND_MASK) == kind ? resolved : 0;
+}
+
+static zend_always_inline uintptr_t ucache_decode_resolve_cache_find(const void *addr, uintptr_t kind)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+ uintptr_t resolved = 0;
+ uint32_t i;
+
+ if (UNEXPECTED(frame != NULL)) {
+ if (frame->resolve != NULL) {
+ resolved = (uintptr_t) zend_hash_index_find_ptr(frame->resolve, (zend_ulong) (uintptr_t) addr);
+ }
+
+ return ucache_decode_resolved_of_kind(resolved, kind);
+ }
+
+ for (i = 0; i < UCACHE_DECODE_DIRECT_CACHE_SLOTS; i++) {
+ if (UC_G(decode_resolve_direct_keys)[i] == addr) {
+ resolved = (uintptr_t) UC_G(decode_resolve_direct_vals)[i];
+
+ return ucache_decode_resolved_of_kind(resolved, kind);
+ }
+ }
+
+ if (UC_G(decode_resolve_cache) == NULL) {
+ return 0;
+ }
+
+ resolved = (uintptr_t) zend_hash_index_find_ptr(
+ UC_G(decode_resolve_cache),
+ (zend_ulong) (uintptr_t) addr
+ );
+
+ return ucache_decode_resolved_of_kind(resolved, kind);
+}
+
+static zend_always_inline void ucache_decode_resolve_cache_store(const void *addr, uintptr_t resolved)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+ void *val = (void *) resolved;
+ uint32_t slot;
+
+ ZEND_ASSERT((resolved & ~UCACHE_DECODE_RESOLVE_TAG_MASK) != 0);
+
+ if (UNEXPECTED(frame != NULL)) {
+ if (frame->resolve == NULL) {
+ frame->resolve = emalloc(sizeof(HashTable));
+
+ zend_hash_init(frame->resolve, 8, NULL, NULL, 0);
+ }
+
+ zend_hash_index_add_ptr(frame->resolve, (zend_ulong) (uintptr_t) addr, val);
+
+ return;
+ }
+
+ slot = UC_G(decode_resolve_direct_next)++ % UCACHE_DECODE_DIRECT_CACHE_SLOTS;
+
+ UC_G(decode_resolve_direct_keys)[slot] = addr;
+ UC_G(decode_resolve_direct_vals)[slot] = val;
+
+ if (UC_G(decode_resolve_cache) == NULL) {
+ UC_G(decode_resolve_cache) = emalloc(sizeof(HashTable));
+
+ zend_hash_init(UC_G(decode_resolve_cache), 8, NULL, NULL, 0);
+ } else if (zend_hash_num_elements(UC_G(decode_resolve_cache)) >= UCACHE_DECODE_CACHE_MAX_ENTRIES) {
+ zend_hash_clean(UC_G(decode_resolve_cache));
+ }
+
+ zend_hash_index_add_ptr(
+ UC_G(decode_resolve_cache),
+ (zend_ulong) (uintptr_t) addr,
+ val
+ );
+}
+
+static zend_always_inline uintptr_t ucache_decode_resolved_class(zend_class_entry *ce)
+{
+ uintptr_t resolved = (uintptr_t) ce | UCACHE_DECODE_RESOLVE_KIND_CLASS;
+
+ ZEND_ASSERT(((uintptr_t) ce & UCACHE_DECODE_RESOLVE_TAG_MASK) == 0);
+
+ if (zend_hash_find_known_hash(&ce->function_table, ZSTR_KNOWN(ZEND_STR_WAKEUP)) != NULL) {
+ return resolved | UCACHE_DECODE_CLASS_HAS_WAKEUP;
+ }
+
+ return resolved;
+}
+
+static zend_always_inline zend_class_entry *ucache_decode_resolved_class_entry(uintptr_t resolved)
+{
+ ZEND_ASSERT(resolved == 0 || (resolved & UCACHE_DECODE_RESOLVE_KIND_MASK) == UCACHE_DECODE_RESOLVE_KIND_CLASS);
+
+ return (zend_class_entry *) (resolved & ~UCACHE_DECODE_RESOLVE_TAG_MASK);
+}
+
+static zend_always_inline bool ucache_decode_resolved_class_has_wakeup(uintptr_t resolved)
+{
+ return (resolved & UCACHE_DECODE_CLASS_HAS_WAKEUP) != 0;
+}
+
+static zend_always_inline uintptr_t ucache_decode_resolved_enum_case(zend_object *case_obj)
+{
+ ZEND_ASSERT(((uintptr_t) case_obj & UCACHE_DECODE_RESOLVE_TAG_MASK) == 0);
+
+ return (uintptr_t) case_obj | UCACHE_DECODE_RESOLVE_KIND_ENUM_CASE;
+}
+
+static zend_always_inline zend_object *ucache_decode_resolved_enum_case_obj(uintptr_t resolved)
+{
+ ZEND_ASSERT(resolved == 0 || (resolved & UCACHE_DECODE_RESOLVE_KIND_MASK) == UCACHE_DECODE_RESOLVE_KIND_ENUM_CASE);
+
+ return (zend_object *) (resolved & ~UCACHE_DECODE_RESOLVE_TAG_MASK);
+}
+
+UCACHE_DEFINE_DECODE_MAP(
+ identity,
+ zend_object,
+ ucache_obj_table_dtor,
+ OBJ_RELEASE(entry))
+
+UCACHE_DEFINE_DECODE_MAP(
+ ref,
+ zend_reference,
+ ucache_ref_table_dtor,
+ if (GC_DELREF(entry) == 0) efree_size(entry, sizeof(zend_reference)))
+
+static zend_always_inline uint32_t ucache_decode_enter(void)
+{
+ uint32_t depth = ++UC_G(decode_depth);
+
+ if (depth == 1) {
+ UC_G(decode_nesting) = 0;
+ }
+
+ zend_fiber_switch_block();
+
+ if (UNEXPECTED(UC_G(decode_identity_map) != NULL || UC_G(decode_ref_map) != NULL)) {
+ ucache_decode_save_maps();
+ }
+
+ return depth;
+}
+
+static zend_always_inline bool ucache_decode_leave_impl(uint32_t depth, bool result)
+{
+ ucache_restore_queue *queue = UC_G(decode_restore_queue);
+
+ if (queue != NULL && queue->depth == depth) {
+ result = ucache_restore_queue_finish(queue, result);
+
+ ZEND_ASSERT(UC_G(decode_restore_queue) == queue);
+ } else {
+ queue = NULL;
+ }
+
+ ucache_decode_identity_map_teardown();
+ ucache_decode_ref_map_teardown();
+
+ if (queue != NULL) {
+ UC_G(decode_identity_map) = queue->saved_identity_map;
+ UC_G(decode_ref_map) = queue->saved_ref_map;
+ UC_G(decode_restore_queue) = queue->prev;
+
+ ucache_restore_queue_destroy(queue);
+ }
+
+ UC_G(decode_depth) = depth - 1;
+
+ zend_fiber_switch_unblock();
+
+ return result;
+}
+
+static zend_always_inline bool ucache_decode_nesting_overflowed(uint32_t nesting)
+{
+ if (!UCACHE_DECODE_COUNTS_NESTING || EXPECTED(nesting < UCACHE_DECODE_MAX_DEPTH)) {
+ return false;
+ }
+
+ UC_G(stack_overflowed) = true;
+
+ return true;
+}
+
+static zend_always_inline bool ucache_decode_node_enter(void)
+{
+ if (!UCACHE_DECODE_COUNTS_NESTING) {
+ return true;
+ }
+
+ if (ucache_decode_nesting_overflowed(UC_G(decode_nesting))) {
+ return false;
+ }
+
+ UC_G(decode_nesting)++;
+
+ return true;
+}
+
+static zend_always_inline bool ucache_decode_node_leave(bool result)
+{
+ if (UCACHE_DECODE_COUNTS_NESTING) {
+ ZEND_ASSERT(UC_G(decode_nesting) != 0);
+
+ UC_G(decode_nesting)--;
+ }
+
+ return result;
+}
+
+static zend_always_inline const void *ucache_decode_verbatim_arr_shm_addr(
+ const uint8_t *buf,
+ const uint8_t *snapshot_origin,
+ const zend_array *arr)
+{
+ if (snapshot_origin == NULL) {
+ return arr;
+ }
+
+ return snapshot_origin + ((const uint8_t *) arr - buf);
+}
+
+static zend_always_inline uintptr_t ucache_verdict_set_idx(const void *shm_addr)
+{
+ uintptr_t addr = (uintptr_t) shm_addr;
+
+ return (addr >> UCACHE_VERBATIM_VERDICT_ADDR_SHIFT) ^ (addr >> UCACHE_VERBATIM_VERDICT_ADDR_MIX_SHIFT);
+}
+
+static zend_always_inline bool ucache_verdict_set_find(const ucache_verbatim_verdict *set, const void *shm_addr)
+{
+ const ucache_ctx *ctx = ucache_active_ctx();
+ uint64_t gen = UC_G(decode_payload_gen);
+ uint32_t way;
+
+ for (way = 0; way < UCACHE_VERBATIM_VERDICT_WAYS; way++) {
+ if (set[way].arr == shm_addr && set[way].gen == gen && set[way].ctx == ctx) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static zend_always_inline void ucache_verdict_set_store(ucache_verbatim_verdict *set, uint8_t *next_way, const void *shm_addr)
+{
+ uint32_t way = *next_way % UCACHE_VERBATIM_VERDICT_WAYS;
+
+ (*next_way)++;
+
+ set[way].ctx = ucache_active_ctx();
+ set[way].arr = shm_addr;
+ set[way].gen = UC_G(decode_payload_gen);
+}
+
+static zend_always_inline ucache_verbatim_verdict *ucache_verbatim_verdict_set(const void *shm_arr)
+{
+ return &UC_G(verbatim_verdicts)[(ucache_verdict_set_idx(shm_arr) & (UCACHE_VERBATIM_VERDICT_SETS - 1)) * UCACHE_VERBATIM_VERDICT_WAYS];
+}
+
+static zend_always_inline ucache_verbatim_verdict *ucache_payload_verdict_set(const void *shm_payload)
+{
+ return &UC_G(payload_verdicts)[(ucache_verdict_set_idx(shm_payload) & (UCACHE_PAYLOAD_VERDICT_SETS - 1)) * UCACHE_VERBATIM_VERDICT_WAYS];
+}
+
+static zend_always_inline bool ucache_verbatim_verdict_find(const void *shm_arr)
+{
+ return ucache_verdict_set_find(ucache_verbatim_verdict_set(shm_arr), shm_arr);
+}
+
+static zend_always_inline void ucache_verbatim_verdict_store(const void *shm_arr)
+{
+ ucache_verdict_set_store(ucache_verbatim_verdict_set(shm_arr), &UC_G(verbatim_verdict_next_way), shm_arr);
+}
+
+static zend_always_inline bool ucache_decode_payload_begin(const void *shm_payload, uint64_t gen)
+{
+ UC_G(decode_payload_gen) = gen;
+ UC_G(decode_payload_validated) = ucache_verdict_set_find(ucache_payload_verdict_set(shm_payload), shm_payload);
+
+ return UC_G(decode_payload_validated);
+}
+
+static zend_always_inline void ucache_decode_payload_end(const void *shm_payload, bool validated, bool result)
+{
+ if (result && !validated) {
+ ucache_verdict_set_store(ucache_payload_verdict_set(shm_payload), &UC_G(payload_verdict_next_way), shm_payload);
+ }
+}
+
+static zend_always_inline const uint8_t *ucache_decode_snapshot_origin(void)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+
+ return frame != NULL && frame->snapshot != NULL ? frame->snapshot->origin : NULL;
+}
+
+static zend_always_inline HashTable *ucache_decode_shape_proto_cache(void)
+{
+ if (UC_G(decode_shape_proto_cache) == NULL) {
+ UC_G(decode_shape_proto_cache) = emalloc(sizeof(HashTable));
+ zend_hash_init(
+ UC_G(decode_shape_proto_cache),
+ 8,
+ NULL,
+ ucache_decode_shape_proto_dtor,
+ 0
+ );
+ }
+
+ return UC_G(decode_shape_proto_cache);
+}
+
+static zend_always_inline bool ucache_sgraph_decode_arr_next_free(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_arr *garr,
+ zend_long *next_free)
+{
+ int64_t wide;
+
+ if (!(garr->flags & UCACHE_SGRAPH_ARR_FLAG_WIDE_NEXT_FREE)) {
+#if SIZEOF_ZEND_LONG < 8
+ if ((uint64_t) garr->next_free > (uint64_t) ZEND_LONG_MAX) {
+ return false;
+ }
+#endif
+
+ *next_free = (zend_long) garr->next_free;
+
+ return true;
+ }
+
+ if (!ucache_decode_range_ok(buf_len, garr->next_free, sizeof(wide))) {
+ return false;
+ }
+
+ memcpy(&wide, buf + garr->next_free, sizeof(wide));
+
+ if (wide > (int64_t) ZEND_LONG_MAX || wide < (int64_t) ZEND_LONG_MIN) {
+ return false;
+ }
+
+ *next_free = (zend_long) wide;
+
+ return true;
+}
+
+static zend_always_inline zend_long ucache_sgraph_shape_next_free_decode(uint32_t next_free)
+{
+ return next_free == UCACHE_SGRAPH_SHAPE_NEXT_FREE_UNSET ? ZEND_LONG_MIN : (zend_long) next_free;
+}
+
+static zend_always_inline void ucache_decode_shape_proto_direct_cache_store(
+ const ucache_sgraph_arr_shape *gshape,
+ zend_array *proto)
+{
+ uint32_t slot;
+
+ slot = UC_G(decode_shape_proto_direct_next)++ % UCACHE_DECODE_DIRECT_CACHE_SLOTS;
+
+ UC_G(decode_shape_proto_direct_keys)[slot] = gshape;
+ UC_G(decode_shape_proto_direct_vals)[slot] = proto;
+}
+
+static zend_always_inline zend_ulong ucache_sgraph_elem_idx(
+ const ucache_sgraph_arr_elem *elem)
+{
+ return (zend_ulong) (((uint64_t) elem->h_hi << 32) | elem->key);
+}
+
+static zend_always_inline bool ucache_decode_arr_still_packed_fillable(const HashTable *arr, uint32_t remaining)
+{
+ return HT_IS_PACKED(arr) &&
+ arr->nInternalPointer == 0 &&
+ arr->nNumUsed == arr->nNumOfElements &&
+ arr->nNextFreeElement == arr->nNumUsed &&
+ remaining <= arr->nTableSize - arr->nNumUsed
+ ;
+}
+
+static zend_always_inline const void *ucache_verbatim_resolve_ptr(
+ const ucache_verbatim_check_ctx *ctx,
+ const void *ptr,
+ size_t size,
+ size_t alignment)
+{
+ uintptr_t addr = (uintptr_t) ptr, base;
+ size_t offset;
+
+ if ((addr & (alignment - 1)) != 0) {
+ return NULL;
+ }
+
+ base = (uintptr_t) ctx->buf;
+ if (addr >= base && addr - base <= ctx->buf_len) {
+ offset = (size_t) (addr - base);
+
+ return size <= ctx->buf_len - offset ? ptr : NULL;
+ }
+
+ if (ctx->snapshot_origin == NULL) {
+ return NULL;
+ }
+
+ base = (uintptr_t) ctx->snapshot_origin;
+ if (addr < base || addr - base > ctx->buf_len) {
+ return NULL;
+ }
+
+ offset = (size_t) (addr - base);
+
+ return size <= ctx->buf_len - offset ? ctx->buf + offset : NULL;
+}
+
+static zend_always_inline bool ucache_verbatim_str_check(
+ const ucache_verbatim_check_ctx *ctx,
+ const zend_string *str)
+{
+ const zend_string *hdr;
+
+ hdr = ucache_verbatim_resolve_ptr(ctx, str, _ZSTR_HEADER_SIZE, alignof(zend_string));
+ if (hdr == NULL || !ucache_decode_str_hdr_ok(hdr) || ZSTR_LEN(hdr) > ctx->buf_len) {
+ return false;
+ }
+
+ return ucache_verbatim_resolve_ptr(ctx, str, _ZSTR_STRUCT_SIZE(ZSTR_LEN(hdr)), alignof(zend_string)) != NULL;
+}
+
+static zend_always_inline uint32_t ucache_verbatim_hash_slot(const zend_array *arr, zend_ulong h)
+{
+ uint32_t idx = (uint32_t) h | arr->nTableMask;
+
+ return idx - arr->nTableMask;
+}
+
+static zend_always_inline bool ucache_verbatim_elem_check(
+ ucache_verbatim_check_ctx *ctx,
+ const zval *val)
+{
+ const zend_array *arr;
+
+ switch (Z_TYPE_INFO_P(val)) {
+ case IS_UNDEF:
+ case IS_NULL:
+ case IS_FALSE:
+ case IS_TRUE:
+ case IS_LONG:
+ case IS_DOUBLE:
+ return true;
+ case IS_INTERNED_STRING_EX:
+ return ucache_verbatim_str_check(ctx, Z_STR_P(val));
+ case IS_ARRAY:
+ if (Z_ARR_P(val) == &zend_empty_array) {
+ return true;
+ }
+
+ arr = ucache_verbatim_resolve_ptr(ctx, Z_ARR_P(val), sizeof(zend_array), alignof(zend_array));
+
+ return arr != NULL && ucache_verbatim_arr_check(ctx, arr);
+ default:
+ return false;
+ }
+}
+
+static zend_always_inline bool ucache_decode_verbatim_arr_ok(
+ const uint8_t *buf,
+ size_t buf_len,
+ const uint8_t *snapshot_origin,
+ const zend_array *arr)
+{
+ const void *shm_arr;
+
+ if (EXPECTED(UC_G(decode_payload_validated))) {
+ return true;
+ }
+
+ shm_arr = ucache_decode_verbatim_arr_shm_addr(buf, snapshot_origin, arr);
+ if (ucache_verbatim_verdict_find(shm_arr)) {
+ return true;
+ }
+
+ return ucache_decode_verbatim_arr_validate(buf, buf_len, snapshot_origin, arr, shm_arr);
+}
+
+static zend_always_inline bool ucache_sgraph_decode_simple_val(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ zend_string *str;
+ int64_t wide;
+ double dval;
+
+ switch (gval->type) {
+ case UCACHE_SGRAPH_VAL_UNDEF:
+ return false;
+ case UCACHE_SGRAPH_VAL_NULL:
+ ZVAL_NULL(dst);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_TRUE:
+ ZVAL_TRUE(dst);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_FALSE:
+ ZVAL_FALSE(dst);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_LONG:
+ ZVAL_LONG(dst, gval->long_val);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_LONG_WIDE:
+ if (!ucache_decode_range_ok(buf_len, gval->offset, sizeof(wide))) {
+ return false;
+ }
+
+ memcpy(&wide, buf + gval->offset, sizeof(wide));
+#if SIZEOF_ZEND_LONG < 8
+ if (wide > (int64_t) ZEND_LONG_MAX || wide < (int64_t) ZEND_LONG_MIN) {
+ return false;
+ }
+#endif
+
+ ZVAL_LONG(dst, (zend_long) wide);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_DOUBLE:
+ if (!ucache_decode_range_ok(buf_len, gval->offset, sizeof(dval))) {
+ return false;
+ }
+
+ memcpy(&dval, buf + gval->offset, sizeof(dval));
+
+ ZVAL_DOUBLE(dst, dval);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_STR:
+ str = ucache_decode_str_at(buf, buf_len, gval->offset);
+ if (str == NULL) {
+ return false;
+ }
+
+ if (UNEXPECTED(UC_G(owned_decode_frame) != NULL)) {
+ ZVAL_STR_COPY(dst, str);
+ } else {
+ ZVAL_INTERNED_STR(dst, str);
+ }
+
+ return true;
+ default:
+ return false;
+ }
+}
+
+static inline zend_object *ucache_enum_case_find(zend_class_entry *ce, zend_string *name)
+{
+ zend_class_constant *c;
+
+ c = zend_hash_find_ptr(CE_CONSTANTS_TABLE(ce), name);
+ if (c == NULL || !(ZEND_CLASS_CONST_FLAGS(c) & ZEND_CLASS_CONST_IS_CASE)) {
+ return NULL;
+ }
+
+ if (Z_TYPE(c->value) == IS_CONSTANT_AST &&
+ zval_update_constant_ex(&c->value, c->ce) == FAILURE
+ ) {
+ return NULL;
+ }
+
+ if (Z_TYPE(c->value) != IS_OBJECT) {
+ return NULL;
+ }
+
+ return Z_OBJ(c->value);
+}
+
+static void ucache_owned_str_dtor(zval *val)
+{
+ zend_string_release((zend_string *) Z_PTR_P(val));
+}
+
+static ZEND_COLD zend_never_inline void ucache_decode_release_keeping_overflow(zval *dst)
+{
+ bool overflowed = UC_G(stack_overflowed);
+
+ if (Z_TYPE_P(dst) == IS_OBJECT) {
+ GC_ADD_FLAGS(Z_OBJ_P(dst), IS_OBJ_DESTRUCTOR_CALLED);
+ }
+
+ zval_ptr_dtor(dst);
+
+ ZVAL_UNDEF(dst);
+
+ UC_G(stack_overflowed) = overflowed;
+}
+
+static ucache_restore_queue *ucache_restore_queue_push(void)
+{
+ ucache_restore_queue *queue = ecalloc(1, sizeof(*queue));
+
+ queue->prev = UC_G(decode_restore_queue);
+ queue->depth = UC_G(decode_depth);
+
+ UC_G(decode_restore_queue) = queue;
+
+ return queue;
+}
+
+static bool ucache_defer_restore(zval *obj, zval *state)
+{
+ ucache_restore_queue *queue = UC_G(decode_restore_queue);
+ ucache_restore_call *call;
+ uint32_t capacity;
+
+ if (queue == NULL || queue->depth != UC_G(decode_depth)) {
+ if (UNEXPECTED(UC_G(decode_depth) == 0)) {
+ return false;
+ }
+
+ queue = ucache_restore_queue_push();
+ }
+
+ if (queue->count == queue->capacity) {
+ if (queue->capacity > UINT32_MAX / 2) {
+ return false;
+ }
+
+ capacity = queue->capacity == 0 ? 8 : queue->capacity * 2;
+
+ queue->calls = safe_erealloc(queue->calls, capacity, sizeof(*queue->calls), 0);
+ queue->capacity = capacity;
+ }
+
+ call = &queue->calls[queue->count++];
+ ZVAL_COPY(&call->obj, obj);
+
+ UC_G(restore_hook_calls)++;
+
+ if (state != NULL) {
+ ZVAL_COPY(&call->state, state);
+ } else {
+ ZVAL_UNDEF(&call->state);
+ }
+
+ return true;
+}
+
+static zend_never_inline bool ucache_restore_queue_finish_owned(
+ ucache_restore_queue *queue,
+ bool result)
+{
+ return ucache_restore_queue_finish_impl(queue, result, true);
+}
+
+static bool ucache_restore_queue_finish(ucache_restore_queue *queue, bool result)
+{
+ if (UNEXPECTED(UC_G(owned_decode_frame) != NULL)) {
+ return ucache_restore_queue_finish_owned(queue, result);
+ }
+
+ return ucache_restore_queue_finish_impl(queue, result, false);
+}
+
+static void ucache_restore_queue_destroy(ucache_restore_queue *queue)
+{
+ if (queue->calls != NULL) {
+ efree(queue->calls);
+ }
+
+ efree(queue);
+}
+
+static bool ucache_sgraph_mangled_name_is_well_formed(const zend_string *name)
+{
+ size_t class_name_len;
+
+ if (ZSTR_LEN(name) < 3 || ZSTR_VAL(name)[1] == '\0') {
+ return false;
+ }
+
+ class_name_len = zend_strnlen(ZSTR_VAL(name) + 1, ZSTR_LEN(name) - 2);
+
+ return class_name_len < ZSTR_LEN(name) - 2 && ZSTR_VAL(name)[class_name_len + 1] == '\0';
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_try_update_declared_prop(
+ zend_object *obj,
+ zend_string *prop_name,
+ zend_property_info *prop_info,
+ zval *prop_val,
+ bool *failed)
+{
+ zval *slot, tmp, indirect;
+
+ *failed = false;
+
+ if (prop_info == NULL ||
+ prop_info == ZEND_WRONG_PROPERTY_INFO ||
+ !zend_string_equals(prop_info->name, prop_name) ||
+ (prop_info->flags & (ZEND_ACC_STATIC|ZEND_ACC_VIRTUAL)) != 0 ||
+ prop_info->offset == ZEND_VIRTUAL_PROPERTY_OFFSET
+ ) {
+ return false;
+ }
+
+ slot = OBJ_PROP(obj, prop_info->offset);
+
+ if (Z_ISREF_P(prop_val)) {
+ if (ZEND_TYPE_IS_SET(prop_info->type)) {
+ if (!zend_verify_prop_assignable_by_ref(prop_info, prop_val, true)) {
+ *failed = true;
+
+ return false;
+ }
+
+ ZEND_REF_ADD_TYPE_SOURCE(Z_REF_P(prop_val), prop_info);
+ }
+
+ ZVAL_COPY(&tmp, prop_val);
+ } else {
+ if (UNEXPECTED(prop_info->flags & ZEND_ACC_READONLY) &&
+ !Z_ISUNDEF_P(slot) &&
+ !(Z_PROP_FLAG_P(slot) & IS_PROP_REINITABLE)
+ ) {
+ zend_readonly_property_modification_error(prop_info);
+
+ *failed = true;
+
+ return false;
+ }
+
+ ZVAL_COPY(&tmp, prop_val);
+
+ if (ZEND_TYPE_IS_SET(prop_info->type) &&
+ !zend_verify_property_type(prop_info, &tmp, true)
+ ) {
+ zval_ptr_dtor(&tmp);
+
+ *failed = true;
+
+ return false;
+ }
+ }
+
+ if (UNEXPECTED(Z_ISREF_P(slot)) && ZEND_TYPE_IS_SET(prop_info->type)) {
+ ZEND_REF_DEL_TYPE_SOURCE(Z_REF_P(slot), prop_info);
+ }
+
+ zval_ptr_dtor(slot);
+
+ ZVAL_COPY_VALUE(slot, &tmp);
+
+ if (obj->properties != NULL) {
+ ZVAL_INDIRECT(&indirect, slot);
+ zend_hash_update(obj->properties, prop_name, &indirect);
+ }
+
+ return true;
+}
+
+static zend_property_info *ucache_sgraph_renamed_prop_info(zend_class_entry *ce, zend_string *prop_name)
+{
+ const char *class_name = NULL, *unmangled_name;
+ size_t unmangled_name_len;
+
+ if (zend_hash_num_elements(&ce->properties_info) == 0 ||
+ zend_unmangle_property_name_ex(prop_name, &class_name, &unmangled_name, &unmangled_name_len) != SUCCESS
+ ) {
+ return NULL;
+ }
+
+ if (class_name != NULL && strcmp(class_name, "*") != 0 && strcasecmp(class_name, ZSTR_VAL(ce->name)) != 0) {
+ return NULL;
+ }
+
+ return zend_hash_str_find_ptr(&ce->properties_info, unmangled_name, unmangled_name_len);
+}
+
+static bool ucache_sgraph_insert_prop(zend_object *obj, zend_string *prop_name, zval *prop_val)
+{
+ Z_TRY_ADDREF_P(prop_val);
+
+ zend_hash_update(zend_std_get_properties_ex(obj), prop_name, prop_val);
+
+ return true;
+}
+
+static bool ucache_sgraph_add_dynamic_prop(zend_object *obj, zend_string *prop_name, zval *prop_val)
+{
+ if (UNEXPECTED(obj->ce->ce_flags & ZEND_ACC_NO_DYNAMIC_PROPERTIES)) {
+ zend_throw_error(
+ NULL,
+ "Cannot create dynamic property %s::$%s",
+ ZSTR_VAL(obj->ce->name),
+ zend_get_unmangled_property_name(prop_name)
+ );
+
+ return false;
+ }
+
+ if (!(obj->ce->ce_flags & ZEND_ACC_ALLOW_DYNAMIC_PROPERTIES)) {
+ zend_error(
+ E_DEPRECATED,
+ "Creation of dynamic property %s::$%s is deprecated",
+ ZSTR_VAL(obj->ce->name),
+ zend_get_unmangled_property_name(prop_name)
+ );
+
+ if (EG(exception)) {
+ return false;
+ }
+ }
+
+ return ucache_sgraph_insert_prop(obj, prop_name, prop_val);
+}
+
+static PHP_UCACHE_HOT uintptr_t ucache_decode_lookup_class(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint32_t class_name_offset)
+{
+ const void *key;
+ zend_string *class_name;
+ zend_class_entry *ce;
+ uintptr_t resolved;
+
+ if (UNEXPECTED(class_name_offset >= buf_len)) {
+ return 0;
+ }
+
+ key = buf + class_name_offset;
+ resolved = ucache_decode_resolve_cache_find(key, UCACHE_DECODE_RESOLVE_KIND_CLASS);
+ if (resolved != 0) {
+ return resolved;
+ }
+
+ class_name = ucache_decode_str_at(buf, buf_len, class_name_offset);
+ if (class_name == NULL) {
+ return 0;
+ }
+
+ BG(serialize_lock)++;
+ ce = zend_lookup_class(class_name);
+ BG(serialize_lock)--;
+
+ if (!ucache_owned_decode_validate_proc() || ce == NULL) {
+ return 0;
+ }
+
+ resolved = ucache_decode_resolved_class(ce);
+ ucache_decode_resolve_cache_store(key, resolved);
+
+ return resolved;
+}
+
+static PHP_UCACHE_HOT uintptr_t ucache_decode_lookup_state_schema_class(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_state_schema *state_schema)
+{
+ uintptr_t resolved;
+
+ resolved = ucache_decode_resolve_cache_find(state_schema, UCACHE_DECODE_RESOLVE_KIND_CLASS);
+ if (resolved != 0) {
+ return resolved;
+ }
+
+ resolved = ucache_decode_lookup_class(buf, buf_len, state_schema->class_name_offset);
+ if (resolved != 0) {
+ ucache_decode_resolve_cache_store(state_schema, resolved);
+ }
+
+ return resolved;
+}
+
+static void ucache_owned_acyclic_verbatim_arr_dtor(zval *zv)
+{
+ zend_array_release((zend_array *) Z_PTR_P(zv));
+}
+
+static void ucache_decode_shape_proto_dtor(zval *zv)
+{
+ zend_array_destroy((zend_array *) Z_PTR_P(zv));
+}
+
+static zend_array *ucache_decode_shape_proto_create(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_arr_shape *gshape)
+{
+ const ucache_sgraph_arr_shape_elem *shape_elems, *shape_elem;
+ zend_string *prop_name;
+ zend_array *proto;
+ zval empty;
+ uint32_t i, count;
+
+ count = gshape->count;
+ if (count == 0 ||
+ count > UCACHE_SGRAPH_ARR_SHAPE_MAX_KEYS ||
+ !ucache_decode_arr_range_ok(buf_len, gshape->elems_offset, count, sizeof(*shape_elems))
+ ) {
+ return NULL;
+ }
+
+ proto = zend_new_array(count);
+ if (HT_FLAGS(proto) & HASH_FLAG_UNINITIALIZED) {
+ zend_hash_real_init_mixed(proto);
+ }
+
+ shape_elems =
+ (const ucache_sgraph_arr_shape_elem *) (buf + gshape->elems_offset)
+ ;
+
+ ZVAL_LONG(&empty, 0);
+
+ for (i = 0; i < count; i++) {
+ shape_elem = &shape_elems[i];
+ prop_name = ucache_decode_str_at(buf, buf_len, shape_elem->key_offset);
+ if (shape_elem->key_offset == 0 || prop_name == NULL) {
+ zend_array_destroy(proto);
+
+ return NULL;
+ }
+
+ if (_zend_hash_append_ex(proto, prop_name, &empty, UC_G(owned_decode_frame) == NULL) == NULL) {
+ zend_array_destroy(proto);
+
+ return NULL;
+ }
+ }
+
+ proto->nNextFreeElement = 0;
+
+ return proto;
+}
+
+static zend_array *ucache_decode_shape_proto_borrow(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_arr_shape *gshape)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+ zend_ulong key;
+ zend_array *proto;
+ HashTable *cache;
+ uint32_t i;
+
+ if (UNEXPECTED(frame != NULL)) {
+ key = (zend_ulong) (uintptr_t) gshape;
+ if (frame->shapes != NULL) {
+ proto = zend_hash_index_find_ptr(frame->shapes, key);
+ if (proto != NULL) {
+ return proto;
+ }
+ } else {
+ frame->shapes = emalloc(sizeof(HashTable));
+
+ zend_hash_init(frame->shapes, 8, NULL, ucache_decode_shape_proto_dtor, 0);
+ }
+
+ proto = ucache_decode_shape_proto_create(buf, buf_len, gshape);
+ if (proto != NULL) {
+ zend_hash_index_add_new_ptr(frame->shapes, key, proto);
+ }
+
+ return proto;
+ }
+
+ for (i = 0; i < UCACHE_DECODE_DIRECT_CACHE_SLOTS; i++) {
+ if (UC_G(decode_shape_proto_direct_keys)[i] == gshape) {
+ return UC_G(decode_shape_proto_direct_vals)[i];
+ }
+ }
+
+ key = (zend_ulong) (uintptr_t) gshape;
+ if (UC_G(decode_shape_proto_cache) != NULL) {
+ proto = zend_hash_index_find_ptr(UC_G(decode_shape_proto_cache), key);
+ if (proto != NULL) {
+ ucache_decode_shape_proto_direct_cache_store(gshape, proto);
+
+ return proto;
+ }
+ }
+
+ proto = ucache_decode_shape_proto_create(buf, buf_len, gshape);
+ if (proto == NULL) {
+ return NULL;
+ }
+
+ cache = ucache_decode_shape_proto_cache();
+ if (zend_hash_num_elements(cache) >= UCACHE_DECODE_CACHE_MAX_ENTRIES) {
+ zend_hash_clean(cache);
+
+ memset((void *) UC_G(decode_shape_proto_direct_keys), 0, sizeof(UC_G(decode_shape_proto_direct_keys)));
+ memset(UC_G(decode_shape_proto_direct_vals), 0, sizeof(UC_G(decode_shape_proto_direct_vals)));
+ }
+
+ if (zend_hash_index_add_ptr(cache, key, proto) == NULL) {
+ zend_array_destroy(proto);
+
+ return NULL;
+ }
+
+ ucache_decode_shape_proto_direct_cache_store(gshape, proto);
+
+ return proto;
+}
+
+static PHP_UCACHE_HOT bool ucache_decode_shape_proto_clone(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_arr_shape *gshape,
+ uint32_t count,
+ uint32_t next_free,
+ zval *dst)
+{
+ zend_array *proto, *arr;
+
+ if (gshape->count != count) {
+ return false;
+ }
+
+ proto = ucache_decode_shape_proto_borrow(buf, buf_len, gshape);
+ if (proto == NULL || proto->nNumUsed != count || proto->nNumOfElements != count) {
+ return false;
+ }
+
+ if (UNEXPECTED(UC_G(owned_decode_frame) != NULL)) {
+ arr = zend_array_dup(proto);
+ arr->nInternalPointer = 0;
+ arr->nNextFreeElement = ucache_sgraph_shape_next_free_decode(next_free);
+
+ ZVAL_ARR(dst, arr);
+
+ return true;
+ }
+
+ ALLOC_HASHTABLE(arr);
+
+ GC_SET_REFCOUNT(arr, 1);
+ GC_TYPE_INFO(arr) = GC_ARRAY;
+
+ HT_FLAGS(arr) = HT_FLAGS(proto) & HASH_FLAG_MASK;
+
+ arr->nTableMask = proto->nTableMask;
+ arr->nNumUsed = proto->nNumUsed;
+ arr->nNumOfElements = proto->nNumOfElements;
+ arr->nTableSize = proto->nTableSize;
+ arr->nInternalPointer = 0;
+ arr->nNextFreeElement = ucache_sgraph_shape_next_free_decode(next_free);
+ arr->pDestructor = ZVAL_PTR_DTOR;
+
+ HT_SET_DATA_ADDR(arr, emalloc(HT_SIZE(arr)));
+
+ ZEND_ASSERT(HT_HAS_STATIC_KEYS_ONLY(proto));
+
+ memcpy(HT_GET_DATA_ADDR(arr), HT_GET_DATA_ADDR(proto), HT_USED_SIZE(proto));
+
+ ZVAL_ARR(dst, arr);
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_shaped_arr(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint32_t shape_offset,
+ uint32_t vals_offset,
+ uint32_t count,
+ uint32_t next_free,
+ zval *dst)
+{
+ const ucache_sgraph_arr_shape *gshape;
+ const ucache_sgraph_val *gvals;
+ zval val;
+ Bucket *bucket;
+ uint32_t i;
+
+ if (!ucache_decode_range_ok(buf_len, shape_offset, sizeof(*gshape)) ||
+ !ucache_decode_arr_range_ok(buf_len, vals_offset, count, sizeof(*gvals))
+ ) {
+ return false;
+ }
+
+ gshape = (const ucache_sgraph_arr_shape *) (buf + shape_offset);
+ if (!ucache_decode_shape_proto_clone(
+ buf,
+ buf_len,
+ gshape,
+ count,
+ next_free,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ HT_ALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+
+ gvals = (const ucache_sgraph_val *) (buf + vals_offset);
+ bucket = Z_ARRVAL_P(dst)->arData;
+
+ for (i = 0; i < count; i++) {
+ if (UNEXPECTED(gvals[i].type == UCACHE_SGRAPH_VAL_UNDEF)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (ucache_sgraph_decode_simple_val(buf, buf_len, &gvals[i], &bucket[i].val)) {
+ continue;
+ }
+
+ ZVAL_UNDEF(&val);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &gvals[i], &val)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ ZVAL_COPY_VALUE(&bucket[i].val, &val);
+ }
+
+ UCACHE_HT_DISALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+
+ return true;
+}
+
+static zend_never_inline bool ucache_sgraph_decode_packed_vals(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_arr *garr,
+ zval *dst)
+{
+ const ucache_sgraph_val *gvals, *gval;
+ zend_long next_free;
+ zval val;
+ uint32_t i, count;
+
+ count = garr->count;
+ if (!ucache_decode_arr_range_ok(
+ buf_len,
+ garr->elems_offset,
+ count,
+ sizeof(*gvals)
+ )
+ ) {
+ return false;
+ }
+
+ array_init_size(dst, count);
+
+ if (count > 0) {
+ zend_hash_real_init_packed(Z_ARRVAL_P(dst));
+
+ HT_ALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+ }
+
+ gvals = (const ucache_sgraph_val *) (buf + garr->elems_offset);
+
+ for (i = 0; i < count; i++) {
+ gval = &gvals[i];
+
+ if (gval->type > UCACHE_SGRAPH_VAL_UNDEF &&
+ gval->type <= UCACHE_SGRAPH_VAL_STR &&
+ ucache_decode_arr_still_packed_fillable(Z_ARRVAL_P(dst), count - i)
+ ) {
+ ZEND_HASH_FILL_PACKED(Z_ARRVAL_P(dst)) {
+ while (i < count) {
+ gval = &gvals[i];
+ if (gval->type == UCACHE_SGRAPH_VAL_UNDEF ||
+ gval->type > UCACHE_SGRAPH_VAL_STR
+ ) {
+ break;
+ }
+
+ if (!ucache_sgraph_decode_simple_val(buf, buf_len, gval, &val)) {
+ ZEND_HASH_FILL_FINISH();
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ ZEND_HASH_FILL_ADD(&val);
+
+ i++;
+ }
+ } ZEND_HASH_FILL_END();
+
+ if (i == count) {
+ break;
+ }
+
+ gval = &gvals[i];
+ }
+
+ if (!ucache_sgraph_decode_simple_val(buf, buf_len, gval, &val)) {
+ ZVAL_UNDEF(&val);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, gval, &val)) {
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (zend_hash_next_index_insert_new(Z_ARRVAL_P(dst), &val) == NULL) {
+ zval_ptr_dtor(&val);
+
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (!ucache_sgraph_decode_arr_next_free(buf, buf_len, garr, &next_free)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ Z_ARRVAL_P(dst)->nNextFreeElement = next_free;
+
+ UCACHE_HT_DISALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_dynamic_arr(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_arr *garr;
+ const ucache_sgraph_arr_elem *gelems, *gelem;
+ zend_long next_free;
+ zend_string *key;
+ zval val;
+ uint32_t i, count;
+
+ if (!ucache_decode_range_ok(buf_len, gval->offset, sizeof(*garr))) {
+ return false;
+ }
+
+ garr = (const ucache_sgraph_arr *) (buf + gval->offset);
+ if (garr->flags & UCACHE_SGRAPH_ARR_FLAG_PACKED_VALS) {
+ if (!(garr->flags & UCACHE_SGRAPH_ARR_FLAG_PACKED)) {
+ return false;
+ }
+
+ return ucache_sgraph_decode_packed_vals(buf, buf_len, garr, dst);
+ }
+
+ count = garr->count;
+ if (!ucache_decode_arr_range_ok(
+ buf_len,
+ garr->elems_offset,
+ count,
+ sizeof(*gelems)
+ )
+ ) {
+ return false;
+ }
+
+ array_init_size(dst, count);
+
+ if (count > 0) {
+ if (garr->flags & UCACHE_SGRAPH_ARR_FLAG_PACKED) {
+ zend_hash_real_init_packed(Z_ARRVAL_P(dst));
+ } else {
+ zend_hash_real_init_mixed(Z_ARRVAL_P(dst));
+ }
+
+ HT_ALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+ }
+
+ gelems = (const ucache_sgraph_arr_elem *) (buf + garr->elems_offset);
+
+ if (garr->flags & UCACHE_SGRAPH_ARR_FLAG_PACKED) {
+ for (i = 0; i < count; i++) {
+ gelem = &gelems[i];
+
+ if (gelem->val.type > UCACHE_SGRAPH_VAL_UNDEF &&
+ gelem->val.type <= UCACHE_SGRAPH_VAL_STR &&
+ ucache_decode_arr_still_packed_fillable(Z_ARRVAL_P(dst), count - i)
+ ) {
+ ZEND_HASH_FILL_PACKED(Z_ARRVAL_P(dst)) {
+ while (i < count) {
+ gelem = &gelems[i];
+
+ if (gelem->val.type == UCACHE_SGRAPH_VAL_UNDEF ||
+ gelem->val.type > UCACHE_SGRAPH_VAL_STR
+ ) {
+ break;
+ }
+
+ if ((gelem->val.flags & UCACHE_SGRAPH_ELEM_STR_KEY) != 0 ||
+ ucache_sgraph_elem_idx(gelem) != i ||
+ !ucache_sgraph_decode_simple_val(
+ buf,
+ buf_len,
+ &gelem->val,
+ &val
+ )
+ ) {
+ ZEND_HASH_FILL_FINISH();
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ ZEND_HASH_FILL_ADD(&val);
+
+ i++;
+ }
+ } ZEND_HASH_FILL_END();
+
+ if (i == count) {
+ break;
+ }
+
+ gelem = &gelems[i];
+ }
+
+ if ((gelem->val.flags & UCACHE_SGRAPH_ELEM_STR_KEY) != 0 ||
+ ucache_sgraph_elem_idx(gelem) != i
+ ) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (!ucache_sgraph_decode_simple_val(buf, buf_len, &gelem->val, &val)) {
+ ZVAL_UNDEF(&val);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &gelem->val, &val)) {
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (zend_hash_next_index_insert_new(Z_ARRVAL_P(dst), &val) == NULL) {
+ zval_ptr_dtor(&val);
+
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (!ucache_sgraph_decode_arr_next_free(buf, buf_len, garr, &next_free)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ Z_ARRVAL_P(dst)->nNextFreeElement = next_free;
+
+ UCACHE_HT_DISALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+
+ return true;
+ }
+
+ for (i = 0; i < count; i++) {
+ gelem = &gelems[i];
+
+ if (!ucache_sgraph_decode_simple_val(buf, buf_len, &gelem->val, &val)) {
+ ZVAL_UNDEF(&val);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &gelem->val, &val)) {
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (gelem->val.flags & UCACHE_SGRAPH_ELEM_STR_KEY) {
+ key = ucache_decode_str_at(buf, buf_len, gelem->key);
+ if (key == NULL) {
+ zval_ptr_dtor(&val);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ _zend_hash_append(Z_ARRVAL_P(dst), key, &val);
+ } else {
+ zend_hash_index_add_new(Z_ARRVAL_P(dst), ucache_sgraph_elem_idx(gelem), &val);
+ }
+ }
+
+ if (!ucache_sgraph_decode_arr_next_free(buf, buf_len, garr, &next_free)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ Z_ARRVAL_P(dst)->nNextFreeElement = next_free;
+
+ UCACHE_HT_DISALLOW_COW_VIOLATION(Z_ARRVAL_P(dst));
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_init_obj(
+ zend_class_entry *ce,
+ uint32_t node_offset,
+ bool shared,
+ zval *dst)
+{
+ if ((ce->ce_flags & (ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_ENUM)) != 0 || object_init_ex(dst, ce) != SUCCESS) {
+ return false;
+ }
+
+ if (!ucache_owned_decode_validate_proc()) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (shared && !ucache_decode_identity_map_insert(node_offset, Z_OBJ_P(dst))) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_update_obj_prop(
+ zval *obj_zv,
+ zend_string *prop_name,
+ zval *prop_val)
+{
+ const char *unmangled_class, *unmangled_prop;
+ zend_object *obj;
+ zend_property_info *prop_info;
+ size_t unmangled_prop_len;
+ bool failed;
+
+ obj = Z_OBJ_P(obj_zv);
+
+ if (ZSTR_LEN(prop_name) != 0 && ZSTR_VAL(prop_name)[0] == '\0' &&
+ zend_hash_num_elements(&obj->ce->properties_info) != 0 &&
+ zend_unmangle_property_name_ex(prop_name, &unmangled_class, &unmangled_prop, &unmangled_prop_len) != SUCCESS
+ ) {
+ return false;
+ }
+
+ prop_info = ucache_sgraph_declared_prop_info(obj->ce, prop_name);
+ if (ucache_sgraph_try_update_declared_prop(
+ obj,
+ prop_name,
+ prop_info,
+ prop_val,
+ &failed
+ )
+ ) {
+ return true;
+ }
+
+ if (failed) {
+ return false;
+ }
+
+ prop_info = ucache_sgraph_renamed_prop_info(obj->ce, prop_name);
+ if (prop_info != NULL) {
+ if (prop_info->flags & ZEND_ACC_VIRTUAL) {
+ php_error_docref(
+ NULL,
+ E_WARNING,
+ "Cannot unserialize value for virtual property %s::$%s",
+ ZSTR_VAL(prop_info->ce->name),
+ zend_get_unmangled_property_name(prop_name)
+ );
+
+ return false;
+ }
+
+ if (ucache_sgraph_try_update_declared_prop(
+ obj,
+ prop_info->name,
+ prop_info,
+ prop_val,
+ &failed
+ )
+ ) {
+ return true;
+ }
+
+ if (failed) {
+ return false;
+ }
+
+ return ucache_sgraph_insert_prop(obj, prop_info->name, prop_val);
+ }
+
+ return ucache_sgraph_add_dynamic_prop(obj, prop_name, prop_val);
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_update_obj_prop_at(
+ zval *obj_zv,
+ zend_string *prop_name,
+ uint32_t prop_idx,
+ zval *prop_val)
+{
+ zend_object *obj;
+ bool failed;
+
+ obj = Z_OBJ_P(obj_zv);
+ if (obj->ce->type == ZEND_USER_CLASS &&
+ obj->ce->properties_info_table != NULL &&
+ prop_idx < obj->ce->default_properties_count
+ ) {
+ if (ucache_sgraph_try_update_declared_prop(
+ obj,
+ prop_name,
+ obj->ce->properties_info_table[prop_idx],
+ prop_val,
+ &failed
+ )
+ ) {
+ return true;
+ }
+
+ if (failed) {
+ return false;
+ }
+ }
+
+ return ucache_sgraph_update_obj_prop(
+ obj_zv,
+ prop_name,
+ prop_val
+ );
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_apply_prop(
+ zval *dst,
+ zend_string *prop_name,
+ uint32_t slot_idx_plus_one,
+ zval *prop_val)
+{
+ bool result;
+
+ if (slot_idx_plus_one != 0) {
+ result = ucache_sgraph_update_obj_prop_at(
+ dst,
+ prop_name,
+ slot_idx_plus_one - 1,
+ prop_val
+ );
+ } else {
+ result = ucache_sgraph_update_obj_prop(
+ dst,
+ prop_name,
+ prop_val
+ );
+ }
+
+ if (!result) {
+ zval_ptr_dtor(prop_val);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ zval_ptr_dtor_nogc(prop_val);
+
+ if (!ucache_owned_decode_validate_proc()) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_obj_props(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint32_t props_offset,
+ uint32_t prop_count,
+ bool use_sleep_slots,
+ zval *dst)
+{
+ const ucache_sgraph_prop *props, *prop;
+ zend_string *prop_name;
+ zval prop_val;
+ uint32_t i;
+
+ if (prop_count == 0) {
+ return true;
+ }
+
+ if (!ucache_decode_arr_range_ok(buf_len, props_offset, prop_count, sizeof(*props))) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ props = (const ucache_sgraph_prop *) (buf + props_offset);
+ for (i = 0; i < prop_count; i++) {
+ prop = &props[i];
+ if (prop->val.type == UCACHE_SGRAPH_VAL_UNDEF) {
+ continue;
+ }
+
+ prop_name = ucache_decode_str_at(buf, buf_len, prop->name_offset);
+ if (prop_name == NULL) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (!ucache_sgraph_decode_simple_val(buf, buf_len, &prop->val, &prop_val)) {
+ ZVAL_UNDEF(&prop_val);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &prop->val, &prop_val)) {
+ zval_ptr_dtor(&prop_val);
+
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (!ucache_sgraph_decode_apply_prop(
+ dst,
+ prop_name,
+ use_sleep_slots ? prop->val.sleep_slot_plus_one : i + 1,
+ &prop_val
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_call_wakeup(zval *dst, bool has_wakeup)
+{
+ UCACHE_ASSERT_RESOLVED_WAKEUP(dst, has_wakeup);
+
+ if (has_wakeup && !ucache_defer_restore(dst, NULL)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return true;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_call_unserialize(zval *dst, zval *state)
+{
+ bool result = ucache_defer_restore(dst, state);
+
+ zval_ptr_dtor(state);
+
+ if (!result) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_decode_obj_state(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint32_t props_offset,
+ uint32_t prop_count,
+ zval *state)
+{
+ const ucache_sgraph_prop *props, *prop;
+ zend_string *prop_name;
+ zval prop_val;
+ uint32_t i;
+
+ if (prop_count == 0) {
+ array_init(state);
+
+ return true;
+ }
+
+ if (!ucache_decode_arr_range_ok(buf_len, props_offset, prop_count, sizeof(*props))) {
+ ZVAL_EMPTY_ARRAY(state);
+
+ return false;
+ }
+
+ array_init_size(state, prop_count);
+
+ props = (const ucache_sgraph_prop *) (buf + props_offset);
+ for (i = 0; i < prop_count; i++) {
+ prop = &props[i];
+ if (prop->val.type == UCACHE_SGRAPH_VAL_UNDEF) {
+ continue;
+ }
+
+ prop_name = ucache_decode_str_at(buf, buf_len, prop->name_offset);
+ if (prop_name == NULL) {
+ return false;
+ }
+
+ ZVAL_UNDEF(&prop_val);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &prop->val, &prop_val)) {
+ zval_ptr_dtor(&prop_val);
+
+ return false;
+ }
+
+ zend_symtable_update(Z_ARRVAL_P(state), prop_name, &prop_val);
+ }
+
+ return true;
+}
+
+static zend_never_inline bool ucache_sgraph_decode_obj_with_unserialize(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ const ucache_sgraph_obj *gobj,
+ zend_class_entry *ce,
+ zval *dst)
+{
+ zval state;
+
+ if (!ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gobj->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_sgraph_decode_obj_state(
+ buf,
+ buf_len,
+ gobj->props_offset,
+ gobj->prop_count,
+ &state
+ )
+ ) {
+ zval_ptr_dtor(&state);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return ucache_sgraph_decode_call_unserialize(dst, &state);
+}
+
+static bool ucache_sgraph_state_has_numeric_str_key(const HashTable *state)
+{
+ zend_string *key;
+ zend_ulong idx;
+
+ ZEND_HASH_FOREACH_STR_KEY(state, key) {
+ if (key != NULL && ZEND_HANDLE_NUMERIC(key, idx)) {
+ return true;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return false;
+}
+
+static void ucache_sgraph_state_to_symtable(zval *state)
+{
+ HashTable *symtable;
+ zend_string *key;
+ zend_ulong idx;
+ zval *val;
+
+ if (!ucache_sgraph_state_has_numeric_str_key(Z_ARRVAL_P(state))) {
+ return;
+ }
+
+ symtable = zend_new_array(zend_hash_num_elements(Z_ARRVAL_P(state)));
+
+ ZEND_HASH_FOREACH_KEY_VAL(Z_ARRVAL_P(state), idx, key, val) {
+ Z_TRY_ADDREF_P(val);
+
+ if (key != NULL) {
+ zend_symtable_update(symtable, key, val);
+ } else {
+ zend_hash_index_update(symtable, idx, val);
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ zval_ptr_dtor(state);
+
+ ZVAL_ARR(state, symtable);
+}
+
+static zend_never_inline bool ucache_sgraph_decode_sleep_shaped_obj_with_unserialize(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ const ucache_sgraph_shaped_state_obj *gsstate,
+ const ucache_sgraph_state_schema *state_schema,
+ zend_class_entry *ce,
+ zval *dst)
+{
+ zval state;
+
+ if (!ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gsstate->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ ZVAL_UNDEF(&state);
+
+ if (!ucache_sgraph_decode_shaped_arr(
+ buf,
+ buf_len,
+ state_schema->shape_offset,
+ gsstate->state_vals_offset,
+ state_schema->count,
+ gsstate->state_next_free,
+ &state
+ ) ||
+ Z_TYPE(state) != IS_ARRAY
+ ) {
+ zval_ptr_dtor(&state);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ ucache_sgraph_state_to_symtable(&state);
+
+ return ucache_sgraph_decode_call_unserialize(dst, &state);
+}
+
+static zend_never_inline bool ucache_sgraph_decode_obj(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ bool sleep_obj,
+ zval *dst)
+{
+ const ucache_sgraph_obj *gobj;
+ zend_class_entry *ce;
+ uintptr_t resolved;
+
+ gobj = (const ucache_sgraph_obj *) (buf + gval->offset);
+ resolved = ucache_decode_lookup_class(buf, buf_len, gobj->class_name_offset);
+ ce = ucache_decode_resolved_class_entry(resolved);
+ if (ce == NULL) {
+ return false;
+ }
+
+ if (UNEXPECTED(ce->__unserialize != NULL)) {
+ return ucache_sgraph_decode_obj_with_unserialize(buf, buf_len, gval, gobj, ce, dst);
+ }
+
+ if (!ucache_sgraph_can_restore_props(ce, sleep_obj)) {
+ return false;
+ }
+
+ if (!ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gobj->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_sgraph_decode_obj_props(
+ buf,
+ buf_len,
+ gobj->props_offset,
+ gobj->prop_count,
+ sleep_obj,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ return ucache_sgraph_decode_call_wakeup(dst, ucache_decode_resolved_class_has_wakeup(resolved));
+}
+
+static zend_never_inline bool ucache_sgraph_decode_sleep_shaped_obj(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_shaped_state_obj *gsstate;
+ const ucache_sgraph_state_schema *state_schema;
+ const ucache_sgraph_arr_shape *gshape;
+ const ucache_sgraph_arr_shape_elem *shape_elems;
+ const ucache_sgraph_val *gvals;
+ zend_string *prop_name;
+ zend_class_entry *ce;
+ zval prop_val;
+ uint32_t i;
+ uintptr_t resolved;
+
+ gsstate = (const ucache_sgraph_shaped_state_obj *) (buf + gval->offset);
+ if (!ucache_decode_range_ok(buf_len, gsstate->state_schema_offset, sizeof(*state_schema))) {
+ return false;
+ }
+
+ state_schema = (const ucache_sgraph_state_schema *) (buf + gsstate->state_schema_offset);
+ resolved = ucache_decode_lookup_state_schema_class(buf, buf_len, state_schema);
+ ce = ucache_decode_resolved_class_entry(resolved);
+ if (ce == NULL) {
+ return false;
+ }
+
+ if (UNEXPECTED(ce->__unserialize != NULL)) {
+ return ucache_sgraph_decode_sleep_shaped_obj_with_unserialize(
+ buf,
+ buf_len,
+ gval,
+ gsstate,
+ state_schema,
+ ce,
+ dst
+ );
+ }
+
+ if (!ucache_sgraph_can_restore_props(ce, true)) {
+ return false;
+ }
+
+ if (!ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gsstate->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_decode_range_ok(buf_len, state_schema->shape_offset, sizeof(*gshape))) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ gshape = (const ucache_sgraph_arr_shape *) (buf + state_schema->shape_offset);
+ if (gshape->count != state_schema->count) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (!ucache_decode_arr_range_ok(buf_len, gshape->elems_offset, state_schema->count, sizeof(*shape_elems)) ||
+ !ucache_decode_arr_range_ok(buf_len, gsstate->state_vals_offset, state_schema->count, sizeof(*gvals))
+ ) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ shape_elems = (const ucache_sgraph_arr_shape_elem *) (buf + gshape->elems_offset);
+ gvals = (const ucache_sgraph_val *) (buf + gsstate->state_vals_offset);
+
+ for (i = 0; i < state_schema->count; i++) {
+ prop_name = ucache_decode_str_at(buf, buf_len, shape_elems[i].key_offset);
+ if (prop_name == NULL) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (!ucache_sgraph_decode_simple_val(
+ buf,
+ buf_len,
+ &gvals[i],
+ &prop_val
+ )
+ ) {
+ ZVAL_UNDEF(&prop_val);
+
+ if (!ucache_sgraph_decode_val(
+ buf,
+ buf_len,
+ &gvals[i],
+ &prop_val
+ )
+ ) {
+ zval_ptr_dtor(&prop_val);
+
+ return ucache_decode_fail_zval(dst);
+ }
+ }
+
+ if (!ucache_sgraph_decode_apply_prop(
+ dst,
+ prop_name,
+ gvals[i].sleep_slot_plus_one,
+ &prop_val
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return ucache_sgraph_decode_call_wakeup(dst, ucache_decode_resolved_class_has_wakeup(resolved));
+}
+
+static zend_never_inline bool ucache_sgraph_decode_safe_direct_obj(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_safe_direct_obj *gsd;
+ const php_ucache_safe_direct_handlers *handlers;
+ zend_class_entry *ce, *base_ce = NULL;
+ zval sd_state;
+ bool restored;
+
+ gsd = (const ucache_sgraph_safe_direct_obj *) (buf + gval->offset);
+
+ ce = ucache_decode_resolved_class_entry(ucache_decode_lookup_class(buf, buf_len, gsd->class_name_offset));
+ if (ce == NULL) {
+ return false;
+ }
+
+ handlers = ucache_safe_direct_find_handlers(ce, &base_ce);
+
+ if (handlers == NULL ||
+ ucache_class_overrides_safe_direct_magic_serialize(ce, handlers, base_ce) ||
+ !ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gsd->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ ZVAL_UNDEF(&sd_state);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &gsd->state, &sd_state) ||
+ Z_TYPE(sd_state) != IS_ARRAY
+ ) {
+ zval_ptr_dtor(&sd_state);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ BG(serialize_lock)++;
+ restored = handlers->state_unserialize(dst, &sd_state);
+ BG(serialize_lock)--;
+
+ zval_ptr_dtor(&sd_state);
+
+ if (!restored || !ucache_owned_decode_validate_proc()) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return ucache_sgraph_decode_obj_props(
+ buf,
+ buf_len,
+ gsd->props_offset,
+ gsd->prop_count,
+ false,
+ dst
+ );
+}
+
+static zend_never_inline bool ucache_sgraph_decode_serialized_obj(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_serialized_obj *gser;
+ zend_class_entry *ce;
+ zval state;
+
+ gser = (const ucache_sgraph_serialized_obj *) (buf + gval->offset);
+
+ ce = ucache_decode_resolved_class_entry(ucache_decode_lookup_class(buf, buf_len, gser->class_name_offset));
+ if (ce == NULL || ce->__unserialize == NULL || (ce->ce_flags & ZEND_ACC_NOT_SERIALIZABLE)) {
+ return false;
+ }
+
+ if (!ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gser->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ ZVAL_UNDEF(&state);
+
+ if (!ucache_sgraph_decode_val(buf, buf_len, &gser->state, &state) ||
+ Z_TYPE(state) != IS_ARRAY
+ ) {
+ zval_ptr_dtor(&state);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return ucache_sgraph_decode_call_unserialize(dst, &state);
+}
+
+static zend_never_inline bool ucache_sgraph_decode_serialized_shaped_obj(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_shaped_state_obj *gsstate;
+ const ucache_sgraph_state_schema *state_schema;
+ zend_class_entry *ce;
+ zval state;
+
+ gsstate = (const ucache_sgraph_shaped_state_obj *) (buf + gval->offset);
+ if (!ucache_decode_range_ok(buf_len, gsstate->state_schema_offset, sizeof(*state_schema))) {
+ return false;
+ }
+
+ state_schema = (const ucache_sgraph_state_schema *) (buf + gsstate->state_schema_offset);
+ ce = ucache_decode_resolved_class_entry(ucache_decode_lookup_state_schema_class(buf, buf_len, state_schema));
+ if (ce == NULL || ce->__unserialize == NULL || (ce->ce_flags & ZEND_ACC_NOT_SERIALIZABLE)) {
+ return false;
+ }
+
+ if (!ucache_sgraph_decode_init_obj(
+ ce,
+ gval->offset,
+ (gsstate->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) != 0,
+ dst
+ )
+ ) {
+ return false;
+ }
+
+ ZVAL_UNDEF(&state);
+
+ if (!ucache_sgraph_decode_shaped_arr(
+ buf,
+ buf_len,
+ state_schema->shape_offset,
+ gsstate->state_vals_offset,
+ state_schema->count,
+ gsstate->state_next_free,
+ &state
+ ) ||
+ Z_TYPE(state) != IS_ARRAY
+ ) {
+ zval_ptr_dtor(&state);
+
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return ucache_sgraph_decode_call_unserialize(dst, &state);
+}
+
+static zend_never_inline bool ucache_sgraph_decode_serdes_obj(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_serdes_obj *gserdes;
+
+ gserdes =
+ (const ucache_sgraph_serdes_obj *) (buf + gval->offset)
+ ;
+
+ if (!ucache_decode_range_ok(
+ buf_len,
+ gval->offset + (uint32_t) sizeof(*gserdes),
+ gserdes->blob_len
+ )
+ ) {
+ return false;
+ }
+
+ UC_G(restore_hook_calls)++;
+
+ if (!ucache_serdes_decode(
+ (const uint8_t *) (gserdes + 1),
+ gserdes->blob_len,
+ dst
+ ) ||
+ Z_TYPE_P(dst) != IS_OBJECT
+ ) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if ((gserdes->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) &&
+ !ucache_decode_identity_map_insert(
+ gval->offset,
+ Z_OBJ_P(dst)
+ )
+ ) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return true;
+}
+
+static zend_never_inline bool ucache_sgraph_decode_enum(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_enum *genum;
+ zend_string *case_name;
+ zend_class_entry *ce;
+ zend_object *case_obj;
+
+ genum = (const ucache_sgraph_enum *) (buf + gval->offset);
+ case_obj = ucache_decode_resolved_enum_case_obj(
+ ucache_decode_resolve_cache_find(genum, UCACHE_DECODE_RESOLVE_KIND_ENUM_CASE)
+ );
+ if (case_obj == NULL) {
+ ce = ucache_decode_resolved_class_entry(ucache_decode_lookup_class(buf, buf_len, genum->class_name_offset));
+ if (ce == NULL || !(ce->ce_flags & ZEND_ACC_ENUM)) {
+ return false;
+ }
+
+ case_name = ucache_decode_str_at(buf, buf_len, genum->case_name_offset);
+ if (case_name == NULL) {
+ return false;
+ }
+
+ case_obj = ucache_enum_case_find(ce, case_name);
+ if (case_obj == NULL || !ucache_owned_decode_validate_proc()) {
+ return false;
+ }
+
+ ucache_decode_resolve_cache_store(genum, ucache_decode_resolved_enum_case(case_obj));
+ }
+
+ ZVAL_OBJ(dst, case_obj);
+
+ GC_ADDREF(case_obj);
+
+ return true;
+}
+
+static zend_never_inline bool ucache_sgraph_decode_ref(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_ref *gref;
+ zend_reference *ref;
+
+ gref = (const ucache_sgraph_ref *) (buf + gval->offset);
+
+ ZVAL_NEW_EMPTY_REF(dst);
+ ref = Z_REF_P(dst);
+
+ ZVAL_UNDEF(&ref->val);
+
+ if ((gref->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED) &&
+ !ucache_decode_ref_map_insert(
+ gval->offset,
+ ref
+ )
+ ) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ if (!ucache_sgraph_decode_val(
+ buf,
+ buf_len,
+ &gref->inner,
+ &ref->val
+ )
+ ) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ return true;
+}
+
+static bool ucache_verbatim_hash_chains_check(
+ const zend_array *arr,
+ const uint32_t *hash,
+ const Bucket *buckets)
+{
+ uint32_t slot_count = (uint32_t) -arr->nTableMask;
+ uint32_t slot, idx, bucket_idx, visited = 0;
+
+ for (slot = 0; slot < slot_count; slot++) {
+ idx = hash[slot];
+
+ while (idx != HT_INVALID_IDX) {
+ bucket_idx = HT_HASH_TO_IDX(idx);
+ if (bucket_idx >= arr->nNumUsed ||
+ HT_IDX_TO_HASH(bucket_idx) != idx ||
+ visited == arr->nNumOfElements ||
+ Z_TYPE_INFO(buckets[bucket_idx].val) == IS_UNDEF ||
+ ucache_verbatim_hash_slot(arr, buckets[bucket_idx].h) != slot
+ ) {
+ return false;
+ }
+
+ visited++;
+ idx = Z_NEXT(buckets[bucket_idx].val);
+ }
+ }
+
+ return visited == arr->nNumOfElements;
+}
+
+static const uint8_t *ucache_verbatim_arr_data(
+ const ucache_verbatim_check_ctx *ctx,
+ const zend_array *arr)
+{
+ uintptr_t data_addr;
+ size_t hash_size, used_size;
+ bool is_packed;
+
+ if (GC_TYPE_INFO(arr) != UCACHE_VERBATIM_ARR_GC_TYPE_INFO ||
+ GC_REFCOUNT(arr) < 2 ||
+ (HT_FLAGS(arr) & ~UCACHE_VERBATIM_ARR_HT_FLAGS_ALLOWED) != 0 ||
+ !(HT_FLAGS(arr) & HASH_FLAG_STATIC_KEYS) ||
+ arr->nTableSize < HT_MIN_SIZE ||
+ arr->nTableSize > HT_MAX_SIZE ||
+ (arr->nTableSize & (arr->nTableSize - 1)) != 0 ||
+ arr->nNumUsed > arr->nTableSize ||
+ arr->nNumOfElements > arr->nNumUsed
+ ) {
+ return NULL;
+ }
+
+ is_packed = HT_IS_PACKED(arr);
+ if (arr->nTableMask != (is_packed ? HT_MIN_MASK : HT_SIZE_TO_MASK(arr->nTableSize))) {
+ return NULL;
+ }
+
+ hash_size = HT_HASH_SIZE(arr->nTableMask);
+ used_size = (size_t) arr->nNumUsed * (is_packed ? sizeof(zval) : sizeof(Bucket));
+ data_addr = (uintptr_t) arr->arData;
+ if (data_addr < hash_size) {
+ return NULL;
+ }
+
+ return ucache_verbatim_resolve_ptr(
+ ctx,
+ (const void *) (data_addr - hash_size),
+ hash_size + used_size,
+ alignof(Bucket)
+ );
+}
+
+static bool ucache_verbatim_arr_check(ucache_verbatim_check_ctx *ctx, const zend_array *arr)
+{
+ const uint8_t *data;
+ const uint32_t *hash;
+ const Bucket *buckets;
+ const zval *packed, *seen;
+ size_t hash_size;
+ uint32_t i, live = 0;
+
+ if (ucache_stack_overflowed() || ucache_decode_nesting_overflowed(ctx->depth)) {
+ return false;
+ }
+
+ if (!ucache_seen_test_and_add(&ctx->seen, arr)) {
+ seen = zend_hash_index_find(&ctx->seen, (zend_ulong) (uintptr_t) arr);
+
+ return seen != NULL && Z_TYPE_P(seen) == IS_TRUE;
+ }
+
+ data = ucache_verbatim_arr_data(ctx, arr);
+ if (data == NULL) {
+ return false;
+ }
+
+ hash = (const uint32_t *) data;
+ hash_size = HT_HASH_SIZE(arr->nTableMask);
+ ctx->depth++;
+
+ if (HT_IS_PACKED(arr)) {
+ if (hash[0] != HT_INVALID_IDX || hash[1] != HT_INVALID_IDX) {
+ return false;
+ }
+
+ packed = (const zval *) (data + hash_size);
+ for (i = 0; i < arr->nNumUsed; i++) {
+ if (!ucache_verbatim_elem_check(ctx, &packed[i])) {
+ return false;
+ }
+
+ if (Z_TYPE_INFO(packed[i]) != IS_UNDEF) {
+ live++;
+ }
+ }
+ } else {
+ buckets = (const Bucket *) (data + hash_size);
+ for (i = 0; i < arr->nNumUsed; i++) {
+ if ((buckets[i].key != NULL && !ucache_verbatim_str_check(ctx, buckets[i].key)) ||
+ !ucache_verbatim_elem_check(ctx, &buckets[i].val)
+ ) {
+ return false;
+ }
+
+ if (Z_TYPE_INFO(buckets[i].val) != IS_UNDEF) {
+ live++;
+ }
+ }
+
+ if (!ucache_verbatim_hash_chains_check(arr, hash, buckets)) {
+ return false;
+ }
+ }
+
+ if (live != arr->nNumOfElements) {
+ return false;
+ }
+
+ ctx->depth--;
+
+ ZVAL_TRUE(zend_hash_index_lookup(&ctx->seen, (zend_ulong) (uintptr_t) arr));
+
+ return true;
+}
+
+static zend_never_inline bool ucache_decode_verbatim_arr_validate(
+ const uint8_t *buf,
+ size_t buf_len,
+ const uint8_t *snapshot_origin,
+ const zend_array *arr,
+ const void *shm_arr)
+{
+ ucache_verbatim_check_ctx ctx;
+ bool result;
+
+ if (UCACHE_DEBUG_FAULT("VERBATIM_ARR_INVALID")) {
+ return false;
+ }
+
+ ctx.buf = buf;
+ ctx.snapshot_origin = snapshot_origin;
+ ctx.buf_len = buf_len;
+ ctx.depth = UC_G(decode_nesting);
+
+ zend_hash_init(&ctx.seen, 8, NULL, NULL, 0);
+
+ result = ucache_verbatim_arr_check(&ctx, arr);
+
+ zend_hash_destroy(&ctx.seen);
+
+ if (result) {
+ ucache_verbatim_verdict_store(shm_arr);
+ }
+
+ return result;
+}
+
+static void *ucache_owned_decode_relocate(const void *ptr, size_t size)
+{
+ const ucache_sgraph_snapshot *snapshot = UC_G(owned_decode_frame)->snapshot;
+ uintptr_t addr = (uintptr_t) ptr, base;
+
+ if (snapshot == NULL) {
+ return (void *) ptr;
+ }
+
+ base = (uintptr_t) snapshot->origin;
+ if (addr >= base && addr - base <= snapshot->len && size <= snapshot->len - (addr - base)) {
+ return (void *) (snapshot->data + (addr - base));
+ }
+
+ base = (uintptr_t) snapshot->data;
+ if (addr >= base && addr - base <= snapshot->len && size <= snapshot->len - (addr - base)) {
+ return (void *) ptr;
+ }
+
+ return NULL;
+}
+
+static zend_string *ucache_owned_decode_relocate_str(const zend_string *str)
+{
+ str = ucache_owned_decode_relocate(str, _ZSTR_HEADER_SIZE);
+ if (str == NULL ||
+ ZSTR_LEN(str) > SIZE_MAX / 2 ||
+ ucache_owned_decode_relocate(str, _ZSTR_STRUCT_SIZE(ZSTR_LEN(str))) == NULL
+ ) {
+ return NULL;
+ }
+
+ return (zend_string *) str;
+}
+
+static zend_array *ucache_owned_decode_relocate_arr(zend_array *arr)
+{
+ void *data;
+
+ if (UC_G(owned_decode_frame)->snapshot == NULL) {
+ return arr;
+ }
+
+ arr = ucache_owned_decode_relocate(arr, sizeof(zend_array));
+ if (arr == NULL) {
+ return NULL;
+ }
+
+ data = ucache_owned_decode_relocate(
+ HT_GET_DATA_ADDR(arr),
+ HT_IS_PACKED(arr) ? HT_PACKED_USED_SIZE(arr) : HT_USED_SIZE(arr)
+ );
+ if (data == NULL) {
+ return NULL;
+ }
+
+ HT_SET_DATA_ADDR(arr, data);
+
+ return arr;
+}
+
+static void ucache_owned_verbatim_arr_drop_borrowed(zend_array *arr)
+{
+ zval *elem;
+ Bucket *bucket;
+ uint32_t i;
+
+ if (HT_IS_PACKED(arr)) {
+ for (i = 0; i < arr->nNumUsed; i++) {
+ elem = &arr->arPacked[i];
+ if (ucache_owned_verbatim_val_is_borrowed(elem)) {
+ ZVAL_NULL(elem);
+ }
+ }
+ } else {
+ HT_FLAGS(arr) &= ~HASH_FLAG_STATIC_KEYS;
+
+ for (i = 0; i < arr->nNumUsed; i++) {
+ bucket = &arr->arData[i];
+ bucket->key = NULL;
+ if (ucache_owned_verbatim_val_is_borrowed(&bucket->val)) {
+ ZVAL_NULL(&bucket->val);
+ }
+ }
+ }
+}
+
+static bool ucache_owned_decode_verbatim(zval *dst, const zval *src)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+ zend_array *arr, *src_arr;
+ zend_string *str;
+ zend_ulong key;
+ zval owned;
+ Bucket *bucket;
+ uint32_t i;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ if (Z_TYPE_P(src) == IS_STRING) {
+ str = ucache_owned_decode_relocate_str(Z_STR_P(src));
+ if (str == NULL) {
+ return false;
+ }
+
+ ZVAL_STR_COPY(dst, ucache_owned_decode_str(str));
+
+ return true;
+ }
+
+ if (Z_TYPE_P(src) != IS_ARRAY) {
+ ZVAL_COPY_VALUE(dst, src);
+
+ return true;
+ }
+
+ if (Z_ARR_P(src) == &zend_empty_array) {
+ ZVAL_EMPTY_ARRAY(dst);
+
+ return true;
+ }
+
+ src_arr = ucache_owned_decode_relocate_arr(Z_ARR_P(src));
+ if (src_arr == NULL) {
+ return false;
+ }
+
+ key = (zend_ulong) (uintptr_t) src_arr;
+ if (frame->arrs != NULL) {
+ arr = zend_hash_index_find_ptr(frame->arrs, key);
+ if (arr != NULL) {
+ GC_ADDREF(arr);
+
+ ZVAL_ARR(dst, arr);
+
+ return true;
+ }
+ } else {
+ frame->arrs = emalloc(sizeof(HashTable));
+
+ zend_hash_init(frame->arrs, 8, NULL, ucache_owned_acyclic_verbatim_arr_dtor, 0);
+ }
+
+ arr = zend_array_dup(src_arr);
+
+ ucache_owned_verbatim_arr_drop_borrowed(arr);
+
+ GC_ADDREF(arr);
+
+ zend_hash_index_add_new_ptr(frame->arrs, key, arr);
+
+ ZVAL_ARR(dst, arr);
+
+ if (HT_IS_PACKED(arr)) {
+ for (i = 0; i < arr->nNumUsed; i++) {
+ if (!ucache_owned_verbatim_val_is_borrowed(&src_arr->arPacked[i])) {
+ continue;
+ }
+
+ if (!ucache_owned_decode_verbatim(&owned, &src_arr->arPacked[i])) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ ZVAL_COPY_VALUE(&arr->arPacked[i], &owned);
+ }
+ } else {
+ for (i = 0; i < arr->nNumUsed; i++) {
+ bucket = &arr->arData[i];
+ if (Z_TYPE(bucket->val) == IS_UNDEF) {
+ continue;
+ }
+
+ if (src_arr->arData[i].key != NULL) {
+ str = ucache_owned_decode_relocate_str(src_arr->arData[i].key);
+
+ if (str == NULL) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ bucket->key = zend_string_copy(ucache_owned_decode_str(str));
+ }
+
+ if (!ucache_owned_verbatim_val_is_borrowed(&src_arr->arData[i].val)) {
+ continue;
+ }
+
+ if (!ucache_owned_decode_verbatim(&owned, &src_arr->arData[i].val)) {
+ return ucache_decode_fail_zval(dst);
+ }
+
+ ZVAL_COPY_VALUE(&bucket->val, &owned);
+ }
+ }
+
+ return true;
+}
+
+static zend_always_inline bool ucache_sgraph_decode_node(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ const ucache_sgraph_shaped_arr *gsarr;
+ zend_reference *shared_ref;
+ zend_object *shared_obj;
+ zend_array *arr;
+ zval src;
+ size_t node_hdr_size;
+
+ node_hdr_size = ucache_decode_node_hdr_size(gval->type);
+ if (node_hdr_size != 0 &&
+ !ucache_decode_range_ok(buf_len, gval->offset, node_hdr_size)
+ ) {
+ return false;
+ }
+
+ switch (gval->type) {
+ case UCACHE_SGRAPH_VAL_UNDEF:
+ return false;
+ case UCACHE_SGRAPH_VAL_NULL:
+ case UCACHE_SGRAPH_VAL_TRUE:
+ case UCACHE_SGRAPH_VAL_FALSE:
+ case UCACHE_SGRAPH_VAL_LONG:
+ case UCACHE_SGRAPH_VAL_LONG_WIDE:
+ case UCACHE_SGRAPH_VAL_DOUBLE:
+ case UCACHE_SGRAPH_VAL_STR:
+ return ucache_sgraph_decode_simple_val(buf, buf_len, gval, dst);
+ case UCACHE_SGRAPH_VAL_ARR:
+ if (gval->offset == 0) {
+ ZVAL_EMPTY_ARRAY(dst);
+ } else {
+ if (!ucache_decode_range_ok(
+ buf_len,
+ gval->offset,
+ sizeof(zend_array)
+ )
+ ) {
+ return false;
+ }
+
+ arr = (zend_array *) (void *) (buf + gval->offset);
+ if (!ucache_decode_verbatim_arr_ok(buf, buf_len, ucache_decode_snapshot_origin(), arr)) {
+ return false;
+ }
+
+ ZVAL_ARR(dst, arr);
+ Z_TYPE_FLAGS_P(dst) = 0;
+ if (UNEXPECTED(UC_G(owned_decode_frame) != NULL)) {
+ ZVAL_COPY_VALUE(&src, dst);
+ ZVAL_UNDEF(dst);
+
+ return ucache_owned_decode_verbatim(dst, &src);
+ }
+ }
+
+ return true;
+ case UCACHE_SGRAPH_VAL_DYNAMIC_ARR:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_dynamic_arr(buf, buf_len, gval, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_SHAPED_ARR:
+ gsarr = (const ucache_sgraph_shaped_arr *) (buf + gval->offset);
+
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_shaped_arr(
+ buf,
+ buf_len,
+ gsarr->shape_offset,
+ ucache_sgraph_shaped_arr_vals_offset(gval->offset),
+ gsarr->count,
+ gsarr->next_free,
+ dst
+ ))
+ ;
+ case UCACHE_SGRAPH_VAL_OBJ:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_obj(buf, buf_len, gval, false, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_SLEEP_OBJ:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_obj(buf, buf_len, gval, true, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_sleep_shaped_obj(buf, buf_len, gval, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_OBJ_REF: {
+ shared_obj = ucache_decode_identity_map_find(gval->offset);
+ if (shared_obj == NULL) {
+ return false;
+ }
+
+ ZVAL_OBJ(dst, shared_obj);
+
+ GC_ADDREF(shared_obj);
+
+ return true;
+ }
+ case UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_safe_direct_obj(buf, buf_len, gval, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_SERIALIZED_OBJ:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_serialized_obj(buf, buf_len, gval, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_serialized_shaped_obj(buf, buf_len, gval, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_SERDES_OBJ:
+ return ucache_sgraph_decode_serdes_obj(buf, buf_len, gval, dst);
+ case UCACHE_SGRAPH_VAL_ENUM:
+ return ucache_sgraph_decode_enum(buf, buf_len, gval, dst);
+ case UCACHE_SGRAPH_VAL_REF:
+ return ucache_decode_node_enter() &&
+ ucache_decode_node_leave(ucache_sgraph_decode_ref(buf, buf_len, gval, dst))
+ ;
+ case UCACHE_SGRAPH_VAL_REF_REF: {
+ shared_ref = ucache_decode_ref_map_find(gval->offset);
+
+ if (shared_ref == NULL) {
+ return false;
+ }
+
+ ZVAL_REF(dst, shared_ref);
+
+ GC_ADDREF(shared_ref);
+
+ return true;
+ }
+ default:
+ return false;
+ }
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_val(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *gval,
+ zval *dst)
+{
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ return ucache_sgraph_decode_node(buf, buf_len, gval, dst);
+}
+
+static bool ucache_sgraph_load_root_val(
+ const ucache_sgraph_hdr *hdr,
+ size_t buf_len,
+ ucache_sgraph_val *root_val)
+{
+ uint32_t root_offset = (hdr->flags & UCACHE_SGRAPH_FLAG_EMPTY_ROOT)
+ ? 0
+ : UCACHE_SGRAPH_ROOT_OFFSET(hdr->pin_word_count)
+ ;
+
+ if (root_offset != 0 && root_offset >= buf_len) {
+ return false;
+ }
+
+ memset(root_val, 0, sizeof(*root_val));
+
+ root_val->type = hdr->root_type;
+ root_val->offset = root_offset;
+
+ switch (root_val->type) {
+ case UCACHE_SGRAPH_VAL_ARR:
+ case UCACHE_SGRAPH_VAL_DYNAMIC_ARR:
+ case UCACHE_SGRAPH_VAL_SHAPED_ARR:
+ case UCACHE_SGRAPH_VAL_ENUM:
+ case UCACHE_SGRAPH_VAL_STR:
+ return true;
+ case UCACHE_SGRAPH_VAL_OBJ:
+ case UCACHE_SGRAPH_VAL_SLEEP_OBJ:
+ case UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ:
+ case UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ:
+ case UCACHE_SGRAPH_VAL_SERIALIZED_OBJ:
+ case UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ:
+ case UCACHE_SGRAPH_VAL_SERDES_OBJ:
+ return root_offset != 0;
+ default:
+ return false;
+ }
+}
+
+#if ZEND_DEBUG
+static void *ucache_sgraph_rebase_ptr(
+ void *ptr,
+ const uint8_t *old_base,
+ const uint8_t *new_base,
+ size_t len)
+{
+ size_t offset;
+
+ if (!ucache_sgraph_ptr_in_range(ptr, old_base, len)) {
+ return ptr;
+ }
+
+ offset = (uintptr_t) ptr - (uintptr_t) old_base;
+
+ return (void *) (new_base + offset);
+}
+
+static zend_array *ucache_sgraph_rebase_verbatim_zval(
+ ucache_sgraph_rebase_ctx *ctx,
+ zval *val)
+{
+ switch (Z_TYPE_P(val)) {
+ case IS_STRING:
+ Z_STR_P(val) = (zend_string *) ucache_sgraph_rebase_ptr(
+ Z_STR_P(val),
+ ctx->old_base,
+ ctx->new_base,
+ ctx->len
+ );
+
+ return NULL;
+ case IS_ARRAY:
+ Z_ARR_P(val) = (zend_array *) ucache_sgraph_rebase_ptr(
+ Z_ARR_P(val),
+ ctx->old_base,
+ ctx->new_base,
+ ctx->len
+ );
+
+ return Z_ARR_P(val);
+ default:
+ return NULL;
+ }
+}
+
+static bool ucache_sgraph_rebase_verbatim_arr(
+ ucache_sgraph_rebase_ctx *ctx,
+ zend_array *arr)
+{
+ zend_array *elem_arr;
+ zval *packed;
+ Bucket *bucket;
+ uint32_t i;
+ void *data;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ if (!ucache_sgraph_ptr_in_range(
+ arr,
+ ctx->new_base,
+ ctx->len
+ )
+ ) {
+ return true;
+ }
+
+ if (!ucache_seen_test_and_add(ctx->seen, arr)) {
+ return true;
+ }
+
+ data = HT_GET_DATA_ADDR(arr);
+ data = ucache_sgraph_rebase_ptr(data, ctx->old_base, ctx->new_base, ctx->len);
+
+ HT_SET_DATA_ADDR(arr, data);
+
+ if (!ucache_sgraph_ptr_in_range(data, ctx->new_base, ctx->len)) {
+ return false;
+ }
+
+ if (HT_IS_PACKED(arr)) {
+ packed = arr->arPacked;
+ for (i = 0; i < arr->nNumUsed; i++) {
+ elem_arr = ucache_sgraph_rebase_verbatim_zval(ctx, &packed[i]);
+ if (elem_arr != NULL && !ucache_sgraph_rebase_verbatim_arr(ctx, elem_arr)) {
+ return false;
+ }
+ }
+ } else {
+ bucket = arr->arData;
+ for (i = 0; i < arr->nNumUsed; i++) {
+ if (bucket[i].key != NULL) {
+ bucket[i].key = (zend_string *) ucache_sgraph_rebase_ptr(
+ bucket[i].key,
+ ctx->old_base,
+ ctx->new_base,
+ ctx->len
+ );
+
+ if (!ucache_sgraph_ptr_in_range(bucket[i].key, ctx->new_base, ctx->len)) {
+ return false;
+ }
+ }
+
+ elem_arr = ucache_sgraph_rebase_verbatim_zval(ctx, &bucket[i].val);
+ if (elem_arr != NULL && !ucache_sgraph_rebase_verbatim_arr(ctx, elem_arr)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_rebase_graph_val(
+ ucache_sgraph_rebase_ctx *ctx,
+ const ucache_sgraph_val *gval)
+{
+ const ucache_sgraph_arr *garr;
+ const ucache_sgraph_arr_elem *gelems;
+ const ucache_sgraph_shaped_arr *gsarr;
+ const ucache_sgraph_val *gvals;
+ const ucache_sgraph_obj *gobj;
+ const ucache_sgraph_prop *props;
+ const ucache_sgraph_safe_direct_obj *gsd;
+ const ucache_sgraph_serialized_obj *gser;
+ const ucache_sgraph_shaped_state_obj *gsstate;
+ const ucache_sgraph_state_schema *state_schema;
+ zend_array *arr;
+ uint32_t i;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ switch (gval->type) {
+ case UCACHE_SGRAPH_VAL_ARR:
+ if (gval->offset == 0) {
+ return true;
+ }
+
+ arr = (zend_array *) (void *) (ctx->new_base + gval->offset);
+
+ return ucache_sgraph_rebase_verbatim_arr(ctx, arr);
+ case UCACHE_SGRAPH_VAL_DYNAMIC_ARR:
+ garr = (const ucache_sgraph_arr *) (ctx->new_base + gval->offset);
+ if (garr->flags & UCACHE_SGRAPH_ARR_FLAG_PACKED_VALS) {
+ gvals = (const ucache_sgraph_val *) (ctx->new_base + garr->elems_offset);
+ for (i = 0; i < garr->count; i++) {
+ if (!ucache_sgraph_rebase_graph_val(ctx, &gvals[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ gelems = (const ucache_sgraph_arr_elem *) (ctx->new_base + garr->elems_offset);
+
+ for (i = 0; i < garr->count; i++) {
+ if (!ucache_sgraph_rebase_graph_val(ctx, &gelems[i].val)) {
+ return false;
+ }
+ }
+
+ return true;
+ case UCACHE_SGRAPH_VAL_SHAPED_ARR:
+ gsarr = (const ucache_sgraph_shaped_arr *) (ctx->new_base + gval->offset);
+ gvals = (const ucache_sgraph_val *) (
+ ctx->new_base + ucache_sgraph_shaped_arr_vals_offset(gval->offset)
+ );
+
+ for (i = 0; i < gsarr->count; i++) {
+ if (!ucache_sgraph_rebase_graph_val(ctx, &gvals[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ case UCACHE_SGRAPH_VAL_OBJ:
+ case UCACHE_SGRAPH_VAL_SLEEP_OBJ:
+ gobj = (const ucache_sgraph_obj *) (ctx->new_base + gval->offset);
+ props = (const ucache_sgraph_prop *) (ctx->new_base + gobj->props_offset);
+
+ for (i = 0; i < gobj->prop_count; i++) {
+ if (!ucache_sgraph_rebase_graph_val(ctx, &props[i].val)) {
+ return false;
+ }
+ }
+
+ return true;
+ case UCACHE_SGRAPH_VAL_REF:
+ return ucache_sgraph_rebase_graph_val(
+ ctx,
+ &((const ucache_sgraph_ref *) (ctx->new_base + gval->offset))->inner
+ );
+ case UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ:
+ case UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ:
+ gsstate =
+ (const ucache_sgraph_shaped_state_obj *) (ctx->new_base + gval->offset)
+ ;
+ state_schema =
+ (const ucache_sgraph_state_schema *) (ctx->new_base + gsstate->state_schema_offset)
+ ;
+ gvals = (const ucache_sgraph_val *) (ctx->new_base + gsstate->state_vals_offset);
+
+ for (i = 0; i < state_schema->count; i++) {
+ if (!ucache_sgraph_rebase_graph_val(ctx, &gvals[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ case UCACHE_SGRAPH_VAL_SERIALIZED_OBJ:
+ gser =
+ (const ucache_sgraph_serialized_obj *) (ctx->new_base + gval->offset)
+ ;
+
+ return ucache_sgraph_rebase_graph_val(ctx, &gser->state);
+ case UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ: {
+ gsd =
+ (const ucache_sgraph_safe_direct_obj *) (ctx->new_base + gval->offset)
+ ;
+
+ if (!ucache_sgraph_rebase_graph_val(ctx, &gsd->state)) {
+ return false;
+ }
+
+ props = (const ucache_sgraph_prop *) (ctx->new_base + gsd->props_offset);
+ for (i = 0; i < gsd->prop_count; i++) {
+ if (!ucache_sgraph_rebase_graph_val(ctx, &props[i].val)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ default:
+ return true;
+ }
+}
+
+static bool ucache_sgraph_rebase_payload_ptrs(
+ const uint8_t *buf,
+ size_t glen,
+ const uint8_t *old_base)
+{
+ const ucache_sgraph_hdr *hdr;
+ ucache_sgraph_rebase_ctx ctx;
+ ucache_sgraph_val root_val;
+ HashTable seen_arrs;
+ bool result;
+
+ hdr = (const ucache_sgraph_hdr *) buf;
+
+ if (!ucache_sgraph_load_root_val(hdr, glen, &root_val)) {
+ return false;
+ }
+
+ zend_hash_init(&seen_arrs, 8, NULL, NULL, 0);
+
+ ctx.old_base = old_base;
+ ctx.new_base = buf;
+ ctx.len = glen;
+ ctx.seen = &seen_arrs;
+
+ result = ucache_sgraph_rebase_graph_val(&ctx, &root_val);
+
+ zend_hash_destroy(&seen_arrs);
+
+ return result;
+}
+#endif /* ZEND_DEBUG */
+
+static void ucache_decode_resolve_cache_release(void)
+{
+ memset((void *) UC_G(decode_resolve_direct_keys), 0, sizeof(UC_G(decode_resolve_direct_keys)));
+ memset(UC_G(decode_resolve_direct_vals), 0, sizeof(UC_G(decode_resolve_direct_vals)));
+
+ UC_G(decode_resolve_direct_next) = 0;
+
+ if (UC_G(decode_resolve_cache) == NULL) {
+ return;
+ }
+
+ zend_hash_destroy(UC_G(decode_resolve_cache));
+
+ efree(UC_G(decode_resolve_cache));
+
+ UC_G(decode_resolve_cache) = NULL;
+}
+
+static void ucache_decode_shape_proto_cache_release(void)
+{
+ memset((void *) UC_G(decode_shape_proto_direct_keys), 0, sizeof(UC_G(decode_shape_proto_direct_keys)));
+ memset(UC_G(decode_shape_proto_direct_vals), 0, sizeof(UC_G(decode_shape_proto_direct_vals)));
+
+ UC_G(decode_shape_proto_direct_next) = 0;
+
+ if (UC_G(decode_shape_proto_cache) == NULL) {
+ return;
+ }
+
+ zend_hash_destroy(UC_G(decode_shape_proto_cache));
+
+ efree(UC_G(decode_shape_proto_cache));
+
+ UC_G(decode_shape_proto_cache) = NULL;
+}
+
+static void ucache_owned_decode_frame_destroy(ucache_owned_decode_frame *frame)
+{
+ HashTable *tables[] = { frame->arrs, frame->shapes, frame->resolve, frame->strs };
+ uint32_t i;
+
+ for (i = 0; i < sizeof(tables) / sizeof(tables[0]); i++) {
+ if (tables[i] != NULL) {
+ zend_hash_destroy(tables[i]);
+ efree(tables[i]);
+ }
+ }
+
+ if (frame->snapshot != NULL) {
+ efree(frame->snapshot);
+ }
+
+ efree(frame);
+}
+
+static zend_never_inline void ucache_decode_save_maps(void)
+{
+ ucache_restore_queue *queue = ucache_restore_queue_push();
+
+ queue->saved_identity_map = UC_G(decode_identity_map);
+ queue->saved_ref_map = UC_G(decode_ref_map);
+
+ UC_G(decode_identity_map) = NULL;
+ UC_G(decode_ref_map) = NULL;
+}
+
+static ZEND_COLD zend_never_inline bool ucache_decode_leave_keeping_overflow(uint32_t depth)
+{
+ bool overflowed = UC_G(stack_overflowed);
+
+ ucache_decode_leave_impl(depth, false);
+
+ UC_G(stack_overflowed) = overflowed;
+
+ return false;
+}
+
+static bool ucache_decode_leave(uint32_t depth, bool result)
+{
+ if (UNEXPECTED(!result)) {
+ return ucache_decode_leave_keeping_overflow(depth);
+ }
+
+ return ucache_decode_leave_impl(depth, true);
+}
+
+static zend_never_inline bool ucache_sgraph_decode_owned(
+ const uint8_t *buf,
+ size_t buf_len,
+ ucache_sgraph_snapshot *snapshot,
+ zval *dst)
+{
+ const ucache_sgraph_hdr *hdr;
+ const uint8_t *gbuf;
+ ucache_sgraph_val root_val;
+ ucache_owned_decode_frame *owned_frame;
+ zend_string *str;
+ uint32_t depth;
+ bool result = false;
+
+ if (!ucache_owned_decode_validate_proc_impl()) {
+ goto done;
+ }
+
+ gbuf = ucache_sgraph_locate(
+ buf,
+ buf_len,
+ &buf_len
+ );
+ if (gbuf == NULL) {
+ goto done;
+ }
+
+ buf = gbuf;
+
+ hdr = (const ucache_sgraph_hdr *) buf;
+ if (!ucache_sgraph_load_root_val(hdr, buf_len, &root_val)) {
+ goto done;
+ }
+
+ if (root_val.type == UCACHE_SGRAPH_VAL_STR) {
+ str = ucache_decode_str_at_raw(buf, buf_len, root_val.offset);
+ if (str != NULL) {
+ ZVAL_NEW_STR(dst, ucache_owned_str_dup(str));
+ result = true;
+ }
+
+ goto done;
+ }
+
+ owned_frame = ecalloc(1, sizeof(*owned_frame));
+ owned_frame->prev = UC_G(owned_decode_frame);
+ owned_frame->owner_pid = ucache_cached_pid();
+ owned_frame->saved_identity_map = UC_G(decode_identity_map);
+ owned_frame->saved_ref_map = UC_G(decode_ref_map);
+ owned_frame->snapshot = snapshot;
+
+ UC_G(owned_decode_frame) = owned_frame;
+ UC_G(decode_identity_map) = NULL;
+ UC_G(decode_ref_map) = NULL;
+
+ depth = ucache_decode_enter();
+
+ result = ucache_sgraph_decode_val(buf, buf_len, &root_val, dst);
+ result = ucache_owned_decode_validate_proc_impl() && result;
+ result = ucache_decode_leave(depth, result);
+ result = ucache_owned_decode_validate_proc_impl() && result;
+
+ UC_G(decode_identity_map) = owned_frame->saved_identity_map;
+ UC_G(decode_ref_map) = owned_frame->saved_ref_map;
+ UC_G(owned_decode_frame) = owned_frame->prev;
+
+ ucache_owned_decode_frame_destroy(owned_frame);
+
+ return result;
+
+done:
+ if (snapshot != NULL) {
+ efree(snapshot);
+ }
+
+ return result;
+}
+
+static zend_never_inline bool ucache_sgraph_decode_outside_owned_frame(
+ const uint8_t *buf,
+ size_t buf_len,
+ const ucache_sgraph_val *root_val,
+ zval *dst)
+{
+ ucache_owned_decode_frame *owned_frame = UC_G(owned_decode_frame);
+ uint32_t depth;
+ bool result = false;
+
+ UC_G(owned_decode_frame) = NULL;
+
+ zend_try {
+ depth = ucache_decode_enter();
+
+ result = ucache_sgraph_decode_val(buf, buf_len, root_val, dst);
+ result = ucache_decode_leave(depth, result);
+ } zend_catch {
+ UC_G(owned_decode_frame) = owned_frame;
+
+ zend_bailout();
+ } zend_end_try();
+
+ UC_G(owned_decode_frame) = owned_frame;
+
+ return result;
+}
+
+zend_property_info *ucache_sgraph_declared_prop_info(
+ zend_class_entry *ce,
+ zend_string *name)
+{
+ const char *class_name, *prop_name;
+ zend_property_info *prop_info;
+ zend_class_entry *scope;
+ size_t prop_len, class_name_len;
+
+ if (ZSTR_LEN(name) == 0) {
+ return NULL;
+ }
+
+ if (ZSTR_VAL(name)[0] != '\0') {
+ prop_info = zend_hash_find_ptr(&ce->properties_info, name);
+ } else {
+ if (!ucache_sgraph_mangled_name_is_well_formed(name) ||
+ zend_unmangle_property_name_ex(name, &class_name, &prop_name, &prop_len) != SUCCESS ||
+ class_name == NULL
+ ) {
+ return NULL;
+ }
+
+ scope = ce;
+ if (class_name[0] != '*' || class_name[1] != '\0') {
+ class_name_len = (size_t) (prop_name - class_name - 1);
+ while (scope != NULL &&
+ zend_binary_strcasecmp(ZSTR_VAL(scope->name), ZSTR_LEN(scope->name), class_name, class_name_len) != 0
+ ) {
+ scope = scope->parent;
+ }
+
+ if (scope == NULL) {
+ return NULL;
+ }
+ }
+
+ prop_info = zend_hash_str_find_ptr(&scope->properties_info, prop_name, prop_len);
+ }
+
+ if (prop_info == NULL ||
+ (prop_info->flags & (ZEND_ACC_STATIC|ZEND_ACC_VIRTUAL)) != 0 ||
+ prop_info->offset == ZEND_VIRTUAL_PROPERTY_OFFSET ||
+ !zend_string_equals(prop_info->name, name)
+ ) {
+ return NULL;
+ }
+
+ return prop_info;
+}
+
+#if ZEND_DEBUG
+void ucache_sgraph_check_rebase_complete(
+ const uint8_t *dst_base,
+ size_t glen,
+ const uint8_t *src_base)
+{
+ uint8_t *check_buf;
+ bool result;
+
+ check_buf = emalloc(glen);
+ memcpy(check_buf, dst_base, glen);
+
+ result = ucache_sgraph_rebase_payload_ptrs(
+ dst_base,
+ glen,
+ src_base
+ );
+
+ ZEND_ASSERT(result);
+ ZEND_ASSERT(memcmp(check_buf, dst_base, glen) == 0);
+
+ efree(check_buf);
+}
+#endif /* ZEND_DEBUG */
+
+bool ucache_owned_decode_validate_proc_impl(void)
+{
+ ucache_owned_decode_frame *frame = UC_G(owned_decode_frame);
+
+ if (frame == NULL || !ucache_owned_decode_frame_reads_inherited_lease(frame)) {
+ return true;
+ }
+
+ UC_G(exec_poisoned) = true;
+ UC_G(in_req_shutdown) = true;
+ UC_G(runtime_resolved) = false;
+
+ if (!EG(exception)) {
+ zend_throw_error(NULL, "Cannot continue UserCache restoration after fork; restart the PHP execution in the child");
+ }
+
+ return false;
+}
+
+void ucache_decode_payload_addr_caches_release(void)
+{
+ ucache_decode_resolve_cache_release();
+ ucache_decode_shape_proto_cache_release();
+}
+
+void ucache_decode_maps_teardown(void)
+{
+ ucache_restore_queue *queue;
+ ucache_owned_decode_frame *frame;
+
+ while ((queue = UC_G(decode_restore_queue)) != NULL) {
+ (void) ucache_restore_queue_finish(queue, false);
+
+ UC_G(decode_restore_queue) = queue->prev;
+
+ ucache_decode_identity_map_teardown();
+ ucache_decode_ref_map_teardown();
+
+ UC_G(decode_identity_map) = queue->saved_identity_map;
+ UC_G(decode_ref_map) = queue->saved_ref_map;
+
+ ucache_restore_queue_destroy(queue);
+ }
+
+ while ((frame = UC_G(owned_decode_frame)) != NULL) {
+ ucache_decode_identity_map_teardown();
+ ucache_decode_ref_map_teardown();
+
+ UC_G(decode_identity_map) = frame->saved_identity_map;
+ UC_G(decode_ref_map) = frame->saved_ref_map;
+ UC_G(owned_decode_frame) = frame->prev;
+
+ ucache_owned_decode_frame_destroy(frame);
+ }
+
+ ucache_decode_identity_map_teardown();
+ ucache_decode_ref_map_teardown();
+
+ UC_G(decode_depth) = 0;
+ UC_G(decode_nesting) = 0;
+}
+
+static PHP_UCACHE_HOT bool ucache_sgraph_decode_impl(
+ const uint8_t *buf,
+ size_t buf_len,
+ zval *dst)
+{
+ const ucache_sgraph_hdr *hdr;
+ const uint8_t *gbuf;
+ ucache_sgraph_val root_val;
+ zend_string *str;
+ zend_array *arr;
+ uint32_t depth;
+ bool result;
+
+ if (UNEXPECTED(UC_G(persistent_exec))) {
+ return ucache_sgraph_decode_owned(buf, buf_len, NULL, dst);
+ }
+
+ gbuf = ucache_sgraph_locate(buf, buf_len, &buf_len);
+ if (gbuf == NULL) {
+ return false;
+ }
+
+ buf = gbuf;
+ hdr = (const ucache_sgraph_hdr *) buf;
+ if (!ucache_sgraph_load_root_val(hdr, buf_len, &root_val)) {
+ return false;
+ }
+
+ switch (root_val.type) {
+ case UCACHE_SGRAPH_VAL_STR:
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ str = ucache_decode_str_at_raw(buf, buf_len, root_val.offset);
+ if (str == NULL) {
+ return false;
+ }
+
+ ZVAL_INTERNED_STR(dst, str);
+
+ return true;
+ case UCACHE_SGRAPH_VAL_ARR:
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ if (root_val.offset == 0) {
+ ZVAL_EMPTY_ARRAY(dst);
+
+ return true;
+ }
+
+ if (!ucache_decode_range_ok(buf_len, root_val.offset, sizeof(zend_array))) {
+ return false;
+ }
+
+ arr = (zend_array *) (void *) (buf + root_val.offset);
+ if (!ucache_decode_verbatim_arr_ok(buf, buf_len, NULL, arr)) {
+ ucache_decode_payload_addr_caches_release();
+
+ return false;
+ }
+
+ ZVAL_ARR(dst, arr);
+
+ Z_TYPE_FLAGS_P(dst) = 0;
+
+ return true;
+ default:
+ break;
+ }
+
+ if (UNEXPECTED(UC_G(owned_decode_frame) != NULL)) {
+ result = ucache_sgraph_decode_outside_owned_frame(buf, buf_len, &root_val, dst);
+ } else {
+ depth = ucache_decode_enter();
+
+ result = ucache_sgraph_decode_val(buf, buf_len, &root_val, dst);
+ result = ucache_decode_leave(depth, result);
+ }
+
+ if (!result) {
+ ucache_decode_payload_addr_caches_release();
+ }
+
+ return result;
+}
+
+PHP_UCACHE_HOT bool ucache_sgraph_decode(
+ const uint8_t *buf,
+ size_t buf_len,
+ uint64_t gen,
+ zval *dst)
+{
+ uint64_t prev_gen = UC_G(decode_payload_gen);
+ bool prev_validated = UC_G(decode_payload_validated), validated, result;
+
+ validated = ucache_decode_payload_begin(buf, gen);
+ result = ucache_sgraph_decode_impl(buf, buf_len, dst);
+ ucache_decode_payload_end(buf, validated, result);
+
+ UC_G(decode_payload_validated) = prev_validated;
+ UC_G(decode_payload_gen) = prev_gen;
+
+ return result;
+}
+
+ucache_sgraph_snapshot *ucache_sgraph_snapshot_create(const uint8_t *buf, size_t buf_len)
+{
+ const uint8_t *gbuf;
+ ucache_sgraph_snapshot *snapshot;
+ size_t glen;
+
+ gbuf = ucache_sgraph_locate(buf, buf_len, &glen);
+ if (gbuf == NULL) {
+ return NULL;
+ }
+
+ snapshot = safe_emalloc(1, glen, offsetof(ucache_sgraph_snapshot, data));
+ snapshot->origin = gbuf;
+ snapshot->len = glen;
+
+ memcpy(snapshot->data, gbuf, glen);
+
+ return snapshot;
+}
+
+bool ucache_sgraph_decode_snapshot(ucache_sgraph_snapshot *snapshot, uint64_t gen, zval *dst)
+{
+ const uint8_t *origin = snapshot->origin;
+ uint64_t prev_gen = UC_G(decode_payload_gen);
+ bool prev_validated = UC_G(decode_payload_validated), validated, result;
+
+ validated = ucache_decode_payload_begin(origin, gen);
+ result = ucache_sgraph_decode_owned(snapshot->data, snapshot->len, snapshot, dst);
+ ucache_decode_payload_end(origin, validated, result);
+
+ UC_G(decode_payload_validated) = prev_validated;
+ UC_G(decode_payload_gen) = prev_gen;
+
+ return result;
+}
diff --git a/ext/user_cache/user_cache_shared_graph_encode.c b/ext/user_cache/user_cache_shared_graph_encode.c
new file mode 100644
index 000000000000..76fc1ba2da2c
--- /dev/null
+++ b/ext/user_cache/user_cache_shared_graph_encode.c
@@ -0,0 +1,4405 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_shared_graph.h"
+
+#include "Zend/zend_interfaces.h"
+#include "Zend/zend_closures.h"
+#include "Zend/zend_operators.h"
+
+#define UCACHE_SGRAPH_COPY_RETRIES 2U
+#define UCACHE_SGRAPH_PACKED_COMPACTION_PERCENT 75U
+#define UCACHE_SGRAPH_STATE_SCHEMA_MAX_VALS 64U
+#define UCACHE_DECLARED_PROP_IDX_NONE 0U
+
+typedef bool (*ucache_sgraph_state_producer_t)(const zval *val, zval *state, const void *producer_arg);
+
+typedef enum {
+ UCACHE_OBJ_ROUTE_UNSTORABLE = 0,
+ UCACHE_OBJ_ROUTE_SAFE_DIRECT,
+ UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE,
+ UCACHE_OBJ_ROUTE_SERIALIZE_PROPS,
+ UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE,
+ UCACHE_OBJ_ROUTE_SLEEP,
+ UCACHE_OBJ_ROUTE_WAKEUP,
+ UCACHE_OBJ_ROUTE_SERDES,
+ UCACHE_OBJ_ROUTE_PLAIN
+} ucache_obj_route;
+
+typedef struct {
+ ucache_obj_route route;
+ const php_ucache_safe_direct_handlers *sd_handlers;
+} ucache_obj_route_info;
+
+typedef struct {
+ size_t size;
+ HashTable seen_objs;
+ HashTable seen_refs;
+ HashTable str_dedup;
+ HashTable arr_shape_dedup;
+ HashTable state_schema_dedup;
+ HashTable direct_arr_dedup;
+ HashTable direct_verdicts;
+ HashTable enum_dedup;
+ uint16_t pin_word_count;
+ bool verbatim_arrs_allowed;
+ bool reserve_failed;
+ bool has_custom_obj_handlers;
+ HashTable *verbatim_verdicts;
+ HashTable *state_memo;
+} ucache_sgraph_calc_ctx;
+
+typedef struct {
+ uint8_t *buf;
+ size_t size;
+ size_t pos;
+ uint32_t *fixup_offsets;
+ uint32_t fixup_count;
+ uint32_t fixup_capacity;
+ HashTable seen_objs;
+ HashTable seen_refs;
+ HashTable str_dedup;
+ HashTable arr_shape_dedup;
+ HashTable state_schema_dedup;
+ HashTable direct_arr_dedup;
+ HashTable direct_verdicts;
+ HashTable enum_dedup;
+ HashTable identity_pins;
+ uint16_t pin_word_count;
+ bool has_shared_identity;
+ bool has_obj;
+ bool prefers_proto;
+ bool has_userland_restore_obj;
+ bool has_verbatim_arr;
+ bool packed_vals_allowed;
+ bool verbatim_arrs_allowed;
+ const HashTable *verbatim_verdicts;
+ HashTable *state_memo;
+} ucache_sgraph_copy_ctx;
+
+typedef struct {
+ zend_class_entry *ce;
+ uint32_t offset;
+ uint32_t count;
+ uint32_t sleep_slots_offset;
+ zend_string *keys[1];
+} ucache_sgraph_shape;
+
+static uint32_t ucache_sgraph_declared_prop_idx_plus_one(
+ zend_class_entry *ce,
+ zend_string *name,
+ uint32_t pos);
+static void ucache_sgraph_shape_dtor(zval *zv);
+static bool ucache_sgraph_calc_val(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val);
+static bool ucache_sgraph_copy_val(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ ucache_sgraph_val *dst);
+static bool ucache_sgraph_copy_verbatim_val(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zval *dst);
+
+static zend_always_inline uint16_t ucache_sgraph_active_pin_word_count(void)
+{
+ return (uint16_t) (ucache_ctx_graph_pin_slot_count(ucache_active_ctx()) / 32U);
+}
+
+static zend_always_inline void ucache_sgraph_calc_init(ucache_sgraph_calc_ctx *ctx)
+{
+ ctx->size = 0;
+ ctx->pin_word_count = ucache_sgraph_active_pin_word_count();
+ ctx->reserve_failed = false;
+ ctx->has_custom_obj_handlers = false;
+ ctx->verbatim_arrs_allowed = false;
+ ctx->verbatim_verdicts = NULL;
+ ctx->state_memo = NULL;
+
+ zend_hash_init(&ctx->seen_objs, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->seen_refs, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->str_dedup, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->arr_shape_dedup, 8, NULL, ucache_sgraph_shape_dtor, 0);
+ zend_hash_init(&ctx->state_schema_dedup, 8, NULL, ucache_sgraph_shape_dtor, 0);
+ zend_hash_init(&ctx->direct_arr_dedup, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->direct_verdicts, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->enum_dedup, 8, NULL, NULL, 0);
+}
+
+static zend_always_inline void ucache_sgraph_calc_destroy(ucache_sgraph_calc_ctx *ctx)
+{
+ zend_hash_destroy(&ctx->enum_dedup);
+ zend_hash_destroy(&ctx->direct_verdicts);
+ zend_hash_destroy(&ctx->direct_arr_dedup);
+ zend_hash_destroy(&ctx->state_schema_dedup);
+ zend_hash_destroy(&ctx->arr_shape_dedup);
+ zend_hash_destroy(&ctx->str_dedup);
+ zend_hash_destroy(&ctx->seen_refs);
+ zend_hash_destroy(&ctx->seen_objs);
+}
+
+static zend_always_inline void ucache_sgraph_calc_verbatim_init(ucache_sgraph_calc_ctx *ctx)
+{
+ ctx->size = 0;
+ ctx->pin_word_count = ucache_sgraph_active_pin_word_count();
+ ctx->reserve_failed = false;
+
+ zend_hash_init(&ctx->str_dedup, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->direct_arr_dedup, 8, NULL, NULL, 0);
+}
+
+static zend_always_inline void ucache_sgraph_calc_verbatim_destroy(ucache_sgraph_calc_ctx *ctx)
+{
+ zend_hash_destroy(&ctx->direct_arr_dedup);
+ zend_hash_destroy(&ctx->str_dedup);
+}
+
+static zend_always_inline bool ucache_sgraph_calc_reserve(
+ ucache_sgraph_calc_ctx *ctx,
+ size_t amount)
+{
+ size_t aligned_amount;
+
+ aligned_amount = UCACHE_ALIGNED_SIZE(amount);
+ if (ctx->size > SIZE_MAX - aligned_amount) {
+ ctx->reserve_failed = true;
+
+ return false;
+ }
+
+ ctx->size += aligned_amount;
+
+ if (UNEXPECTED(ctx->size > UINT32_MAX)) {
+ ctx->reserve_failed = true;
+
+ return false;
+ }
+
+ return true;
+}
+
+static zend_always_inline bool ucache_sgraph_calc_reserve_str(
+ ucache_sgraph_calc_ctx *ctx,
+ const zend_string *str)
+{
+ if (zend_hash_add_empty_element(&ctx->str_dedup, (zend_string *) str) == NULL) {
+ return true;
+ }
+
+ return ucache_sgraph_calc_reserve(ctx, _ZSTR_STRUCT_SIZE(ZSTR_LEN(str)));
+}
+
+static zend_always_inline bool ucache_sgraph_arr_is_fresh_empty(const HashTable *arr)
+{
+ return arr->nNumOfElements == 0 && arr->nNextFreeElement == ZEND_LONG_MIN;
+}
+
+static zend_always_inline uint32_t ucache_sgraph_verbatim_compact_table_size(uint32_t count)
+{
+ uint32_t table_size = HT_MIN_SIZE;
+
+ while (table_size < count) {
+ table_size <<= 1;
+ }
+
+ return table_size;
+}
+
+static zend_always_inline size_t ucache_sgraph_verbatim_compact_data_size(uint32_t count)
+{
+ return HT_HASH_SIZE(HT_SIZE_TO_MASK(ucache_sgraph_verbatim_compact_table_size(count))) +
+ (size_t) count * sizeof(Bucket)
+ ;
+}
+
+static zend_always_inline bool ucache_sgraph_verbatim_arr_needs_compaction(const HashTable *arr)
+{
+ if (HT_IS_PACKED(arr)) {
+ return !HT_IS_WITHOUT_HOLES(arr) &&
+ ucache_sgraph_verbatim_compact_data_size(arr->nNumOfElements) * 100U <=
+ HT_PACKED_USED_SIZE(arr) * UCACHE_SGRAPH_PACKED_COMPACTION_PERCENT
+ ;
+ }
+
+ return arr->nNumUsed != arr->nNumOfElements ||
+ (arr->nTableSize > HT_MIN_SIZE && arr->nTableSize / 2 >= arr->nNumOfElements)
+ ;
+}
+
+static zend_always_inline size_t ucache_sgraph_verbatim_data_size(const HashTable *arr)
+{
+ if (ucache_sgraph_verbatim_arr_needs_compaction(arr)) {
+ return ucache_sgraph_verbatim_compact_data_size(arr->nNumOfElements);
+ }
+
+ return HT_IS_PACKED(arr) ? HT_PACKED_USED_SIZE(arr) : HT_USED_SIZE(arr);
+}
+
+static zend_always_inline bool ucache_sgraph_next_free_is_wide(zend_long next_free)
+{
+ return next_free < 0 || (zend_ulong) next_free > (zend_ulong) UINT32_MAX;
+}
+
+static zend_always_inline bool ucache_sgraph_calc_reserve_dynamic_arr_as_mixed(
+ ucache_sgraph_calc_ctx *ctx,
+ const HashTable *arr)
+{
+ return ucache_sgraph_calc_reserve(ctx, sizeof(ucache_sgraph_arr)) &&
+ ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) arr->nNumOfElements * sizeof(ucache_sgraph_arr_elem)
+ ) &&
+ (
+ !ucache_sgraph_next_free_is_wide(arr->nNextFreeElement) ||
+ ucache_sgraph_calc_reserve(ctx, sizeof(int64_t))
+ )
+ ;
+}
+
+static zend_always_inline uint32_t ucache_sgraph_shape_next_free_encode(zend_long next_free)
+{
+ return next_free == ZEND_LONG_MIN ? UCACHE_SGRAPH_SHAPE_NEXT_FREE_UNSET : (uint32_t) next_free;
+}
+
+static zend_always_inline uint16_t ucache_sgraph_sleep_slot_hint(
+ zend_class_entry *ce,
+ zend_string *prop_name,
+ uint32_t pos)
+{
+ uint32_t slot_plus_one = ucache_sgraph_declared_prop_idx_plus_one(ce, prop_name, pos);
+
+ return slot_plus_one <= UINT16_MAX ? (uint16_t) slot_plus_one : 0;
+}
+
+static zend_always_inline bool ucache_sgraph_arr_has_shape(const HashTable *arr)
+{
+ zend_string *key;
+
+ if (arr->nNumOfElements == 0 ||
+ HT_IS_PACKED(arr) ||
+ arr->nNumOfElements > UCACHE_SGRAPH_ARR_SHAPE_MAX_KEYS ||
+ (arr->nNextFreeElement < 0 && arr->nNextFreeElement != ZEND_LONG_MIN) ||
+ arr->nNextFreeElement >= UCACHE_SGRAPH_SHAPE_NEXT_FREE_UNSET ||
+ !HT_IS_WITHOUT_HOLES(arr)
+ ) {
+ return false;
+ }
+
+ ZEND_HASH_FOREACH_STR_KEY((HashTable *) arr, key) {
+ if (key == NULL) {
+ return false;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return true;
+}
+
+static zend_always_inline bool ucache_sgraph_state_memo_entry_is_schema_verdict(const zval *entry)
+{
+ return Z_TYPE_P(entry) == IS_TRUE || Z_TYPE_P(entry) == IS_FALSE;
+}
+
+static zend_always_inline void ucache_sgraph_state_memo_retain_key(zend_object *obj)
+{
+ GC_ADDREF(obj);
+}
+
+static zend_always_inline bool ucache_sgraph_can_use_verbatim_arrs(void)
+{
+#ifdef ZEND_WIN32
+ return false;
+#else
+ return !ucache_ctx_is_boundary(ucache_active_ctx());
+#endif
+}
+
+static zend_always_inline bool ucache_sgraph_safe_direct_prop_is_internal(
+ const php_ucache_safe_direct_handlers *handlers,
+ const zend_object *obj,
+ const zend_string *prop_name)
+{
+ return handlers->is_internal_prop != NULL && handlers->is_internal_prop(obj, prop_name);
+}
+
+static zend_always_inline bool ucache_sgraph_props_unbuilt(zend_object *obj)
+{
+ return obj->properties == NULL && !zend_object_is_lazy(obj);
+}
+
+static zend_always_inline bool ucache_sgraph_plain_props_unbuilt(zend_object *obj)
+{
+ return obj->handlers->get_properties_for == NULL &&
+ obj->handlers->get_properties == zend_std_get_properties &&
+ ucache_sgraph_props_unbuilt(obj)
+ ;
+}
+
+static zend_always_inline zval *ucache_sgraph_declared_prop(
+ zend_object *obj,
+ const php_ucache_safe_direct_handlers *handlers,
+ uint32_t slot,
+ zend_string **prop_name)
+{
+ zend_property_info *prop_info = obj->ce->properties_info_table[slot];
+ zval *prop;
+
+ if (prop_info == NULL) {
+ return NULL;
+ }
+
+ prop = OBJ_PROP(obj, prop_info->offset);
+ if (handlers != NULL && ucache_sgraph_safe_direct_prop_is_internal(handlers, obj, prop_info->name)) {
+ return NULL;
+ }
+
+ *prop_name = prop_info->name;
+
+ return prop;
+}
+
+static zend_always_inline uint32_t ucache_sgraph_declared_prop_count(
+ zend_object *obj,
+ const php_ucache_safe_direct_handlers *handlers)
+{
+ zend_string *prop_name;
+ uint32_t slot, count = 0;
+
+ for (slot = 0; slot < (uint32_t) obj->ce->default_properties_count; slot++) {
+ if (ucache_sgraph_declared_prop(obj, handlers, slot, &prop_name) != NULL) {
+ count++;
+ }
+ }
+
+ return count;
+}
+
+static zend_always_inline void ucache_sgraph_release_traversed_arr(HashTable *arr)
+{
+ if (!(GC_FLAGS(arr) & GC_IMMUTABLE)) {
+ GC_DTOR(arr);
+ }
+}
+
+static zend_always_inline void ucache_sgraph_hold_props_across_hooks(zend_object *obj, HashTable *props)
+{
+ GC_ADDREF(obj);
+ GC_TRY_ADDREF(props);
+}
+
+static zend_always_inline void ucache_sgraph_release_held_props(zend_object *obj, HashTable *props)
+{
+ zend_release_properties(props);
+
+ OBJ_RELEASE(obj);
+}
+
+static zend_always_inline const php_ucache_safe_direct_handlers *ucache_sgraph_usable_safe_direct_handlers(
+ zend_class_entry *ce)
+{
+ const php_ucache_safe_direct_handlers *handlers;
+ zend_class_entry *base_ce = NULL;
+
+ if (ce->ce_flags & ZEND_ACC_NOT_SERIALIZABLE) {
+ return NULL;
+ }
+
+ handlers = ucache_safe_direct_find_handlers(ce, &base_ce);
+ if (handlers == NULL || ucache_class_overrides_safe_direct_magic_serialize(ce, handlers, base_ce)) {
+ return NULL;
+ }
+
+ return handlers;
+}
+
+static zend_always_inline bool ucache_class_has_sleep(zend_class_entry *ce)
+{
+ return zend_hash_find_known_hash(&ce->function_table, ZSTR_KNOWN(ZEND_STR_SLEEP)) != NULL;
+}
+
+static zend_always_inline bool ucache_sgraph_can_use_sleep_obj(zend_class_entry *ce)
+{
+ if ((ce->ce_flags & (ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_ENUM)) != 0 ||
+ ucache_class_has_serialize_handlers(ce) ||
+ !ucache_class_has_sleep(ce)
+ ) {
+ return false;
+ }
+
+ return ucache_sgraph_can_restore_props(ce, true);
+}
+
+static zend_always_inline bool ucache_sgraph_can_use_wakeup_obj(zend_class_entry *ce)
+{
+ return ce->__serialize == NULL &&
+ !ucache_class_has_sleep(ce) &&
+ ucache_sgraph_wakeup_rebuilds_state(ce)
+ ;
+}
+
+static zend_always_inline void ucache_sgraph_copy_record_fixup(
+ ucache_sgraph_copy_ctx *ctx,
+ const void *slot)
+{
+ uint32_t capacity;
+
+ ZEND_ASSERT(ucache_sgraph_ptr_in_range(slot, ctx->buf, ctx->size));
+ ZEND_ASSERT(((uintptr_t) slot & (sizeof(uintptr_t) - 1)) == 0);
+
+ if (ctx->fixup_count == ctx->fixup_capacity) {
+ capacity = ctx->fixup_capacity == 0 ? 8 : ctx->fixup_capacity * 2;
+
+ ctx->fixup_offsets = safe_erealloc(ctx->fixup_offsets, capacity, sizeof(*ctx->fixup_offsets), 0);
+ ctx->fixup_capacity = capacity;
+ }
+
+ ctx->fixup_offsets[ctx->fixup_count++] = (uint32_t) ((const uint8_t *) slot - ctx->buf);
+}
+
+static zend_always_inline bool ucache_sgraph_long_is_inline(zend_long val)
+{
+ return (zend_long) (int32_t) val == val;
+}
+
+static zend_always_inline bool ucache_sgraph_ref_is_held_once(const zend_reference *ref)
+{
+ return GC_REFCOUNT(ref) == 1;
+}
+
+static inline bool ucache_class_magic_route_active(zend_class_entry *ce)
+{
+ const php_ucache_safe_direct_handlers *handlers;
+ zend_class_entry *base_ce = NULL;
+
+ if (ce->ce_flags & (ZEND_ACC_NOT_SERIALIZABLE|ZEND_ACC_ENUM)) {
+ return false;
+ }
+
+ handlers = ucache_safe_direct_find_handlers(ce, &base_ce);
+
+ if (ucache_class_overrides_safe_direct_magic_serialize(ce, handlers, base_ce)) {
+ return true;
+ }
+
+ return handlers == NULL;
+}
+
+static inline bool ucache_class_uses_magic_serialize(zend_class_entry *ce)
+{
+ return ce->__serialize != NULL && ce->__unserialize != NULL &&
+ ucache_class_magic_route_active(ce)
+ ;
+}
+
+static inline bool ucache_class_uses_serialize_props(zend_class_entry *ce)
+{
+ return ce->__serialize != NULL && ce->__unserialize == NULL &&
+ ucache_class_magic_route_active(ce)
+ ;
+}
+
+static inline bool ucache_class_uses_magic_unserialize(zend_class_entry *ce)
+{
+ if (ce->__serialize != NULL || ce->__unserialize == NULL) {
+ return false;
+ }
+
+ if (ucache_class_has_serialize_handlers(ce)) {
+ return false;
+ }
+
+ return ucache_class_magic_route_active(ce);
+}
+
+static inline bool ucache_class_uses_serdes(zend_class_entry *ce)
+{
+ return (ce->ce_flags & ZEND_ACC_NOT_SERIALIZABLE) == 0 && ucache_class_has_serialize_handlers(ce);
+}
+
+static void ucache_sgraph_shape_dtor(zval *zv)
+{
+ ucache_sgraph_shape *shape = Z_PTR_P(zv);
+ uint32_t i;
+
+ for (i = 0; i < shape->count; i++) {
+ zend_string_release(shape->keys[i]);
+ }
+
+ efree(shape);
+}
+
+static zend_ulong ucache_sgraph_arr_shape_hash(const HashTable *arr, const zend_class_entry *ce)
+{
+ const Bucket *bucket;
+ zend_ulong hash = (zend_ulong) arr->nNumOfElements;
+
+ ZEND_ASSERT(ucache_sgraph_arr_has_shape(arr));
+
+ if (ce != NULL) {
+ hash ^= zend_string_hash_val(ce->name);
+ }
+
+ ZEND_HASH_MAP_FOREACH_BUCKET((HashTable *) arr, bucket) {
+ hash = (hash + bucket->h) * (zend_ulong) UINT64_C(0x9E3779B97F4A7C15);
+ } ZEND_HASH_FOREACH_END();
+
+ return hash;
+}
+
+static ucache_sgraph_shape *ucache_sgraph_shape_find(
+ HashTable *dedup,
+ zend_ulong hash,
+ const zend_class_entry *ce,
+ const HashTable *arr,
+ bool *collision)
+{
+ const Bucket *bucket;
+ ucache_sgraph_shape *shape;
+ uint32_t i = 0;
+
+ *collision = false;
+
+ shape = zend_hash_index_find_ptr(dedup, hash);
+ if (shape == NULL) {
+ return NULL;
+ }
+
+ *collision = true;
+
+ if (shape->ce != ce || shape->count != arr->nNumOfElements) {
+ return NULL;
+ }
+
+ ZEND_HASH_MAP_FOREACH_BUCKET((HashTable *) arr, bucket) {
+ if (!zend_string_equals(shape->keys[i], bucket->key)) {
+ return NULL;
+ }
+
+ i++;
+ } ZEND_HASH_FOREACH_END();
+
+ *collision = false;
+
+ return shape;
+}
+
+static ucache_sgraph_shape *ucache_sgraph_shape_add(
+ HashTable *dedup,
+ zend_ulong hash,
+ zend_class_entry *ce,
+ const HashTable *arr,
+ uint32_t offset)
+{
+ const Bucket *bucket;
+ ucache_sgraph_shape *shape;
+ uint32_t i = 0;
+
+ shape = safe_emalloc(
+ arr->nNumOfElements,
+ sizeof(zend_string *),
+ offsetof(ucache_sgraph_shape, keys)
+ );
+ shape->ce = ce;
+ shape->offset = offset;
+ shape->count = arr->nNumOfElements;
+ shape->sleep_slots_offset = 0;
+
+ ZEND_HASH_MAP_FOREACH_BUCKET((HashTable *) arr, bucket) {
+ shape->keys[i++] = zend_string_copy(bucket->key);
+ } ZEND_HASH_FOREACH_END();
+
+ return zend_hash_index_add_new_ptr(dedup, hash, shape);
+}
+
+static bool ucache_sgraph_calc_arr_shape(
+ ucache_sgraph_calc_ctx *ctx,
+ const HashTable *arr)
+{
+ zend_string *key;
+ zend_ulong hash;
+ bool collision;
+
+ hash = ucache_sgraph_arr_shape_hash(arr, NULL);
+ if (ucache_sgraph_shape_find(&ctx->arr_shape_dedup, hash, NULL, arr, &collision) != NULL) {
+ return true;
+ }
+
+ if (!ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_arr_shape)
+ ) ||
+ !ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) arr->nNumOfElements * sizeof(ucache_sgraph_arr_shape_elem)
+ )
+ ) {
+ return false;
+ }
+
+ ZEND_HASH_FOREACH_STR_KEY((HashTable *) arr, key) {
+ if (!ucache_sgraph_calc_reserve_str(ctx, key)) {
+ return false;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ if (!collision) {
+ ucache_sgraph_shape_add(&ctx->arr_shape_dedup, hash, NULL, arr, 0);
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_calc_state_schema(
+ ucache_sgraph_calc_ctx *ctx,
+ zend_class_entry *ce,
+ const HashTable *arr)
+{
+ zend_ulong hash;
+ bool collision;
+
+ hash = ucache_sgraph_arr_shape_hash(arr, ce);
+ if (ucache_sgraph_shape_find(&ctx->state_schema_dedup, hash, ce, arr, &collision) != NULL) {
+ return true;
+ }
+
+ if (!ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_state_schema)
+ ) ||
+ !ucache_sgraph_calc_reserve_str(ctx, ce->name) ||
+ !ucache_sgraph_calc_arr_shape(ctx, arr)
+ ) {
+ return false;
+ }
+
+ if (!collision) {
+ ucache_sgraph_shape_add(&ctx->state_schema_dedup, hash, ce, arr, 0);
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_state_val_fits_schema(
+ zval *val,
+ const HashTable *state_arr,
+ uint32_t *vals_left)
+{
+ zval *elem;
+
+ if (*vals_left == 0 || ucache_stack_overflowed()) {
+ return false;
+ }
+
+ --*vals_left;
+
+ ZVAL_DEREF(val);
+
+ switch (Z_TYPE_P(val)) {
+ case IS_UNDEF:
+ case IS_NULL:
+ case IS_FALSE:
+ case IS_TRUE:
+ case IS_LONG:
+ case IS_DOUBLE:
+ case IS_STRING:
+ return true;
+ case IS_ARRAY:
+ if (Z_ARRVAL_P(val) == state_arr) {
+ return false;
+ }
+
+ ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(val), elem) {
+ if (!ucache_sgraph_state_val_fits_schema(elem, state_arr, vals_left)) {
+ return false;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool ucache_sgraph_state_arr_fits_schema(const HashTable *arr)
+{
+ zval *elem;
+ uint32_t vals_left = UCACHE_SGRAPH_STATE_SCHEMA_MAX_VALS;
+
+ if (!ucache_sgraph_arr_has_shape(arr)) {
+ return false;
+ }
+
+ ZEND_HASH_FOREACH_VAL((HashTable *) arr, elem) {
+ if (!ucache_sgraph_state_val_fits_schema(elem, arr, &vals_left)) {
+ return false;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return true;
+}
+
+static bool ucache_sgraph_state_arr_fits_schema_memo(
+ HashTable *state_memo,
+ const HashTable *arr)
+{
+ zval *verdict, verdict_zv;
+
+ ZEND_ASSERT(state_memo != NULL);
+
+ verdict = zend_hash_index_find(state_memo, (zend_ulong) (uintptr_t) arr);
+ if (verdict != NULL && ucache_sgraph_state_memo_entry_is_schema_verdict(verdict)) {
+ return Z_TYPE_P(verdict) == IS_TRUE;
+ }
+
+ ZVAL_BOOL(&verdict_zv, ucache_sgraph_state_arr_fits_schema(arr));
+
+ zend_hash_index_add(state_memo, (zend_ulong) (uintptr_t) arr, &verdict_zv);
+
+ return Z_TYPE(verdict_zv) == IS_TRUE;
+}
+
+static bool ucache_sgraph_calc_shaped_state_obj(
+ ucache_sgraph_calc_ctx *ctx,
+ zend_class_entry *ce,
+ const HashTable *state_arr)
+{
+ zval *elem;
+
+ if (!ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_shaped_state_obj)
+ ) ||
+ !ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) state_arr->nNumOfElements * sizeof(ucache_sgraph_val)
+ ) ||
+ !ucache_sgraph_calc_state_schema(ctx, ce, state_arr)
+ ) {
+ return false;
+ }
+
+ ZEND_HASH_FOREACH_VAL((HashTable *) state_arr, elem) {
+ if (!ucache_sgraph_calc_val(ctx, elem)) {
+ return false;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ return true;
+}
+
+static ucache_obj_route ucache_sgraph_classify_obj_route_impl(zend_class_entry *ce)
+{
+ if (ucache_class_uses_magic_serialize(ce)) {
+ return UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE;
+ }
+
+ if (ucache_class_uses_serialize_props(ce)) {
+ return ucache_sgraph_can_restore_props(ce, true)
+ ? UCACHE_OBJ_ROUTE_SERIALIZE_PROPS
+ : UCACHE_OBJ_ROUTE_UNSTORABLE
+ ;
+ }
+
+ if (ucache_class_uses_magic_unserialize(ce)) {
+ return UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE;
+ }
+
+ if (ucache_sgraph_can_use_sleep_obj(ce)) {
+ return UCACHE_OBJ_ROUTE_SLEEP;
+ }
+
+ if (ucache_sgraph_can_use_wakeup_obj(ce)) {
+ return UCACHE_OBJ_ROUTE_WAKEUP;
+ }
+
+ if (ucache_class_uses_serdes(ce)) {
+ return UCACHE_OBJ_ROUTE_SERDES;
+ }
+
+ if (!ucache_sgraph_can_restore_direct(ce)) {
+ return UCACHE_OBJ_ROUTE_UNSTORABLE;
+ }
+
+ return UCACHE_OBJ_ROUTE_PLAIN;
+}
+
+static zend_never_inline ucache_obj_route_info ucache_sgraph_memoize_obj_route(zend_class_entry *ce)
+{
+ ucache_obj_route_info info;
+ zval route_zv;
+
+ if (UC_G(obj_route_memo) == NULL) {
+ UC_G(obj_route_memo) = emalloc(sizeof(HashTable));
+
+ zend_hash_init(UC_G(obj_route_memo), 8, NULL, NULL, 0);
+ }
+
+ info.sd_handlers = ucache_sgraph_usable_safe_direct_handlers(ce);
+ if (info.sd_handlers != NULL) {
+ info.route = UCACHE_OBJ_ROUTE_SAFE_DIRECT;
+
+ ZVAL_PTR(&route_zv, (void *) info.sd_handlers);
+ } else {
+ info.route = ucache_sgraph_classify_obj_route_impl(ce);
+
+ ZVAL_LONG(&route_zv, (zend_long) info.route);
+ }
+
+ zend_hash_index_add(UC_G(obj_route_memo), (zend_ulong) (uintptr_t) ce, &route_zv);
+
+ return info;
+}
+
+static ucache_obj_route_info ucache_sgraph_classify_obj_route(zend_class_entry *ce)
+{
+ ucache_obj_route_info info;
+ zval *cached;
+
+ if (UC_G(obj_route_memo) == NULL) {
+ return ucache_sgraph_memoize_obj_route(ce);
+ }
+
+ cached = zend_hash_index_find(UC_G(obj_route_memo), (zend_ulong) (uintptr_t) ce);
+ if (cached == NULL) {
+ return ucache_sgraph_memoize_obj_route(ce);
+ }
+
+ if (Z_TYPE_P(cached) == IS_PTR) {
+ info.route = UCACHE_OBJ_ROUTE_SAFE_DIRECT;
+ info.sd_handlers = Z_PTR_P(cached);
+ } else {
+ info.route = (ucache_obj_route) Z_LVAL_P(cached);
+ info.sd_handlers = NULL;
+ }
+
+ return info;
+}
+
+static bool ucache_sgraph_can_copy_verbatim_val(HashTable *direct_verdicts, const zval *val)
+{
+ const zval *packed_val;
+ const HashTable *arr;
+ const Bucket *bucket;
+ zend_ulong arr_key;
+ zval *cached, verdict;
+ uint32_t i;
+ bool result = true;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ switch (Z_TYPE_P(val)) {
+ case IS_UNDEF:
+ case IS_NULL:
+ case IS_FALSE:
+ case IS_TRUE:
+ case IS_LONG:
+ case IS_DOUBLE:
+ case IS_STRING:
+ return true;
+ case IS_ARRAY:
+ arr = Z_ARRVAL_P(val);
+ if (ucache_sgraph_arr_is_fresh_empty(arr)) {
+ return true;
+ }
+
+ if (HT_FLAGS(arr) & HASH_FLAG_UNINITIALIZED) {
+ return false;
+ }
+
+ arr_key = (zend_ulong) (uintptr_t) arr;
+
+ if (GC_REFCOUNT(arr) > 1) {
+ cached = zend_hash_index_find(direct_verdicts, arr_key);
+ if (cached != NULL) {
+ return Z_TYPE_P(cached) == IS_TRUE;
+ }
+ }
+
+ if (HT_IS_PACKED(arr)) {
+ for (i = 0; i < arr->nNumUsed; i++) {
+ packed_val = &arr->arPacked[i];
+ if (!ucache_sgraph_can_copy_verbatim_val(direct_verdicts, packed_val)) {
+ result = false;
+
+ break;
+ }
+ }
+ } else {
+ bucket = arr->arData;
+
+ for (i = 0; i < arr->nNumUsed; i++) {
+ if (Z_TYPE(bucket[i].val) != IS_UNDEF &&
+ !ucache_sgraph_can_copy_verbatim_val(direct_verdicts, &bucket[i].val)
+ ) {
+ result = false;
+
+ break;
+ }
+ }
+ }
+
+ if (GC_REFCOUNT(arr) > 1 || !result) {
+ ZVAL_BOOL(&verdict, result);
+
+ zend_hash_index_add(direct_verdicts, arr_key, &verdict);
+ }
+
+ return result;
+ default:
+ return false;
+ }
+}
+
+static bool ucache_sgraph_can_copy_verbatim_arr(HashTable *direct_verdicts, const zval *val)
+{
+ const zval *cached;
+
+ if (GC_REFCOUNT(Z_ARRVAL_P(val)) == 1) {
+ cached = zend_hash_index_find(direct_verdicts, (zend_ulong) (uintptr_t) Z_ARRVAL_P(val));
+ if (cached != NULL) {
+ return Z_TYPE_P(cached) == IS_TRUE;
+ }
+ }
+
+ return ucache_sgraph_can_copy_verbatim_val(direct_verdicts, val);
+}
+
+static bool ucache_sgraph_calc_verbatim_val(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val)
+{
+ const zval *packed_val;
+ const HashTable *arr;
+ const Bucket *bucket;
+ zend_ulong arr_key;
+ zval *cached, seen_marker;
+ uint32_t i;
+ size_t data_size;
+ bool result = true;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ switch (Z_TYPE_P(val)) {
+ case IS_UNDEF:
+ case IS_NULL:
+ case IS_FALSE:
+ case IS_TRUE:
+ case IS_LONG:
+ case IS_DOUBLE:
+ return true;
+ case IS_STRING:
+ return ucache_sgraph_calc_reserve_str(ctx, Z_STR_P(val));
+ case IS_ARRAY:
+ arr = Z_ARRVAL_P(val);
+ if (ucache_sgraph_arr_is_fresh_empty(arr)) {
+ return true;
+ }
+
+ if (HT_FLAGS(arr) & HASH_FLAG_UNINITIALIZED) {
+ return false;
+ }
+
+ arr_key = (zend_ulong) (uintptr_t) arr;
+ if (GC_REFCOUNT(arr) > 1) {
+ cached = zend_hash_index_find(&ctx->direct_arr_dedup, arr_key);
+ if (cached != NULL) {
+ return true;
+ }
+ }
+
+ data_size = ucache_sgraph_verbatim_data_size(arr);
+ if (!ucache_sgraph_calc_reserve(ctx, sizeof(zend_array)) ||
+ !ucache_sgraph_calc_reserve(ctx, data_size)
+ ) {
+ return false;
+ }
+
+ if (HT_IS_PACKED(arr)) {
+ for (i = 0; i < arr->nNumUsed; i++) {
+ packed_val = &arr->arPacked[i];
+ if (!ucache_sgraph_calc_verbatim_val(ctx, packed_val)) {
+ result = false;
+
+ break;
+ }
+ }
+ } else {
+ bucket = arr->arData;
+ for (i = 0; i < arr->nNumUsed; i++) {
+ if (bucket[i].key != NULL &&
+ !ucache_sgraph_calc_reserve_str(ctx, bucket[i].key)
+ ) {
+ result = false;
+
+ break;
+ }
+
+ if (Z_TYPE(bucket[i].val) != IS_UNDEF &&
+ !ucache_sgraph_calc_verbatim_val(ctx, &bucket[i].val)
+ ) {
+ result = false;
+
+ break;
+ }
+ }
+ }
+
+ if (result && GC_REFCOUNT(arr) > 1) {
+ ZVAL_TRUE(&seen_marker);
+
+ zend_hash_index_add(&ctx->direct_arr_dedup, arr_key, &seen_marker);
+ }
+
+ return result;
+ default:
+ return false;
+ }
+}
+
+static bool ucache_sgraph_produce_safe_direct_state(
+ const zval *val,
+ zval *state,
+ const void *producer_arg)
+{
+ const php_ucache_safe_direct_handlers *handlers = producer_arg;
+
+ if (!handlers->state_serialize(state, val) || Z_TYPE_P(state) != IS_ARRAY) {
+ if (!Z_ISUNDEF_P(state)) {
+ zval_ptr_dtor(state);
+
+ ZVAL_UNDEF(state);
+ }
+
+ if (!EG(exception)) {
+ zend_type_error(
+ "The state of the %s object cannot be stored in the user cache",
+ ZSTR_VAL(Z_OBJCE_P(val)->name)
+ );
+ }
+
+ return false;
+ }
+
+ return true;
+}
+
+static HashTable *ucache_sgraph_copy_with_req_keys(HashTable *props)
+{
+ HashTable *copy = zend_new_array(zend_hash_num_elements(props));
+ zend_string *key, *req_key;
+ zend_ulong idx;
+ zval *val;
+
+ ZEND_HASH_FOREACH_KEY_VAL(props, idx, key, val) {
+ Z_TRY_ADDREF_P(val);
+
+ if (key == NULL) {
+ zend_hash_index_add_new(copy, idx, val);
+
+ continue;
+ }
+
+ req_key = ZSTR_IS_INTERNED(key) || !(GC_FLAGS(key) & IS_STR_PERSISTENT)
+ ? zend_string_copy(key)
+ : zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), false)
+ ;
+ zend_hash_add_new(copy, req_key, val);
+
+ zend_string_release(req_key);
+ } ZEND_HASH_FOREACH_END();
+
+ return copy;
+}
+
+static uint32_t ucache_sgraph_declared_prop_idx_plus_one(
+ zend_class_entry *ce,
+ zend_string *name,
+ uint32_t pos)
+{
+ zend_property_info *prop_info;
+ uint32_t prop_idx;
+
+ if (ce->type != ZEND_USER_CLASS || ce->properties_info_table == NULL) {
+ return UCACHE_DECLARED_PROP_IDX_NONE;
+ }
+
+ if (pos < (uint32_t) ce->default_properties_count) {
+ prop_info = ce->properties_info_table[pos];
+ if (prop_info != NULL &&
+ zend_string_equals(prop_info->name, name) &&
+ (prop_info->flags & (ZEND_ACC_STATIC|ZEND_ACC_VIRTUAL)) == 0 &&
+ prop_info->offset != ZEND_VIRTUAL_PROPERTY_OFFSET &&
+ OBJ_PROP_TO_NUM(prop_info->offset) == pos
+ ) {
+ return pos + 1;
+ }
+ }
+
+ prop_info = ucache_sgraph_declared_prop_info(ce, name);
+ if (prop_info == NULL) {
+ return UCACHE_DECLARED_PROP_IDX_NONE;
+ }
+
+ prop_idx = OBJ_PROP_TO_NUM(prop_info->offset);
+ if (prop_idx >= (uint32_t) ce->default_properties_count) {
+ return UCACHE_DECLARED_PROP_IDX_NONE;
+ }
+
+ return prop_idx + 1;
+}
+
+static bool ucache_sgraph_extract_serialize_snapshot(
+ const zval *val,
+ zval *state,
+ const void *producer_arg)
+{
+ zval obj_zv;
+
+ ZVAL_OBJ(&obj_zv, Z_OBJ_P(val));
+
+ if (php_var_serialize_call_magic_serialize(state, &obj_zv) != SUCCESS) {
+ ZVAL_UNDEF(state);
+
+ return false;
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_extract_prop_snapshot(
+ const zval *val,
+ zval *state,
+ const void *producer_arg)
+{
+ zend_ulong num_key;
+ zend_string *key;
+ zval *prop, elem;
+ HashTable *props;
+ bool result = true;
+
+ props = zend_get_properties_for((zval *) val, ZEND_PROP_PURPOSE_SERIALIZE);
+ if (props == NULL) {
+ array_init(state);
+
+ return true;
+ }
+
+ array_init_size(state, zend_hash_num_elements(props));
+
+ ZEND_HASH_FOREACH_KEY_VAL(props, num_key, key, prop) {
+ if (Z_TYPE_P(prop) == IS_INDIRECT) {
+ prop = Z_INDIRECT_P(prop);
+ if (Z_TYPE_P(prop) == IS_UNDEF) {
+ continue;
+ }
+ }
+
+ if (Z_ISREF_P(prop) && Z_REFCOUNT_P(prop) == 1) {
+ prop = Z_REFVAL_P(prop);
+ }
+
+ ZVAL_COPY(&elem, prop);
+ if (key != NULL) {
+ result = zend_hash_add_new(Z_ARRVAL_P(state), key, &elem) != NULL;
+ } else {
+ result = zend_hash_index_add_new(Z_ARRVAL_P(state), num_key, &elem) != NULL;
+ }
+
+ if (!result) {
+ zval_ptr_dtor(&elem);
+
+ break;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ zend_release_properties(props);
+
+ if (!result) {
+ zval_ptr_dtor(state);
+
+ ZVAL_UNDEF(state);
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_extract_sleep_snapshot(
+ const zval *val,
+ zval *state,
+ const void *producer_arg)
+{
+ zend_object *obj = Z_OBJ_P(val);
+ zval *sleep_zv, obj_zv;
+ HashTable *names, *props;
+ bool result;
+
+ sleep_zv = zend_hash_find_known_hash(&obj->ce->function_table, ZSTR_KNOWN(ZEND_STR_SLEEP));
+ ZEND_ASSERT(sleep_zv != NULL);
+
+ ZVAL_UNDEF(state);
+
+ names = php_var_serialize_call_sleep(obj, Z_FUNC_P(sleep_zv));
+ if (names == NULL) {
+ if (!EG(exception)) {
+ zend_type_error(
+ "%s::__sleep() did not return an array of member names; the object cannot be stored in the user cache",
+ ZSTR_VAL(obj->ce->name)
+ );
+ }
+
+ return false;
+ }
+
+ ZVAL_OBJ(&obj_zv, obj);
+
+ props = emalloc(sizeof(HashTable));
+ result = php_var_serialize_get_sleep_props(props, &obj_zv, names) == SUCCESS;
+
+ zend_array_release(names);
+
+ if (result && obj->ce->type == ZEND_INTERNAL_CLASS) {
+ ZVAL_ARR(state, ucache_sgraph_copy_with_req_keys(props));
+ } else if (result) {
+ ZVAL_ARR(state, props);
+ }
+
+ if (!result || obj->ce->type == ZEND_INTERNAL_CLASS) {
+ zend_hash_destroy(props);
+
+ efree(props);
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_extract_unserialize_route_snapshot(
+ const zval *val,
+ zval *state,
+ const void *producer_arg)
+{
+ HashTable *symtable;
+ bool produced = ucache_class_has_sleep(Z_OBJCE_P(val))
+ ? ucache_sgraph_extract_sleep_snapshot(val, state, NULL)
+ : ucache_sgraph_extract_prop_snapshot(val, state, NULL)
+ ;
+
+ if (!produced) {
+ return false;
+ }
+
+ symtable = zend_proptable_to_symtable(Z_ARRVAL_P(state), false);
+
+ zval_ptr_dtor(state);
+
+ ZVAL_ARR(state, symtable);
+
+ return true;
+}
+
+static bool ucache_sgraph_get_memoized_state(
+ const zval *val,
+ HashTable *state_memo,
+ ucache_sgraph_state_producer_t produce_state,
+ const void *producer_arg,
+ zval **state_ptr)
+{
+ zend_ulong memo_key;
+ zend_object *obj;
+ zval *memo_state, owned_state;
+
+ *state_ptr = NULL;
+
+ if (state_memo == NULL) {
+ return false;
+ }
+
+ obj = Z_OBJ_P(val);
+ memo_key = (zend_ulong) (uintptr_t) obj;
+ memo_state = zend_hash_index_find(state_memo, memo_key);
+ if (memo_state != NULL) {
+ if (Z_TYPE_P(memo_state) != IS_ARRAY) {
+ return false;
+ }
+
+ *state_ptr = memo_state;
+
+ return true;
+ }
+
+ ucache_sgraph_state_memo_retain_key(obj);
+
+ ZVAL_UNDEF(&owned_state);
+
+ if (!produce_state(val, &owned_state, producer_arg)) {
+ OBJ_RELEASE(obj);
+
+ zval_ptr_dtor(&owned_state);
+
+ return false;
+ }
+
+ *state_ptr = zend_hash_index_add_new(state_memo, memo_key, &owned_state);
+
+ return true;
+}
+
+static bool ucache_sgraph_get_safe_direct_state(
+ const zval *val,
+ const php_ucache_safe_direct_handlers *handlers,
+ HashTable *state_memo,
+ zval *borrowed_state,
+ zval *owned_state)
+{
+ zval *state_ptr;
+
+ ZVAL_UNDEF(owned_state);
+
+ if (state_memo != NULL) {
+ if (!ucache_sgraph_get_memoized_state(
+ val,
+ state_memo,
+ ucache_sgraph_produce_safe_direct_state,
+ handlers,
+ &state_ptr
+ )
+ ) {
+ return false;
+ }
+
+ ZVAL_COPY_VALUE(borrowed_state, state_ptr);
+
+ return true;
+ }
+
+ if (!ucache_sgraph_produce_safe_direct_state(val, owned_state, handlers)) {
+ if (!Z_ISUNDEF_P(owned_state)) {
+ zval_ptr_dtor(owned_state);
+
+ ZVAL_UNDEF(owned_state);
+ }
+
+ return false;
+ }
+
+ ZVAL_COPY_VALUE(borrowed_state, owned_state);
+
+ return true;
+}
+
+static bool ucache_sgraph_get_serdes_blob(
+ const zval *val,
+ HashTable *state_memo,
+ zend_string **blob_ptr)
+{
+ zend_ulong memo_key;
+ zend_string *blob;
+ zend_object *obj = Z_OBJ_P(val);
+ zval *memo_state, blob_zv;
+ smart_str buf = {0};
+
+ *blob_ptr = NULL;
+
+ if (state_memo == NULL) {
+ return false;
+ }
+
+ memo_key = (zend_ulong) (uintptr_t) obj;
+ memo_state = zend_hash_index_find(state_memo, memo_key);
+ if (memo_state != NULL) {
+ if (Z_TYPE_P(memo_state) != IS_STRING) {
+ return false;
+ }
+
+ *blob_ptr = Z_STR_P(memo_state);
+
+ return true;
+ }
+
+ ucache_sgraph_state_memo_retain_key(obj);
+
+ if (!ucache_serdes_encode((zval *) val, &buf)) {
+ smart_str_free(&buf);
+
+ OBJ_RELEASE(obj);
+
+ return false;
+ }
+
+ blob = smart_str_extract(&buf);
+ if (ZSTR_LEN(blob) > UINT32_MAX) {
+ zend_string_release(blob);
+
+ OBJ_RELEASE(obj);
+
+ return false;
+ }
+
+ ZVAL_STR(&blob_zv, blob);
+ zend_hash_index_add_new(state_memo, memo_key, &blob_zv);
+
+ *blob_ptr = blob;
+
+ return true;
+}
+
+static ucache_sgraph_state_producer_t ucache_sgraph_route_state_producer(
+ ucache_obj_route route)
+{
+ switch (route) {
+ case UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE:
+ case UCACHE_OBJ_ROUTE_SERIALIZE_PROPS:
+ return ucache_sgraph_extract_serialize_snapshot;
+ case UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE:
+ return ucache_sgraph_extract_unserialize_route_snapshot;
+ case UCACHE_OBJ_ROUTE_SLEEP:
+ return ucache_sgraph_extract_sleep_snapshot;
+ case UCACHE_OBJ_ROUTE_WAKEUP:
+ return ucache_sgraph_extract_prop_snapshot;
+ default:
+ return NULL;
+ }
+}
+
+static bool ucache_sgraph_get_route_state(
+ const zval *val,
+ HashTable *state_memo,
+ ucache_obj_route route,
+ HashTable **state_arr)
+{
+ zval *state_ptr;
+
+ if (!ucache_sgraph_get_memoized_state(
+ val,
+ state_memo,
+ ucache_sgraph_route_state_producer(route),
+ NULL,
+ &state_ptr
+ )
+ ) {
+ return false;
+ }
+
+ *state_arr = Z_ARRVAL_P(state_ptr);
+
+ return true;
+}
+
+static bool ucache_sgraph_calc_magic_state_obj(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val,
+ zend_object *obj,
+ ucache_obj_route route)
+{
+ HashTable *state_ht;
+ zval state_zv;
+ bool result;
+
+ if (!ucache_seen_test_and_add(&ctx->seen_objs, obj)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_route_state(val, ctx->state_memo, route, &state_ht)) {
+ return false;
+ }
+
+ if (ucache_sgraph_state_arr_fits_schema_memo(ctx->state_memo, state_ht)) {
+ result = ucache_sgraph_calc_shaped_state_obj(
+ ctx,
+ obj->ce,
+ state_ht
+ );
+ } else {
+ ZVAL_ARR(&state_zv, state_ht);
+ result = ucache_sgraph_calc_reserve(ctx,
+ sizeof(ucache_sgraph_serialized_obj)) &&
+ ucache_sgraph_calc_reserve_str(ctx, obj->ce->name) &&
+ ucache_sgraph_calc_val(ctx, &state_zv)
+ ;
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_sleep_state_obj(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val,
+ zend_object *obj,
+ ucache_obj_route route)
+{
+ zend_ulong num_key;
+ zend_string *prop_name, *resolved_name;
+ zval *prop_val;
+ HashTable *props;
+ uint32_t prop_count;
+ bool result;
+
+ if (!ucache_seen_test_and_add(&ctx->seen_objs, obj)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_route_state(val, ctx->state_memo, route, &props)) {
+ return false;
+ }
+
+ if (ucache_sgraph_state_arr_fits_schema_memo(ctx->state_memo, props)) {
+ result = ucache_sgraph_calc_shaped_state_obj(
+ ctx,
+ obj->ce,
+ props
+ );
+ } else {
+ prop_count = zend_hash_num_elements(props);
+ result = ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_obj)
+ ) &&
+ ucache_sgraph_calc_reserve_str(ctx, obj->ce->name) &&
+ (prop_count == 0 ||
+ ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) prop_count * sizeof(ucache_sgraph_prop)
+ )
+ )
+ ;
+
+ if (result) {
+ ZEND_HASH_FOREACH_KEY_VAL(props, num_key, prop_name, prop_val) {
+ resolved_name = prop_name != NULL
+ ? zend_string_copy(prop_name)
+ : zend_long_to_str((zend_long) num_key)
+ ;
+
+ if (!ucache_sgraph_calc_reserve_str(ctx, resolved_name) ||
+ !ucache_sgraph_calc_val(ctx, prop_val)
+ ) {
+ zend_string_release(resolved_name);
+ result = false;
+
+ break;
+ }
+
+ zend_string_release(resolved_name);
+ } ZEND_HASH_FOREACH_END();
+ }
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_declared_props(
+ ucache_sgraph_calc_ctx *ctx,
+ zend_object *obj,
+ const php_ucache_safe_direct_handlers *handlers)
+{
+ zend_string *prop_name;
+ zval *prop_val;
+ uint32_t slot, prop_count = ucache_sgraph_declared_prop_count(obj, handlers);
+ bool result = true;
+
+ if (prop_count != 0 &&
+ !ucache_sgraph_calc_reserve(ctx, (size_t) prop_count * sizeof(ucache_sgraph_prop))
+ ) {
+ return false;
+ }
+
+ GC_ADDREF(obj);
+
+ for (slot = 0; slot < (uint32_t) obj->ce->default_properties_count; slot++) {
+ prop_val = ucache_sgraph_declared_prop(obj, handlers, slot, &prop_name);
+ if (prop_val == NULL) {
+ continue;
+ }
+
+ if (!ucache_sgraph_calc_reserve_str(ctx, prop_name) ||
+ !ucache_sgraph_calc_val(ctx, prop_val)
+ ) {
+ result = false;
+
+ break;
+ }
+ }
+
+ OBJ_RELEASE(obj);
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_safe_direct_obj(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val,
+ zend_object *obj,
+ const php_ucache_safe_direct_handlers *handlers)
+{
+ zend_string *prop_name;
+ zval *prop_val, *src_val, borrowed_state, state;
+ HashTable *props;
+ uint32_t prop_count;
+ bool result;
+
+ if (!ucache_sgraph_get_safe_direct_state(
+ val,
+ handlers,
+ ctx->state_memo,
+ &borrowed_state,
+ &state
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_seen_test_and_add(&ctx->seen_objs, obj)) {
+ result = true;
+
+ goto done;
+ }
+
+ if (!ucache_sgraph_calc_reserve(ctx,
+ sizeof(ucache_sgraph_safe_direct_obj)) ||
+ !ucache_sgraph_calc_reserve_str(ctx, obj->ce->name) ||
+ !ucache_sgraph_calc_val(ctx, &borrowed_state)
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ if (ucache_sgraph_props_unbuilt(obj)) {
+ result = ucache_sgraph_calc_declared_props(ctx, obj, handlers);
+
+ goto done;
+ }
+
+ props = zend_std_get_properties(obj);
+ prop_count = 0;
+ result = true;
+
+ if (props == NULL) {
+ goto done;
+ }
+
+ ZEND_HASH_FOREACH_STR_KEY(props, prop_name) {
+ if (prop_name == NULL) {
+ result = false;
+
+ break;
+ }
+
+ if (!ucache_sgraph_safe_direct_prop_is_internal(handlers, obj, prop_name)) {
+ prop_count++;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ if (!result) {
+ goto done;
+ }
+
+ if (prop_count != 0 &&
+ !ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) prop_count * sizeof(ucache_sgraph_prop)
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ ucache_sgraph_hold_props_across_hooks(obj, props);
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(props, prop_name, prop_val) {
+ if (prop_name == NULL) {
+ result = false;
+
+ break;
+ }
+
+ src_val = Z_TYPE_P(prop_val) == IS_INDIRECT
+ ? Z_INDIRECT_P(prop_val)
+ : prop_val
+ ;
+
+ if (ucache_sgraph_safe_direct_prop_is_internal(handlers, obj, prop_name)) {
+ continue;
+ }
+
+ if (!ucache_sgraph_calc_reserve_str(ctx, prop_name) ||
+ !ucache_sgraph_calc_val(ctx, src_val)
+ ) {
+ result = false;
+
+ break;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ ucache_sgraph_release_held_props(obj, props);
+
+done:
+ if (!Z_ISUNDEF(state)) {
+ zval_ptr_dtor(&state);
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_plain_obj(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val,
+ zend_object *obj)
+{
+ zend_string *prop_name;
+ zval *prop_val, *src_val;
+ HashTable *props;
+ bool result;
+
+ if (!ucache_seen_test_and_add(&ctx->seen_objs, obj)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_obj)
+ ) ||
+ !ucache_sgraph_calc_reserve_str(ctx, obj->ce->name)
+ ) {
+ return false;
+ }
+
+ if (obj->handlers != &std_object_handlers) {
+ ctx->has_custom_obj_handlers = true;
+ }
+
+ if (ucache_sgraph_plain_props_unbuilt(obj)) {
+ return ucache_sgraph_calc_declared_props(ctx, obj, NULL);
+ }
+
+ props = zend_get_properties_for((zval *) val, ZEND_PROP_PURPOSE_SERIALIZE);
+ if (props == NULL) {
+ return true;
+ }
+
+ result = true;
+ if (props->nNumOfElements != 0 &&
+ !ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) props->nNumOfElements * sizeof(ucache_sgraph_prop)
+ )
+ ) {
+ zend_release_properties(props);
+
+ return false;
+ }
+
+ GC_ADDREF(obj);
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(props, prop_name, prop_val) {
+ src_val = Z_TYPE_P(prop_val) == IS_INDIRECT
+ ? Z_INDIRECT_P(prop_val)
+ : prop_val
+ ;
+
+ if (prop_name == NULL ||
+ !ucache_sgraph_calc_reserve_str(ctx, prop_name) ||
+ !ucache_sgraph_calc_val(ctx, src_val)
+ ) {
+ result = false;
+
+ break;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ zend_release_properties(props);
+
+ OBJ_RELEASE(obj);
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_obj(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val)
+{
+ ucache_obj_route_info route_info;
+ zend_string *case_name, *serdes_blob;
+ zend_class_entry *ce;
+ zend_object *obj;
+
+ obj = Z_OBJ_P(val);
+ ce = obj->ce;
+ if (ce == zend_ce_closure) {
+ ucache_throw_unstorable_obj(ce);
+
+ return false;
+ }
+
+ if (!zend_lazy_object_initialized(obj)) {
+ if (!EG(exception)) {
+ zend_type_error(UCACHE_MSG_LAZY_OBJ_UNSTORABLE);
+ }
+
+ return false;
+ }
+
+ if (ce->ce_flags & ZEND_ACC_ENUM) {
+ if (!ucache_seen_test_and_add(&ctx->enum_dedup, obj)) {
+ return true;
+ }
+
+ case_name = Z_STR_P(zend_enum_fetch_case_name(obj));
+
+ return ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_enum)
+ ) &&
+ ucache_sgraph_calc_reserve_str(ctx, ce->name) &&
+ ucache_sgraph_calc_reserve_str(ctx, case_name)
+ ;
+ }
+
+ route_info = ucache_sgraph_classify_obj_route(ce);
+
+ switch (route_info.route) {
+ case UCACHE_OBJ_ROUTE_SAFE_DIRECT:
+ return ucache_sgraph_calc_safe_direct_obj(ctx, val, obj, route_info.sd_handlers);
+ case UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE:
+ return ucache_sgraph_calc_magic_state_obj(
+ ctx, val, obj, UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE
+ );
+ case UCACHE_OBJ_ROUTE_SERIALIZE_PROPS:
+ return ucache_sgraph_calc_sleep_state_obj(
+ ctx, val, obj, UCACHE_OBJ_ROUTE_SERIALIZE_PROPS
+ );
+ case UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE:
+ return ucache_sgraph_calc_magic_state_obj(
+ ctx, val, obj, UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE
+ );
+ case UCACHE_OBJ_ROUTE_SLEEP:
+ return ucache_sgraph_calc_sleep_state_obj(
+ ctx, val, obj, UCACHE_OBJ_ROUTE_SLEEP
+ );
+ case UCACHE_OBJ_ROUTE_WAKEUP:
+ return ucache_sgraph_calc_sleep_state_obj(
+ ctx, val, obj, UCACHE_OBJ_ROUTE_WAKEUP
+ );
+ case UCACHE_OBJ_ROUTE_SERDES:
+ if (!ucache_seen_test_and_add(&ctx->seen_objs, obj)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_serdes_blob(val, ctx->state_memo, &serdes_blob)) {
+ return false;
+ }
+
+ return ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_serdes_obj) + ZSTR_LEN(serdes_blob)
+ );
+ case UCACHE_OBJ_ROUTE_PLAIN:
+ return ucache_sgraph_calc_plain_obj(ctx, val, obj);
+ case UCACHE_OBJ_ROUTE_UNSTORABLE:
+ ucache_throw_unstorable_obj(obj->ce);
+
+ return false;
+ }
+
+ return false;
+}
+
+static bool ucache_sgraph_calc_arr(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val)
+{
+ HashTable *arr;
+ zend_string *key;
+ zval *elem, *verdict, verdict_zv;
+ bool result, verbatim, verdicts_usable;
+
+ arr = Z_ARRVAL_P(val);
+
+ if (arr->nNumOfElements == 0) {
+ if (!ucache_sgraph_arr_is_fresh_empty(arr)) {
+ return ucache_sgraph_calc_reserve(ctx, sizeof(ucache_sgraph_arr)) &&
+ (
+ !ucache_sgraph_next_free_is_wide(arr->nNextFreeElement) ||
+ ucache_sgraph_calc_reserve(ctx, sizeof(int64_t))
+ )
+ ;
+ }
+
+ return true;
+ }
+
+ if (ctx->verbatim_arrs_allowed) {
+ if (GC_FLAGS(arr) & IS_ARRAY_IMMUTABLE) {
+ return ucache_sgraph_calc_verbatim_val(ctx, val);
+ }
+
+ verdicts_usable = ctx->verbatim_verdicts != NULL &&
+ (
+ ctx->state_memo == NULL ||
+ zend_hash_num_elements(ctx->state_memo) == 0
+ )
+ ;
+ verdict = verdicts_usable
+ ? zend_hash_index_find(ctx->verbatim_verdicts, (zend_ulong) (uintptr_t) arr)
+ : NULL
+ ;
+
+ if (verdict != NULL) {
+ verbatim = Z_TYPE_P(verdict) == IS_TRUE;
+ } else {
+ verbatim = ucache_sgraph_can_copy_verbatim_arr(&ctx->direct_verdicts, val);
+
+ if (verdicts_usable) {
+ ZVAL_BOOL(&verdict_zv, verbatim);
+
+ zend_hash_index_add(ctx->verbatim_verdicts, (zend_ulong) (uintptr_t) arr, &verdict_zv);
+ }
+ }
+
+ if (verbatim) {
+ return ucache_sgraph_calc_verbatim_val(ctx, val);
+ }
+ }
+
+ result = true;
+
+ GC_TRY_ADDREF(arr);
+
+ if (ucache_sgraph_arr_has_shape(arr)) {
+ if (!ucache_sgraph_calc_reserve(
+ ctx,
+ sizeof(ucache_sgraph_shaped_arr)
+ ) ||
+ !ucache_sgraph_calc_reserve(
+ ctx,
+ (size_t) arr->nNumOfElements * sizeof(ucache_sgraph_val)
+ ) ||
+ !ucache_sgraph_calc_arr_shape(ctx, arr)
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ ZEND_HASH_FOREACH_VAL(arr, elem) {
+ if (!ucache_sgraph_calc_val(ctx, elem)) {
+ result = false;
+
+ break;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ goto done;
+ }
+
+ if (!ucache_sgraph_calc_reserve_dynamic_arr_as_mixed(ctx, arr)) {
+ result = false;
+
+ goto done;
+ }
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(arr, key, elem) {
+ if (key != NULL && !ucache_sgraph_calc_reserve_str(ctx, key)) {
+ result = false;
+
+ break;
+ }
+
+ if (!ucache_sgraph_calc_val(ctx, elem)) {
+ result = false;
+
+ break;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+done:
+ ucache_sgraph_release_traversed_arr(arr);
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_ref(
+ ucache_sgraph_calc_ctx *ctx,
+ zend_reference *ref)
+{
+ zval *reached_again;
+ bool held_once, result;
+
+ held_once = ucache_sgraph_ref_is_held_once(ref);
+
+ reached_again = zend_hash_index_lookup(&ctx->seen_refs, (zend_ulong) (uintptr_t) ref);
+ if (Z_TYPE_P(reached_again) != IS_NULL) {
+ ZVAL_TRUE(reached_again);
+
+ return true;
+ }
+
+ ZVAL_FALSE(reached_again);
+
+ result = ucache_sgraph_calc_reserve(ctx, sizeof(ucache_sgraph_ref)) &&
+ ucache_sgraph_calc_val(ctx, &ref->val)
+ ;
+
+ if (held_once) {
+ reached_again = zend_hash_index_find(&ctx->seen_refs, (zend_ulong) (uintptr_t) ref);
+ if (Z_TYPE_P(reached_again) == IS_FALSE) {
+ zend_hash_index_del(&ctx->seen_refs, (zend_ulong) (uintptr_t) ref);
+ }
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_calc_val(
+ ucache_sgraph_calc_ctx *ctx,
+ const zval *val)
+{
+ if (ucache_stack_overflowed()) {
+ if (!EG(exception)) {
+ zend_type_error(UCACHE_MSG_NESTED_TOO_DEEPLY);
+ }
+
+ return false;
+ }
+
+ switch (Z_TYPE_P(val)) {
+ case IS_UNDEF:
+ case IS_NULL:
+ case IS_FALSE:
+ case IS_TRUE:
+ return true;
+ case IS_LONG:
+ return ucache_sgraph_long_is_inline(Z_LVAL_P(val)) ||
+ ucache_sgraph_calc_reserve(ctx, sizeof(int64_t))
+ ;
+ case IS_DOUBLE:
+ return ucache_sgraph_calc_reserve(ctx, sizeof(double));
+ case IS_STRING:
+ return ucache_sgraph_calc_reserve_str(ctx, Z_STR_P(val));
+ case IS_RESOURCE:
+ ucache_throw_unstorable_res();
+
+ return false;
+ case IS_ARRAY:
+ return ucache_sgraph_calc_arr(ctx, val);
+ case IS_OBJECT:
+ return ucache_sgraph_calc_obj(ctx, val);
+ case IS_REFERENCE:
+ return ucache_sgraph_calc_ref(ctx, Z_REF_P(val));
+ default:
+ return false;
+ }
+}
+
+static void ucache_sgraph_copy_init(
+ ucache_sgraph_copy_ctx *ctx,
+ uint8_t *buf,
+ size_t size)
+{
+ ctx->buf = buf;
+ ctx->size = size;
+ ctx->pos = 0;
+ ctx->fixup_offsets = NULL;
+ ctx->fixup_count = 0;
+ ctx->fixup_capacity = 0;
+
+ zend_hash_init(&ctx->seen_objs, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->seen_refs, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->str_dedup, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->arr_shape_dedup, 8, NULL, ucache_sgraph_shape_dtor, 0);
+ zend_hash_init(&ctx->state_schema_dedup, 8, NULL, ucache_sgraph_shape_dtor, 0);
+ zend_hash_init(&ctx->direct_arr_dedup, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->direct_verdicts, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->enum_dedup, 8, NULL, NULL, 0);
+ zend_hash_init(&ctx->identity_pins, 8, NULL, ZVAL_PTR_DTOR, 0);
+
+ ctx->has_shared_identity = false;
+ ctx->has_obj = false;
+ ctx->prefers_proto = false;
+ ctx->has_userland_restore_obj = false;
+ ctx->has_verbatim_arr = false;
+ ctx->verbatim_arrs_allowed = ucache_sgraph_can_use_verbatim_arrs();
+ ctx->verbatim_verdicts = NULL;
+ ctx->pin_word_count = ucache_sgraph_active_pin_word_count();
+}
+
+static void ucache_sgraph_copy_destroy(ucache_sgraph_copy_ctx *ctx)
+{
+ if (ctx->fixup_offsets != NULL) {
+ efree(ctx->fixup_offsets);
+
+ ctx->fixup_offsets = NULL;
+ }
+
+ zend_hash_destroy(&ctx->enum_dedup);
+ zend_hash_destroy(&ctx->direct_verdicts);
+ zend_hash_destroy(&ctx->direct_arr_dedup);
+ zend_hash_destroy(&ctx->state_schema_dedup);
+ zend_hash_destroy(&ctx->arr_shape_dedup);
+ zend_hash_destroy(&ctx->str_dedup);
+ zend_hash_destroy(&ctx->seen_refs);
+ zend_hash_destroy(&ctx->seen_objs);
+ zend_hash_destroy(&ctx->identity_pins);
+}
+
+static bool ucache_sgraph_seen_record_obj_offsets(
+ ucache_sgraph_copy_ctx *ctx,
+ zend_object *obj,
+ uint32_t obj_offset,
+ uint32_t flags_offset)
+{
+ zval pin;
+ uint32_t delta;
+
+ delta = flags_offset - obj_offset;
+
+ ZEND_ASSERT((obj_offset & 3) == 0);
+ ZEND_ASSERT((delta == 4 || delta == 12) && "unexpected flags field offset");
+
+ if (zend_hash_index_add_ptr(
+ &ctx->seen_objs,
+ (zend_ulong) (uintptr_t) obj,
+ (void *) (uintptr_t) (obj_offset | (delta >> 2))
+ ) == NULL
+ ) {
+ return false;
+ }
+
+ ZVAL_OBJ_COPY(&pin, obj);
+ zend_hash_next_index_insert_new(&ctx->identity_pins, &pin);
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_emit_obj_ref_if_seen(
+ ucache_sgraph_copy_ctx *ctx,
+ const zend_object *obj,
+ ucache_sgraph_val *dst)
+{
+ uint32_t packed, obj_offset, flags_offset;
+ void *seen_offset;
+
+ seen_offset = zend_hash_index_find_ptr(&ctx->seen_objs, (zend_ulong) (uintptr_t) obj);
+ if (seen_offset == NULL) {
+ return false;
+ }
+
+ packed = (uint32_t) (uintptr_t) seen_offset;
+ obj_offset = packed & ~(uint32_t) 3;
+ flags_offset = obj_offset + ((packed & 3) << 2);
+
+ *(uint32_t *) (ctx->buf + flags_offset) |= UCACHE_SGRAPH_OBJ_FLAG_SHARED;
+ ctx->has_shared_identity = true;
+
+ dst->type = UCACHE_SGRAPH_VAL_OBJ_REF;
+ dst->offset = obj_offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_alloc(
+ ucache_sgraph_copy_ctx *ctx,
+ size_t amount,
+ uint32_t *offset)
+{
+ size_t aligned_amount;
+
+ aligned_amount = UCACHE_ALIGNED_SIZE(amount);
+ if (ctx->pos > ctx->size || aligned_amount > ctx->size - ctx->pos) {
+ return false;
+ }
+
+ *offset = (uint32_t) ctx->pos;
+
+ if (aligned_amount > amount) {
+ memset(ctx->buf + ctx->pos + amount, 0, aligned_amount - amount);
+ }
+
+ ctx->pos += aligned_amount;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_str(
+ ucache_sgraph_copy_ctx *ctx,
+ const zend_string *str,
+ uint32_t *offset)
+{
+ zend_string *new_str;
+ zval *cached;
+ uint32_t str_offset;
+ size_t str_size;
+
+ cached = zend_hash_lookup(&ctx->str_dedup, (zend_string *) str);
+ if (Z_TYPE_P(cached) == IS_LONG) {
+ *offset = (uint32_t) Z_LVAL_P(cached);
+
+ return true;
+ }
+
+ str_size = _ZSTR_STRUCT_SIZE(ZSTR_LEN(str));
+ if (!ucache_sgraph_copy_alloc(ctx, str_size, &str_offset)) {
+ return false;
+ }
+
+ new_str = (zend_string *) (ctx->buf + str_offset);
+
+ memcpy(new_str, str, str_size);
+
+ GC_SET_REFCOUNT(new_str, 2);
+ GC_TYPE_INFO(new_str) = GC_STRING |
+ ((IS_STR_INTERNED | (ZSTR_IS_VALID_UTF8(str) ? IS_STR_VALID_UTF8 : 0)) << GC_FLAGS_SHIFT)
+ ;
+
+ *offset = str_offset;
+
+ ZVAL_LONG(cached, (zend_long) str_offset);
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_arr_shape(
+ ucache_sgraph_copy_ctx *ctx,
+ const HashTable *arr,
+ uint32_t *offset)
+{
+ ucache_sgraph_arr_shape *gshape;
+ ucache_sgraph_arr_shape_elem *shape_elem;
+ ucache_sgraph_shape *shape;
+ zend_string *key;
+ zend_ulong hash;
+ uint32_t shape_offset, elems_offset, key_offset;
+ bool collision;
+
+ hash = ucache_sgraph_arr_shape_hash(arr, NULL);
+ shape = ucache_sgraph_shape_find(&ctx->arr_shape_dedup, hash, NULL, arr, &collision);
+ if (shape != NULL) {
+ *offset = shape->offset;
+
+ return true;
+ }
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(*gshape), &shape_offset) ||
+ !ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) arr->nNumOfElements * sizeof(*shape_elem),
+ &elems_offset
+ )
+ ) {
+ return false;
+ }
+
+ gshape = (ucache_sgraph_arr_shape *) (ctx->buf + shape_offset);
+ gshape->count = (uint32_t) arr->nNumOfElements;
+ gshape->elems_offset = elems_offset;
+
+ shape_elem = (ucache_sgraph_arr_shape_elem *) (ctx->buf + elems_offset);
+
+ ZEND_HASH_FOREACH_STR_KEY((HashTable *) arr, key) {
+ ZEND_ASSERT(key != NULL);
+
+ if (!ucache_sgraph_copy_str(ctx, key, &key_offset)) {
+ return false;
+ }
+
+ shape_elem->key_offset = key_offset;
+
+ ++shape_elem;
+ } ZEND_HASH_FOREACH_END();
+
+ if (!collision) {
+ ucache_sgraph_shape_add(&ctx->arr_shape_dedup, hash, NULL, arr, shape_offset);
+ }
+
+ *offset = shape_offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_state_schema(
+ ucache_sgraph_copy_ctx *ctx,
+ zend_class_entry *ce,
+ const HashTable *arr,
+ uint32_t *offset,
+ ucache_sgraph_shape **dedup_entry)
+{
+ ucache_sgraph_state_schema *schema;
+ ucache_sgraph_shape *shape;
+ zend_ulong hash;
+ uint32_t schema_offset, class_name_offset, shape_offset;
+ bool collision;
+
+ hash = ucache_sgraph_arr_shape_hash(arr, ce);
+ shape = ucache_sgraph_shape_find(&ctx->state_schema_dedup, hash, ce, arr, &collision);
+ *dedup_entry = shape;
+ if (shape != NULL) {
+ *offset = shape->offset;
+
+ return true;
+ }
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(*schema), &schema_offset) ||
+ !ucache_sgraph_copy_str(ctx, ce->name, &class_name_offset) ||
+ !ucache_sgraph_copy_arr_shape(ctx, arr, &shape_offset)
+ ) {
+ return false;
+ }
+
+ schema = (ucache_sgraph_state_schema *) (ctx->buf + schema_offset);
+ schema->class_name_offset = class_name_offset;
+ schema->shape_offset = shape_offset;
+ schema->count = (uint32_t) arr->nNumOfElements;
+
+ if (!collision) {
+ *dedup_entry = ucache_sgraph_shape_add(&ctx->state_schema_dedup, hash, ce, arr, schema_offset);
+ }
+
+ *offset = schema_offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_shaped_state_vals(
+ ucache_sgraph_copy_ctx *ctx,
+ const HashTable *state_arr,
+ uint32_t vals_offset,
+ uint32_t count)
+{
+ ucache_sgraph_val *gvals;
+ zval *elem;
+ uint32_t i;
+
+ gvals = (ucache_sgraph_val *) (ctx->buf + vals_offset);
+ i = 0;
+
+ ZEND_HASH_FOREACH_VAL((HashTable *) state_arr, elem) {
+ if (i == count || !ucache_sgraph_copy_val(ctx, elem, &gvals[i])) {
+ return false;
+ }
+
+ ++i;
+ } ZEND_HASH_FOREACH_END();
+
+ return i == count;
+}
+
+static void ucache_sgraph_copy_sleep_slot_hints(
+ ucache_sgraph_copy_ctx *ctx,
+ ucache_sgraph_shape *schema_entry,
+ zend_class_entry *ce,
+ const HashTable *state_arr,
+ uint32_t vals_offset)
+{
+ const ucache_sgraph_val *known_vals;
+ ucache_sgraph_val *gvals;
+ zend_string *key;
+ uint32_t i = 0;
+
+ ZEND_ASSERT(vals_offset != 0);
+
+ gvals = (ucache_sgraph_val *) (ctx->buf + vals_offset);
+
+ if (schema_entry != NULL && schema_entry->sleep_slots_offset != 0) {
+ known_vals = (const ucache_sgraph_val *) (ctx->buf + schema_entry->sleep_slots_offset);
+ for (i = 0; i < schema_entry->count; i++) {
+ gvals[i].sleep_slot_plus_one = known_vals[i].sleep_slot_plus_one;
+ }
+
+ return;
+ }
+
+ ZEND_HASH_MAP_FOREACH_STR_KEY((HashTable *) state_arr, key) {
+ gvals[i].sleep_slot_plus_one = ucache_sgraph_sleep_slot_hint(ce, key, i);
+ i++;
+ } ZEND_HASH_FOREACH_END();
+
+ if (schema_entry != NULL) {
+ schema_entry->sleep_slots_offset = vals_offset;
+ }
+}
+
+static bool ucache_sgraph_copy_verbatim_compacted_bucket(
+ ucache_sgraph_copy_ctx *ctx,
+ zend_array *target,
+ uint32_t idx,
+ zend_ulong h,
+ zend_string *key,
+ const zval *val)
+{
+ Bucket *dst_bucket = &target->arData[idx];
+ uint32_t key_offset, hash_slot;
+
+ dst_bucket->h = h;
+ dst_bucket->key = NULL;
+
+ if (key != NULL) {
+ if (!ucache_sgraph_copy_str(ctx, key, &key_offset)) {
+ return false;
+ }
+
+ dst_bucket->key = (zend_string *) (void *) (ctx->buf + key_offset);
+
+ ucache_sgraph_copy_record_fixup(ctx, &dst_bucket->key);
+ }
+
+ if (!ucache_sgraph_copy_verbatim_val(ctx, val, &dst_bucket->val)) {
+ return false;
+ }
+
+ hash_slot = (uint32_t) h | target->nTableMask;
+ Z_NEXT(dst_bucket->val) = HT_HASH(target, hash_slot);
+ HT_HASH(target, hash_slot) = HT_IDX_TO_HASH(idx);
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_verbatim_compacted_buckets(
+ ucache_sgraph_copy_ctx *ctx,
+ const HashTable *src_arr,
+ zend_array *target)
+{
+ const Bucket *src_bucket;
+ uint32_t i, idx = 0;
+
+ if (HT_IS_PACKED(src_arr)) {
+ HT_FLAGS(target) &= ~HASH_FLAG_PACKED;
+
+ for (i = 0; i < src_arr->nNumUsed; i++) {
+ if (Z_TYPE(src_arr->arPacked[i]) == IS_UNDEF) {
+ continue;
+ }
+
+ if (!ucache_sgraph_copy_verbatim_compacted_bucket(ctx, target, idx, i, NULL, &src_arr->arPacked[i])) {
+ return false;
+ }
+
+ idx++;
+ }
+
+ return true;
+ }
+
+ for (i = 0; i < src_arr->nNumUsed; i++) {
+ src_bucket = &src_arr->arData[i];
+ if (Z_TYPE(src_bucket->val) == IS_UNDEF) {
+ continue;
+ }
+
+ if (!ucache_sgraph_copy_verbatim_compacted_bucket(
+ ctx,
+ target,
+ idx,
+ src_bucket->h,
+ src_bucket->key,
+ &src_bucket->val
+ )
+ ) {
+ return false;
+ }
+
+ idx++;
+ }
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_verbatim_val(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zval *dst)
+{
+ const zval *src_packed;
+ const HashTable *src_arr;
+ const Bucket *src_bucket;
+ zend_ulong arr_key;
+ zend_array *target;
+ zval *dst_packed, *cached, cached_offset, pin;
+ Bucket *dst_bucket;
+ uint32_t i, str_offset, arr_offset, data_offset, key_offset;
+ size_t data_size;
+ bool result = true, compact;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ switch (Z_TYPE_P(src)) {
+ case IS_UNDEF:
+ case IS_NULL:
+ case IS_TRUE:
+ case IS_FALSE:
+ Z_LVAL_P(dst) = 0;
+ Z_TYPE_INFO_P(dst) = Z_TYPE_P(src);
+
+ return true;
+ case IS_LONG:
+ ZVAL_LONG(dst, Z_LVAL_P(src));
+
+ return true;
+ case IS_DOUBLE:
+ ZVAL_DOUBLE(dst, Z_DVAL_P(src));
+
+ return true;
+ case IS_STRING:
+ if (!ucache_sgraph_copy_str(ctx, Z_STR_P(src), &str_offset)) {
+ return false;
+ }
+
+ ZVAL_INTERNED_STR(dst, (zend_string *) (void *) (ctx->buf + str_offset));
+
+ if (ucache_sgraph_ptr_in_range(dst, ctx->buf, ctx->size)) {
+ ucache_sgraph_copy_record_fixup(ctx, dst);
+ }
+
+ return true;
+ case IS_ARRAY:
+ src_arr = Z_ARRVAL_P(src);
+ if (ucache_sgraph_arr_is_fresh_empty(src_arr)) {
+ ZVAL_EMPTY_ARRAY(dst);
+
+ return true;
+ }
+
+ if (HT_FLAGS(src_arr) & HASH_FLAG_UNINITIALIZED) {
+ return false;
+ }
+
+ if (GC_REFCOUNT(src_arr) > 1) {
+ cached = zend_hash_index_find(&ctx->direct_arr_dedup, (zend_ulong) (uintptr_t) src_arr);
+ if (cached != NULL) {
+ ZVAL_ARR(dst, (zend_array *) (void *) (ctx->buf + (uint32_t) Z_LVAL_P(cached)));
+ Z_TYPE_FLAGS_P(dst) = 0;
+
+ if (ucache_sgraph_ptr_in_range(dst, ctx->buf, ctx->size)) {
+ ucache_sgraph_copy_record_fixup(ctx, dst);
+ }
+
+ return true;
+ }
+ }
+
+ arr_key = (zend_ulong) (uintptr_t) src_arr;
+ data_size = ucache_sgraph_verbatim_data_size(src_arr);
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(zend_array), &arr_offset) ||
+ !ucache_sgraph_copy_alloc(ctx, data_size, &data_offset)
+ ) {
+ return false;
+ }
+
+ target = (zend_array *) (ctx->buf + arr_offset);
+ compact = ucache_sgraph_verbatim_arr_needs_compaction(src_arr);
+
+ memcpy(target, src_arr, sizeof(zend_array));
+
+ GC_SET_REFCOUNT(target, 2);
+ GC_TYPE_INFO(target) = GC_ARRAY | ((IS_ARRAY_IMMUTABLE | GC_NOT_COLLECTABLE) << GC_FLAGS_SHIFT);
+
+ HT_FLAGS(target) |= HASH_FLAG_STATIC_KEYS;
+ HT_SET_ITERATORS_COUNT(target, 0);
+
+ target->pDestructor = NULL;
+ target->nInternalPointer = 0;
+
+ if (compact) {
+ target->nTableSize = ucache_sgraph_verbatim_compact_table_size(src_arr->nNumOfElements);
+ target->nTableMask = HT_SIZE_TO_MASK(target->nTableSize);
+ target->nNumUsed = src_arr->nNumOfElements;
+
+ HT_SET_DATA_ADDR(target, ctx->buf + data_offset);
+ HT_HASH_RESET(target);
+ } else {
+ memcpy(ctx->buf + data_offset, HT_GET_DATA_ADDR(src_arr), data_size);
+
+ HT_SET_DATA_ADDR(target, ctx->buf + data_offset);
+ }
+
+ ucache_sgraph_copy_record_fixup(ctx, &target->arData);
+
+ if (compact) {
+ result = ucache_sgraph_copy_verbatim_compacted_buckets(ctx, src_arr, target);
+ } else if (HT_IS_PACKED(src_arr)) {
+ dst_packed = target->arPacked;
+ for (i = 0; i < src_arr->nNumUsed; i++) {
+ src_packed = &src_arr->arPacked[i];
+
+ memset(&dst_packed[i], 0, sizeof(zval));
+
+ if (!ucache_sgraph_copy_verbatim_val(ctx, src_packed, &dst_packed[i])) {
+ result = false;
+
+ break;
+ }
+ }
+ } else {
+ src_bucket = src_arr->arData;
+ dst_bucket = target->arData;
+
+ for (i = 0; i < src_arr->nNumUsed; i++) {
+ if (src_bucket[i].key != NULL) {
+ if (!ucache_sgraph_copy_str(
+ ctx,
+ src_bucket[i].key,
+ &key_offset
+ )
+ ) {
+ result = false;
+
+ break;
+ }
+
+ dst_bucket[i].key = (zend_string *) (void *) (ctx->buf + key_offset);
+
+ ucache_sgraph_copy_record_fixup(ctx, &dst_bucket[i].key);
+ } else {
+ dst_bucket[i].key = NULL;
+ }
+
+ if (!ucache_sgraph_copy_verbatim_val(ctx, &src_bucket[i].val, &dst_bucket[i].val)) {
+ result = false;
+
+ break;
+ }
+ }
+ }
+
+ if (!result) {
+ return false;
+ }
+
+ if (GC_REFCOUNT(src_arr) > 1) {
+ ZVAL_LONG(&cached_offset, (zend_long) arr_offset);
+
+ zend_hash_index_add(&ctx->direct_arr_dedup, arr_key, &cached_offset);
+
+ ZVAL_COPY(&pin, src);
+ zend_hash_next_index_insert_new(&ctx->identity_pins, &pin);
+ }
+
+ ZVAL_ARR(dst, (zend_array *) (void *) (ctx->buf + arr_offset));
+ Z_TYPE_FLAGS_P(dst) = 0;
+
+ if (ucache_sgraph_ptr_in_range(dst, ctx->buf, ctx->size)) {
+ ucache_sgraph_copy_record_fixup(ctx, dst);
+ }
+
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool ucache_sgraph_copy_shaped_state_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ zend_object *obj,
+ const HashTable *state_arr,
+ uint8_t node_type,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_shaped_state_obj *gsstate;
+ ucache_sgraph_shape *schema_entry;
+ uint32_t sstate_offset, state_schema_offset, state_vals_offset,
+ count = state_arr->nNumOfElements
+ ;
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ sizeof(ucache_sgraph_shaped_state_obj),
+ &sstate_offset
+ ) ||
+ !ucache_sgraph_copy_state_schema(
+ ctx,
+ obj->ce,
+ state_arr,
+ &state_schema_offset,
+ &schema_entry
+ ) ||
+ !ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) count * sizeof(ucache_sgraph_val),
+ &state_vals_offset
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_sgraph_seen_record_obj_offsets(
+ ctx,
+ obj,
+ sstate_offset,
+ sstate_offset + offsetof(ucache_sgraph_shaped_state_obj, flags)
+ )
+ ) {
+ return false;
+ }
+
+ gsstate = (ucache_sgraph_shaped_state_obj *)
+ (ctx->buf + sstate_offset)
+ ;
+ gsstate->state_schema_offset = state_schema_offset;
+ gsstate->flags = 0;
+ gsstate->state_vals_offset = state_vals_offset;
+ gsstate->state_next_free =
+ ucache_sgraph_shape_next_free_encode(state_arr->nNextFreeElement)
+ ;
+
+ if (!ucache_sgraph_copy_shaped_state_vals(
+ ctx,
+ state_arr,
+ state_vals_offset,
+ count
+ )
+ ) {
+ return false;
+ }
+
+ if (node_type == UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ) {
+ ucache_sgraph_copy_sleep_slot_hints(
+ ctx,
+ schema_entry,
+ obj->ce,
+ state_arr,
+ state_vals_offset
+ );
+ }
+
+ dst->type = node_type;
+ dst->offset = sstate_offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_magic_state_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zend_object *obj,
+ ucache_obj_route route,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_serialized_obj *gser;
+ HashTable *sd_state_ht;
+ zval sd_state_zv;
+ uint32_t sd_offset, sd_class_offset;
+ bool result;
+
+ ctx->has_userland_restore_obj = true;
+
+ if (ucache_sgraph_copy_emit_obj_ref_if_seen(ctx, obj, dst)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_route_state(src, ctx->state_memo, route, &sd_state_ht)) {
+ return false;
+ }
+
+ if (ucache_sgraph_state_arr_fits_schema_memo(ctx->state_memo, sd_state_ht)) {
+ result = ucache_sgraph_copy_shaped_state_obj(
+ ctx,
+ obj,
+ sd_state_ht,
+ UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ,
+ dst
+ );
+
+ goto done;
+ }
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ sizeof(ucache_sgraph_serialized_obj), &sd_offset
+ ) ||
+ !ucache_sgraph_copy_str(ctx, obj->ce->name, &sd_class_offset)
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ if (!ucache_sgraph_seen_record_obj_offsets(
+ ctx,
+ obj,
+ sd_offset,
+ sd_offset + offsetof(ucache_sgraph_serialized_obj, flags)
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ gser = (ucache_sgraph_serialized_obj *) (ctx->buf + sd_offset);
+ gser->class_name_offset = sd_class_offset;
+ gser->flags = 0;
+
+ ZVAL_ARR(&sd_state_zv, sd_state_ht);
+ if (!ucache_sgraph_copy_val(ctx, &sd_state_zv, &gser->state)) {
+ result = false;
+
+ goto done;
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_SERIALIZED_OBJ;
+ dst->offset = sd_offset;
+ result = true;
+
+done:
+ return result;
+}
+
+static bool ucache_sgraph_copy_sleep_state_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zend_object *obj,
+ ucache_obj_route route,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_obj *gobj;
+ ucache_sgraph_prop *gprops;
+ zend_ulong num_key;
+ zend_string *prop_name, *resolved_name;
+ zval *prop_val;
+ HashTable *props;
+ uint32_t obj_offset, class_name_offset, props_offset,
+ prop_idx, prop_count
+ ;
+ uint16_t slot_hint;
+ bool result;
+
+ if (route != UCACHE_OBJ_ROUTE_SERIALIZE_PROPS ||
+ zend_hash_find_known_hash(
+ &obj->ce->function_table,
+ ZSTR_KNOWN(ZEND_STR_WAKEUP)
+ ) != NULL
+ ) {
+ ctx->has_userland_restore_obj = true;
+ }
+
+ if (ucache_sgraph_copy_emit_obj_ref_if_seen(ctx, obj, dst)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_route_state(src, ctx->state_memo, route, &props)) {
+ return false;
+ }
+
+ if (ucache_sgraph_state_arr_fits_schema_memo(ctx->state_memo, props)) {
+ result = ucache_sgraph_copy_shaped_state_obj(
+ ctx,
+ obj,
+ props,
+ UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ,
+ dst
+ );
+
+ goto done;
+ }
+
+ prop_count = zend_hash_num_elements(props);
+ props_offset = 0;
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(*gobj), &obj_offset) ||
+ !ucache_sgraph_copy_str(ctx, obj->ce->name, &class_name_offset) ||
+ (prop_count != 0 &&
+ !ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) prop_count * sizeof(ucache_sgraph_prop),
+ &props_offset
+ )
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ if (!ucache_sgraph_seen_record_obj_offsets(
+ ctx,
+ obj,
+ obj_offset,
+ obj_offset + offsetof(ucache_sgraph_obj, flags)
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ gobj = (ucache_sgraph_obj *) (ctx->buf + obj_offset);
+ gobj->class_name_offset = class_name_offset;
+ gobj->prop_count = prop_count;
+ gobj->props_offset = props_offset;
+ gobj->flags = 0;
+
+ if (prop_count == 0) {
+ dst->type = UCACHE_SGRAPH_VAL_SLEEP_OBJ;
+ dst->offset = obj_offset;
+ result = true;
+
+ goto done;
+ }
+
+ gprops = (ucache_sgraph_prop *) (ctx->buf + props_offset);
+ prop_idx = 0;
+ result = true;
+
+ ZEND_HASH_FOREACH_KEY_VAL(props, num_key, prop_name, prop_val) {
+ if (prop_idx == prop_count) {
+ result = false;
+
+ break;
+ }
+
+ resolved_name = prop_name != NULL
+ ? zend_string_copy(prop_name)
+ : zend_long_to_str((zend_long) num_key)
+ ;
+
+ gprops[prop_idx].name_offset = 0;
+ gprops[prop_idx].val.type = UCACHE_SGRAPH_VAL_UNDEF;
+
+ slot_hint = ucache_sgraph_sleep_slot_hint(obj->ce, resolved_name, prop_idx);
+
+ if (!ucache_sgraph_copy_str(
+ ctx,
+ resolved_name,
+ &gprops[prop_idx].name_offset
+ ) ||
+ !ucache_sgraph_copy_val(
+ ctx,
+ prop_val,
+ &gprops[prop_idx].val
+ )
+ ) {
+ zend_string_release(resolved_name);
+ result = false;
+
+ break;
+ }
+
+ gprops[prop_idx].val.sleep_slot_plus_one = slot_hint;
+
+ zend_string_release(resolved_name);
+
+ ++prop_idx;
+ } ZEND_HASH_FOREACH_END();
+
+ if (result && prop_idx == prop_count) {
+ dst->type = UCACHE_SGRAPH_VAL_SLEEP_OBJ;
+ dst->offset = obj_offset;
+ } else {
+ result = false;
+ }
+
+done:
+ return result;
+}
+
+static bool ucache_sgraph_copy_declared_props(
+ ucache_sgraph_copy_ctx *ctx,
+ zend_object *obj,
+ const php_ucache_safe_direct_handlers *handlers,
+ uint32_t *props_offset,
+ uint32_t *prop_count)
+{
+ ucache_sgraph_prop *gprops;
+ zend_string *prop_name;
+ zval *prop_val;
+ uint32_t slot, prop_idx = 0;
+ bool result = true;
+
+ *prop_count = ucache_sgraph_declared_prop_count(obj, handlers);
+ *props_offset = 0;
+
+ if (*prop_count == 0) {
+ return true;
+ }
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) *prop_count * sizeof(ucache_sgraph_prop),
+ props_offset
+ )
+ ) {
+ return false;
+ }
+
+ gprops = (ucache_sgraph_prop *) (ctx->buf + *props_offset);
+
+ GC_ADDREF(obj);
+
+ for (slot = 0; slot < (uint32_t) obj->ce->default_properties_count; slot++) {
+ prop_val = ucache_sgraph_declared_prop(obj, handlers, slot, &prop_name);
+ if (prop_val == NULL) {
+ continue;
+ }
+
+ if (prop_idx == *prop_count) {
+ result = false;
+
+ break;
+ }
+
+ memset(&gprops[prop_idx], 0, sizeof(gprops[prop_idx]));
+
+ if (!ucache_sgraph_copy_str(ctx, prop_name, &gprops[prop_idx].name_offset) ||
+ !ucache_sgraph_copy_val(ctx, prop_val, &gprops[prop_idx].val)
+ ) {
+ result = false;
+
+ break;
+ }
+
+ ++prop_idx;
+ }
+
+ OBJ_RELEASE(obj);
+
+ return result && prop_idx == *prop_count;
+}
+
+static bool ucache_sgraph_copy_safe_direct_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zend_object *obj,
+ const php_ucache_safe_direct_handlers *handlers,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_safe_direct_obj *gsd;
+ ucache_sgraph_prop *gprops;
+ zend_string *prop_name;
+ zval *prop_val, *src_val, sd_borrowed_state, sd_state;
+ HashTable *props;
+ uint32_t str_offset, prop_idx, prop_count,
+ sd_offset, sd_class_offset,
+ sd_props_offset
+ ;
+ bool result;
+
+ if (handlers->prefer_req_local_proto) {
+ ctx->prefers_proto = true;
+ }
+
+ if (ucache_sgraph_copy_emit_obj_ref_if_seen(ctx, obj, dst)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_safe_direct_state(
+ src,
+ handlers,
+ ctx->state_memo,
+ &sd_borrowed_state,
+ &sd_state
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ sizeof(ucache_sgraph_safe_direct_obj),
+ &sd_offset
+ ) ||
+ !ucache_sgraph_copy_str(
+ ctx,
+ obj->ce->name,
+ &sd_class_offset
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ if (!ucache_sgraph_seen_record_obj_offsets(
+ ctx,
+ obj,
+ sd_offset,
+ sd_offset + offsetof(ucache_sgraph_safe_direct_obj, flags)
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ gsd = (ucache_sgraph_safe_direct_obj *) (ctx->buf + sd_offset);
+ gsd->class_name_offset = sd_class_offset;
+ gsd->flags = 0;
+
+ if (!ucache_sgraph_copy_val(ctx, &sd_borrowed_state, &gsd->state)) {
+ result = false;
+
+ goto done;
+ }
+
+ if (ucache_sgraph_props_unbuilt(obj)) {
+ result = ucache_sgraph_copy_declared_props(ctx, obj, handlers, &sd_props_offset, &prop_count);
+ if (result) {
+ gsd->prop_count = prop_count;
+ gsd->props_offset = sd_props_offset;
+ dst->type = UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ;
+ dst->offset = sd_offset;
+ }
+
+ goto done;
+ }
+
+ props = zend_std_get_properties(obj);
+ prop_count = 0;
+ result = true;
+ if (props != NULL) {
+ ZEND_HASH_FOREACH_STR_KEY(props, prop_name) {
+ if (prop_name == NULL) {
+ result = false;
+
+ break;
+ }
+
+ if (!ucache_sgraph_safe_direct_prop_is_internal(handlers, obj, prop_name)) {
+ prop_count++;
+ }
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ if (!result) {
+ goto done;
+ }
+
+ gsd->prop_count = prop_count;
+ gsd->props_offset = 0;
+
+ if (prop_count != 0) {
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) prop_count * sizeof(ucache_sgraph_prop),
+ &sd_props_offset
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ gsd->props_offset = sd_props_offset;
+ gprops = (ucache_sgraph_prop *) (ctx->buf + sd_props_offset);
+ prop_idx = 0;
+
+ ucache_sgraph_hold_props_across_hooks(obj, props);
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(props, prop_name, prop_val) {
+ if (prop_name == NULL) {
+ result = false;
+
+ break;
+ }
+
+ src_val = Z_TYPE_P(prop_val) == IS_INDIRECT
+ ? Z_INDIRECT_P(prop_val)
+ : prop_val
+ ;
+
+ if (ucache_sgraph_safe_direct_prop_is_internal(handlers, obj, prop_name)) {
+ continue;
+ }
+
+ if (prop_idx == prop_count) {
+ result = false;
+
+ break;
+ }
+
+ memset(&gprops[prop_idx], 0, sizeof(gprops[prop_idx]));
+
+ if (!ucache_sgraph_copy_str(ctx, prop_name, &str_offset)) {
+ result = false;
+
+ break;
+ }
+
+ gprops[prop_idx].name_offset = str_offset;
+
+ if (!ucache_sgraph_copy_val(
+ ctx,
+ src_val,
+ &gprops[prop_idx].val
+ )
+ ) {
+ result = false;
+
+ break;
+ }
+
+ ++prop_idx;
+ } ZEND_HASH_FOREACH_END();
+
+ ucache_sgraph_release_held_props(obj, props);
+
+ if (!result || prop_idx != prop_count) {
+ result = false;
+
+ goto done;
+ }
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ;
+ dst->offset = sd_offset;
+
+done:
+ if (!Z_ISUNDEF(sd_state)) {
+ zval_ptr_dtor(&sd_state);
+ }
+
+ return result;
+}
+
+static bool ucache_sgraph_copy_serdes_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zend_object *obj,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_serdes_obj *gserdes;
+ zend_string *serdes_blob;
+ uint32_t serdes_offset;
+
+ ctx->has_userland_restore_obj = true;
+
+ if (ucache_sgraph_copy_emit_obj_ref_if_seen(ctx, obj, dst)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_get_serdes_blob(src, ctx->state_memo, &serdes_blob)) {
+ return false;
+ }
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ sizeof(ucache_sgraph_serdes_obj) + ZSTR_LEN(serdes_blob),
+ &serdes_offset
+ )
+ ) {
+ return false;
+ }
+
+ if (!ucache_sgraph_seen_record_obj_offsets(
+ ctx,
+ obj,
+ serdes_offset,
+ serdes_offset + offsetof(ucache_sgraph_serdes_obj, flags)
+ )
+ ) {
+ return false;
+ }
+
+ gserdes = (ucache_sgraph_serdes_obj *) (ctx->buf + serdes_offset);
+ gserdes->blob_len = (uint32_t) ZSTR_LEN(serdes_blob);
+ gserdes->flags = 0;
+
+ memcpy(gserdes + 1, ZSTR_VAL(serdes_blob), ZSTR_LEN(serdes_blob));
+
+ dst->type = UCACHE_SGRAPH_VAL_SERDES_OBJ;
+ dst->offset = serdes_offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_plain_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ zend_object *obj,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_obj *gobj;
+ ucache_sgraph_prop *gprops;
+ zend_string *prop_name;
+ zval *prop_val, *src_val;
+ HashTable *props;
+ uint32_t obj_offset, class_name_offset, props_offset,
+ prop_idx, prop_count
+ ;
+ bool result;
+
+ ctx->prefers_proto = true;
+
+ if (ucache_sgraph_copy_emit_obj_ref_if_seen(ctx, obj, dst)) {
+ return true;
+ }
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ sizeof(*gobj),
+ &obj_offset
+ ) ||
+ !ucache_sgraph_seen_record_obj_offsets(
+ ctx,
+ obj,
+ obj_offset,
+ obj_offset + offsetof(ucache_sgraph_obj, flags)
+ ) ||
+ !ucache_sgraph_copy_str(
+ ctx,
+ obj->ce->name,
+ &class_name_offset
+ )
+ ) {
+ return false;
+ }
+
+ if (ucache_sgraph_plain_props_unbuilt(obj)) {
+ gobj = (ucache_sgraph_obj *) (ctx->buf + obj_offset);
+ gobj->class_name_offset = class_name_offset;
+ gobj->prop_count = 0;
+ gobj->props_offset = 0;
+ gobj->flags = 0;
+
+ if (!ucache_sgraph_copy_declared_props(ctx, obj, NULL, &props_offset, &prop_count)) {
+ return false;
+ }
+
+ gobj->prop_count = prop_count;
+ gobj->props_offset = props_offset;
+ dst->type = UCACHE_SGRAPH_VAL_OBJ;
+ dst->offset = obj_offset;
+
+ return true;
+ }
+
+ props = zend_get_properties_for((zval *) src, ZEND_PROP_PURPOSE_SERIALIZE);
+ prop_count = props != NULL ? props->nNumOfElements : 0;
+ props_offset = 0;
+
+ if (prop_count != 0 &&
+ !ucache_sgraph_copy_alloc(
+ ctx,
+ ((size_t) prop_count * sizeof(ucache_sgraph_prop)),
+ &props_offset
+ )
+ ) {
+ if (props != NULL) {
+ zend_release_properties(props);
+ }
+
+ return false;
+ }
+
+ gobj = (ucache_sgraph_obj *) (ctx->buf + obj_offset);
+ gobj->class_name_offset = class_name_offset;
+ gobj->prop_count = prop_count;
+ gobj->props_offset = props_offset;
+ gobj->flags = 0;
+
+ if (prop_count == 0) {
+ if (props != NULL) {
+ zend_release_properties(props);
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_OBJ;
+ dst->offset = obj_offset;
+
+ return true;
+ }
+
+ gprops = (ucache_sgraph_prop *) (ctx->buf + props_offset);
+ prop_idx = 0;
+ result = true;
+
+ GC_ADDREF(obj);
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(props, prop_name, prop_val) {
+ if (prop_name == NULL || prop_idx == prop_count) {
+ result = false;
+
+ break;
+ }
+
+ gprops[prop_idx].name_offset = 0;
+ gprops[prop_idx].val.type = UCACHE_SGRAPH_VAL_UNDEF;
+
+ src_val = Z_TYPE_P(prop_val) == IS_INDIRECT
+ ? Z_INDIRECT_P(prop_val)
+ : prop_val
+ ;
+
+ if (!ucache_sgraph_copy_str(ctx, prop_name, &gprops[prop_idx].name_offset) ||
+ !ucache_sgraph_copy_val(
+ ctx,
+ src_val,
+ &gprops[prop_idx].val
+ )
+ ) {
+ result = false;
+
+ break;
+ }
+
+ ++prop_idx;
+ } ZEND_HASH_FOREACH_END();
+
+ zend_release_properties(props);
+ OBJ_RELEASE(obj);
+
+ if (!result || prop_idx != prop_count) {
+ return false;
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_OBJ;
+ dst->offset = obj_offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_enum(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_enum *genum;
+ zend_class_entry *ce;
+ zend_string *case_name;
+ uint32_t enum_offset, enum_class_offset, enum_case_offset;
+ void *seen_offset;
+
+ seen_offset = zend_hash_index_find_ptr(&ctx->enum_dedup, (zend_ulong) (uintptr_t) Z_OBJ_P(src));
+ if (seen_offset != NULL) {
+ dst->type = UCACHE_SGRAPH_VAL_ENUM;
+ dst->offset = (uint32_t) (uintptr_t) seen_offset;
+
+ return true;
+ }
+
+ ce = Z_OBJCE_P(src);
+ case_name = Z_STR_P(zend_enum_fetch_case_name(Z_OBJ_P(src)));
+
+ if (!ucache_sgraph_copy_alloc(ctx,
+ sizeof(ucache_sgraph_enum), &enum_offset) ||
+ !ucache_sgraph_copy_str(ctx, ce->name, &enum_class_offset) ||
+ !ucache_sgraph_copy_str(ctx, case_name, &enum_case_offset)
+ ) {
+ return false;
+ }
+
+ genum = (ucache_sgraph_enum *) (ctx->buf + enum_offset);
+ genum->class_name_offset = enum_class_offset;
+ genum->case_name_offset = enum_case_offset;
+
+ zend_hash_index_add_ptr(
+ &ctx->enum_dedup,
+ (zend_ulong) (uintptr_t) Z_OBJ_P(src),
+ (void *) (uintptr_t) enum_offset
+ );
+
+ dst->type = UCACHE_SGRAPH_VAL_ENUM;
+ dst->offset = enum_offset;
+
+ return true;
+}
+
+static void ucache_sgraph_copy_leave_ref_held_once(
+ ucache_sgraph_copy_ctx *ctx,
+ zend_reference *ref,
+ const ucache_sgraph_ref *gref)
+{
+ zval pin;
+
+ if (!(gref->flags & UCACHE_SGRAPH_OBJ_FLAG_SHARED)) {
+ zend_hash_index_del(&ctx->seen_refs, (zend_ulong) (uintptr_t) ref);
+
+ if (GC_REFCOUNT(ref) > 1) {
+ GC_DTOR(ref);
+
+ return;
+ }
+ }
+
+ ZVAL_REF(&pin, ref);
+ zend_hash_next_index_insert_new(&ctx->identity_pins, &pin);
+}
+
+static bool ucache_sgraph_copy_ref(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_ref *gref;
+ zend_reference *ref;
+ zval pin;
+ uint32_t shared_offset, ref_offset;
+ bool held_once, result;
+ void *seen_offset;
+
+ ref = Z_REF_P(src);
+ seen_offset = zend_hash_index_find_ptr(&ctx->seen_refs, (zend_ulong) (uintptr_t) ref);
+
+ if (seen_offset != NULL) {
+ shared_offset = (uint32_t) (uintptr_t) seen_offset;
+ ((ucache_sgraph_ref *) (ctx->buf + shared_offset))->flags |=
+ UCACHE_SGRAPH_OBJ_FLAG_SHARED
+ ;
+
+ dst->type = UCACHE_SGRAPH_VAL_REF_REF;
+ dst->offset = shared_offset;
+
+ ctx->has_shared_identity = true;
+
+ return true;
+ }
+
+ held_once = ucache_sgraph_ref_is_held_once(ref);
+
+ if (!ucache_sgraph_copy_alloc(
+ ctx,
+ sizeof(ucache_sgraph_ref),
+ &ref_offset
+ ) ||
+ zend_hash_index_add_ptr(
+ &ctx->seen_refs,
+ (zend_ulong) (uintptr_t) ref,
+ (void *) (uintptr_t) ref_offset
+ ) == NULL
+ ) {
+ return false;
+ }
+
+ GC_ADDREF(ref);
+
+ if (!held_once) {
+ ZVAL_REF(&pin, ref);
+ zend_hash_next_index_insert_new(&ctx->identity_pins, &pin);
+ }
+
+ gref = (ucache_sgraph_ref *) (ctx->buf + ref_offset);
+ gref->flags = 0;
+
+ result = ucache_sgraph_copy_val(ctx, &ref->val, &gref->inner);
+
+ if (held_once) {
+ ucache_sgraph_copy_leave_ref_held_once(ctx, ref, gref);
+ }
+
+ if (!result) {
+ return false;
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_REF;
+ dst->offset = ref_offset;
+
+ ctx->has_shared_identity = true;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_arr(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ ucache_sgraph_val *dst)
+{
+ ucache_sgraph_arr *garr;
+ ucache_sgraph_arr_elem *gelems, *gelem;
+ ucache_sgraph_shaped_arr *gsarr;
+ ucache_sgraph_val *gvals;
+ zend_ulong h;
+ zend_string *key;
+ zval *elem, *verdict, arr_val;
+ HashTable *arr;
+ uint32_t count, elem_idx,
+ arr_offset, elems_offset, key_offset,
+ shape_offset, vals_offset,
+ wide_next_free_offset
+ ;
+ int64_t wide_next_free;
+ bool result, verbatim, packed;
+
+ arr = Z_ARRVAL_P(src);
+ result = true;
+
+ if (arr->nNumOfElements == 0) {
+ if (ucache_sgraph_arr_is_fresh_empty(arr)) {
+ dst->type = UCACHE_SGRAPH_VAL_ARR;
+ dst->offset = 0;
+
+ return true;
+ }
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(*garr), &arr_offset)) {
+ return false;
+ }
+
+ garr = (ucache_sgraph_arr *) (ctx->buf + arr_offset);
+ garr->count = 0;
+ garr->elems_offset = 0;
+ garr->flags = 0;
+
+ if (UNEXPECTED(ucache_sgraph_next_free_is_wide(arr->nNextFreeElement))) {
+ wide_next_free = (int64_t) arr->nNextFreeElement;
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(wide_next_free), &wide_next_free_offset)) {
+ return false;
+ }
+
+ memcpy(ctx->buf + wide_next_free_offset, &wide_next_free, sizeof(wide_next_free));
+
+ garr->next_free = wide_next_free_offset;
+ garr->flags |= UCACHE_SGRAPH_ARR_FLAG_WIDE_NEXT_FREE;
+ } else {
+ garr->next_free = (uint32_t) arr->nNextFreeElement;
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_DYNAMIC_ARR;
+ dst->offset = arr_offset;
+
+ return true;
+ }
+
+ if (ctx->verbatim_arrs_allowed) {
+ if (GC_FLAGS(arr) & IS_ARRAY_IMMUTABLE) {
+ verbatim = true;
+ } else {
+ verdict = ctx->verbatim_verdicts != NULL
+ ? zend_hash_index_find(ctx->verbatim_verdicts, (zend_ulong) (uintptr_t) arr)
+ : NULL
+ ;
+ if (verdict != NULL) {
+ verbatim = Z_TYPE_P(verdict) == IS_TRUE;
+ } else {
+ verbatim = ucache_sgraph_can_copy_verbatim_arr(&ctx->direct_verdicts, src);
+ }
+ }
+
+ if (verbatim) {
+ if (!ucache_sgraph_copy_verbatim_val(ctx, src, &arr_val)) {
+ return false;
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_ARR;
+ dst->offset = (uint32_t) ((uint8_t *) Z_ARRVAL(arr_val) - ctx->buf);
+
+ ctx->has_verbatim_arr = true;
+
+ return true;
+ }
+ }
+
+ count = arr->nNumOfElements;
+ elem_idx = 0;
+
+ GC_TRY_ADDREF(arr);
+
+ if (ucache_sgraph_arr_has_shape(arr)) {
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(*gsarr), &arr_offset) ||
+ !ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) count * sizeof(*gvals),
+ &vals_offset
+ ) ||
+ !ucache_sgraph_copy_arr_shape(ctx, arr, &shape_offset)
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ gsarr = (ucache_sgraph_shaped_arr *) (ctx->buf + arr_offset);
+ gsarr->count = count;
+ gsarr->next_free = ucache_sgraph_shape_next_free_encode(arr->nNextFreeElement);
+ gsarr->shape_offset = shape_offset;
+
+ ZEND_ASSERT(vals_offset == ucache_sgraph_shaped_arr_vals_offset(arr_offset));
+
+ gvals = (ucache_sgraph_val *) (ctx->buf + vals_offset);
+
+ ZEND_HASH_FOREACH_VAL(arr, elem) {
+ if (elem_idx == count || !ucache_sgraph_copy_val(ctx, elem, &gvals[elem_idx])) {
+ result = false;
+
+ break;
+ }
+
+ ++elem_idx;
+ } ZEND_HASH_FOREACH_END();
+
+ if (result && elem_idx == count) {
+ dst->type = UCACHE_SGRAPH_VAL_SHAPED_ARR;
+ dst->offset = arr_offset;
+ } else {
+ result = false;
+ }
+
+ goto done;
+ }
+
+ packed = ctx->packed_vals_allowed && HT_IS_PACKED(arr) && HT_IS_WITHOUT_HOLES(arr);
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(*garr), &arr_offset) ||
+ !ucache_sgraph_copy_alloc(
+ ctx,
+ (size_t) count * (packed ? sizeof(*gvals) : sizeof(*gelems)),
+ &elems_offset
+ )
+ ) {
+ result = false;
+
+ goto done;
+ }
+
+ garr = (ucache_sgraph_arr *) (ctx->buf + arr_offset);
+ garr->count = count;
+ garr->elems_offset = elems_offset;
+ garr->flags = (HT_IS_PACKED(arr) && HT_IS_WITHOUT_HOLES(arr))
+ ? UCACHE_SGRAPH_ARR_FLAG_PACKED
+ : 0
+ ;
+
+ if (packed) {
+ garr->flags |= UCACHE_SGRAPH_ARR_FLAG_PACKED_VALS;
+ }
+
+ if (UNEXPECTED(ucache_sgraph_next_free_is_wide(arr->nNextFreeElement))) {
+ wide_next_free = (int64_t) arr->nNextFreeElement;
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(wide_next_free), &wide_next_free_offset)) {
+ result = false;
+
+ goto done;
+ }
+
+ memcpy(ctx->buf + wide_next_free_offset, &wide_next_free, sizeof(wide_next_free));
+
+ garr->next_free = wide_next_free_offset;
+ garr->flags |= UCACHE_SGRAPH_ARR_FLAG_WIDE_NEXT_FREE;
+ } else {
+ garr->next_free = (uint32_t) arr->nNextFreeElement;
+ }
+
+ if (packed) {
+ gvals = (ucache_sgraph_val *) (ctx->buf + elems_offset);
+
+ ZEND_HASH_PACKED_FOREACH_VAL(arr, elem) {
+ if (elem_idx == count || !ucache_sgraph_copy_val(ctx, elem, &gvals[elem_idx])) {
+ result = false;
+
+ break;
+ }
+
+ ++elem_idx;
+ } ZEND_HASH_FOREACH_END();
+ } else {
+ gelems = (ucache_sgraph_arr_elem *) (ctx->buf + elems_offset);
+
+ ZEND_HASH_FOREACH_KEY_VAL(arr, h, key, elem) {
+ if (elem_idx == count) {
+ result = false;
+
+ break;
+ }
+
+ gelem = &gelems[elem_idx];
+ memset(gelem, 0, sizeof(*gelem));
+
+ if (key != NULL) {
+ if (!ucache_sgraph_copy_str(ctx, key, &key_offset)) {
+ result = false;
+
+ break;
+ }
+
+ gelem->key = key_offset;
+ } else {
+ gelem->key = (uint32_t) h;
+ gelem->h_hi = (uint32_t) ((uint64_t) h >> 32);
+ }
+
+ if (!ucache_sgraph_copy_val(ctx, elem, &gelem->val)) {
+ result = false;
+
+ break;
+ }
+
+ if (key != NULL) {
+ gelem->val.flags = UCACHE_SGRAPH_ELEM_STR_KEY;
+ }
+
+ ++elem_idx;
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ if (result && elem_idx == count) {
+ dst->type = UCACHE_SGRAPH_VAL_DYNAMIC_ARR;
+ dst->offset = arr_offset;
+ } else {
+ result = false;
+ }
+
+done:
+ ucache_sgraph_release_traversed_arr(arr);
+
+ return result;
+}
+
+static bool ucache_sgraph_copy_obj(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ ucache_sgraph_val *dst)
+{
+ ucache_obj_route_info route_info;
+ zend_class_entry *ce;
+ zend_object *obj;
+
+ ctx->has_obj = true;
+ ce = Z_OBJCE_P(src);
+
+ if (ce->ce_flags & ZEND_ACC_ENUM) {
+ return ucache_sgraph_copy_enum(ctx, src, dst);
+ }
+
+ obj = Z_OBJ_P(src);
+
+ if (!zend_lazy_object_initialized(obj)) {
+ if (!EG(exception)) {
+ zend_type_error(UCACHE_MSG_LAZY_OBJ_UNSTORABLE);
+ }
+
+ return false;
+ }
+
+ route_info = ucache_sgraph_classify_obj_route(obj->ce);
+
+ switch (route_info.route) {
+ case UCACHE_OBJ_ROUTE_SAFE_DIRECT:
+ return ucache_sgraph_copy_safe_direct_obj(ctx, src, obj, route_info.sd_handlers, dst);
+ case UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE:
+ return ucache_sgraph_copy_magic_state_obj(
+ ctx, src, obj, UCACHE_OBJ_ROUTE_MAGIC_SERIALIZE, dst
+ );
+ case UCACHE_OBJ_ROUTE_SERIALIZE_PROPS:
+ return ucache_sgraph_copy_sleep_state_obj(
+ ctx, src, obj, UCACHE_OBJ_ROUTE_SERIALIZE_PROPS, dst
+ );
+ case UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE:
+ return ucache_sgraph_copy_magic_state_obj(
+ ctx, src, obj, UCACHE_OBJ_ROUTE_MAGIC_UNSERIALIZE, dst
+ );
+ case UCACHE_OBJ_ROUTE_SLEEP:
+ return ucache_sgraph_copy_sleep_state_obj(
+ ctx, src, obj, UCACHE_OBJ_ROUTE_SLEEP, dst
+ );
+ case UCACHE_OBJ_ROUTE_WAKEUP:
+ return ucache_sgraph_copy_sleep_state_obj(
+ ctx, src, obj, UCACHE_OBJ_ROUTE_WAKEUP, dst
+ );
+ case UCACHE_OBJ_ROUTE_SERDES:
+ return ucache_sgraph_copy_serdes_obj(ctx, src, obj, dst);
+ case UCACHE_OBJ_ROUTE_PLAIN:
+ return ucache_sgraph_copy_plain_obj(ctx, src, obj, dst);
+ case UCACHE_OBJ_ROUTE_UNSTORABLE:
+ ucache_throw_unstorable_obj(obj->ce);
+
+ return false;
+ }
+
+ return false;
+}
+
+static bool ucache_sgraph_copy_wide_scalar(
+ ucache_sgraph_copy_ctx *ctx,
+ uint32_t type,
+ const void *bytes,
+ ucache_sgraph_val *dst)
+{
+ uint32_t offset;
+
+ if (!ucache_sgraph_copy_alloc(ctx, sizeof(uint64_t), &offset)) {
+ return false;
+ }
+
+ memcpy(ctx->buf + offset, bytes, sizeof(uint64_t));
+
+ dst->type = type;
+ dst->offset = offset;
+
+ return true;
+}
+
+static bool ucache_sgraph_copy_val(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *src,
+ ucache_sgraph_val *dst)
+{
+ uint32_t str_offset;
+ int64_t wide;
+ double dval;
+
+ if (ucache_stack_overflowed()) {
+ return false;
+ }
+
+ memset(dst, 0, sizeof(*dst));
+
+ switch (Z_TYPE_P(src)) {
+ case IS_UNDEF:
+ dst->type = UCACHE_SGRAPH_VAL_UNDEF;
+
+ return true;
+ case IS_NULL:
+ dst->type = UCACHE_SGRAPH_VAL_NULL;
+
+ return true;
+ case IS_TRUE:
+ dst->type = UCACHE_SGRAPH_VAL_TRUE;
+
+ return true;
+ case IS_FALSE:
+ dst->type = UCACHE_SGRAPH_VAL_FALSE;
+
+ return true;
+ case IS_LONG:
+ if (ucache_sgraph_long_is_inline(Z_LVAL_P(src))) {
+ dst->type = UCACHE_SGRAPH_VAL_LONG;
+ dst->long_val = (int32_t) Z_LVAL_P(src);
+
+ return true;
+ }
+
+ wide = (int64_t) Z_LVAL_P(src);
+
+ return ucache_sgraph_copy_wide_scalar(
+ ctx, UCACHE_SGRAPH_VAL_LONG_WIDE, &wide, dst
+ );
+ case IS_DOUBLE:
+ dval = Z_DVAL_P(src);
+
+ return ucache_sgraph_copy_wide_scalar(
+ ctx, UCACHE_SGRAPH_VAL_DOUBLE, &dval, dst
+ );
+ case IS_STRING:
+ if (!ucache_sgraph_copy_str(ctx, Z_STR_P(src), &str_offset)) {
+ return false;
+ }
+
+ dst->type = UCACHE_SGRAPH_VAL_STR;
+ dst->offset = str_offset;
+
+ return true;
+ case IS_ARRAY:
+ return ucache_sgraph_copy_arr(ctx, src, dst);
+ case IS_RESOURCE:
+ ucache_throw_unstorable_res();
+
+ return false;
+ case IS_OBJECT:
+ return ucache_sgraph_copy_obj(ctx, src, dst);
+ case IS_REFERENCE:
+ return ucache_sgraph_copy_ref(ctx, src, dst);
+ default:
+ return false;
+ }
+}
+
+static bool ucache_sgraph_copy_root(
+ ucache_sgraph_copy_ctx *ctx,
+ const zval *val,
+ ucache_sgraph_val *root_val)
+{
+ uint32_t hdr_offset;
+
+ if (!ucache_sgraph_copy_alloc(ctx, UCACHE_SGRAPH_HDR_SIZE(ctx->pin_word_count), &hdr_offset) ||
+ hdr_offset != 0 ||
+ (Z_TYPE_P(val) != IS_OBJECT && Z_TYPE_P(val) != IS_ARRAY && Z_TYPE_P(val) != IS_STRING) ||
+ !ucache_sgraph_copy_val(ctx, val, root_val) ||
+ (root_val->offset != 0 && root_val->offset != UCACHE_SGRAPH_ROOT_OFFSET(ctx->pin_word_count))
+ ) {
+ return false;
+ }
+
+ switch (root_val->type) {
+ case UCACHE_SGRAPH_VAL_OBJ:
+ case UCACHE_SGRAPH_VAL_SAFE_DIRECT_OBJ:
+ case UCACHE_SGRAPH_VAL_SERIALIZED_OBJ:
+ case UCACHE_SGRAPH_VAL_SERIALIZED_SHAPED_OBJ:
+ case UCACHE_SGRAPH_VAL_SERDES_OBJ:
+ case UCACHE_SGRAPH_VAL_SLEEP_OBJ:
+ case UCACHE_SGRAPH_VAL_SLEEP_SHAPED_OBJ:
+ case UCACHE_SGRAPH_VAL_ENUM:
+ return Z_TYPE_P(val) == IS_OBJECT;
+ case UCACHE_SGRAPH_VAL_ARR:
+ case UCACHE_SGRAPH_VAL_DYNAMIC_ARR:
+ case UCACHE_SGRAPH_VAL_SHAPED_ARR:
+ return Z_TYPE_P(val) == IS_ARRAY;
+ case UCACHE_SGRAPH_VAL_STR:
+ return Z_TYPE_P(val) == IS_STRING;
+ default:
+ return false;
+ }
+}
+
+static void ucache_sgraph_pin_owners_init(ucache_sgraph_hdr *hdr)
+{
+ uint32_t w;
+
+ for (w = 0; w < MIN(hdr->pin_word_count, UCACHE_GRAPH_PIN_WORDS_MAX); w++) {
+ atomic_init(&hdr->pin_owners[w], 0);
+ }
+}
+
+ZEND_COLD void ucache_throw_unstorable_res(void)
+{
+ if (!EG(exception)) {
+ zend_type_error(UCACHE_MSG_RESOURCE_UNSTORABLE);
+ }
+}
+
+ZEND_COLD void ucache_throw_unstorable_obj(const zend_class_entry *ce)
+{
+ if (!EG(exception)) {
+ zend_type_error(UCACHE_MSG_OBJ_UNSTORABLE, ZSTR_VAL(ce->name));
+ }
+}
+
+void ucache_sgraph_obj_route_memo_release(void)
+{
+ if (UC_G(obj_route_memo) != NULL) {
+ zend_hash_destroy(UC_G(obj_route_memo));
+
+ efree(UC_G(obj_route_memo));
+
+ UC_G(obj_route_memo) = NULL;
+ }
+}
+
+void ucache_sgraph_calc_verbatim_root(
+ const zval *val,
+ HashTable *verbatim_verdicts,
+ size_t *buf_len)
+{
+ ucache_sgraph_calc_ctx calc_ctx;
+ zval verdict_zv;
+ bool completed, decided;
+
+ if (!ucache_sgraph_can_use_verbatim_arrs() ||
+ Z_ARRVAL_P(val)->nNumOfElements == 0
+ ) {
+ return;
+ }
+
+ ucache_sgraph_calc_verbatim_init(&calc_ctx);
+
+ completed = ucache_sgraph_calc_reserve(
+ &calc_ctx,
+ UCACHE_SGRAPH_HDR_SIZE(calc_ctx.pin_word_count)
+ ) &&
+ ucache_sgraph_calc_verbatim_val(&calc_ctx, val)
+ ;
+
+ if (completed && calc_ctx.size <= UINT32_MAX - UCACHE_SGRAPH_ALIGNMENT_SLACK) {
+ *buf_len = calc_ctx.size + UCACHE_SGRAPH_ALIGNMENT_SLACK;
+ }
+
+ decided = completed || !calc_ctx.reserve_failed;
+
+ ucache_sgraph_calc_verbatim_destroy(&calc_ctx);
+
+ if (decided && !(GC_FLAGS(Z_ARRVAL_P(val)) & IS_ARRAY_IMMUTABLE)) {
+ ZVAL_BOOL(&verdict_zv, completed);
+ zend_hash_index_add(
+ verbatim_verdicts,
+ (zend_ulong) (uintptr_t) Z_ARRVAL_P(val),
+ &verdict_zv
+ );
+ }
+}
+
+bool ucache_calc_sgraph_size(
+ const zval *val,
+ HashTable *state_memo,
+ HashTable *verbatim_verdicts,
+ size_t *buf_len,
+ bool *packed_vals_allowed)
+{
+ ucache_sgraph_calc_ctx calc_ctx;
+ bool result;
+
+ *buf_len = 0;
+ *packed_vals_allowed = false;
+
+ if (Z_TYPE_P(val) != IS_OBJECT &&
+ Z_TYPE_P(val) != IS_ARRAY &&
+ Z_TYPE_P(val) != IS_STRING
+ ) {
+ return false;
+ }
+
+ ucache_sgraph_calc_init(&calc_ctx);
+
+ calc_ctx.verbatim_arrs_allowed = ucache_sgraph_can_use_verbatim_arrs();
+ calc_ctx.verbatim_verdicts = verbatim_verdicts;
+ calc_ctx.state_memo = state_memo;
+ result = ucache_sgraph_calc_reserve(
+ &calc_ctx,
+ UCACHE_SGRAPH_HDR_SIZE(calc_ctx.pin_word_count)
+ );
+
+ if (result) {
+ result = ucache_sgraph_calc_val(&calc_ctx, val) && !EG(exception);
+ }
+
+ if (result) {
+ if (calc_ctx.size > UINT32_MAX - UCACHE_SGRAPH_ALIGNMENT_SLACK) {
+ result = false;
+ } else {
+ calc_ctx.size += UCACHE_SGRAPH_ALIGNMENT_SLACK;
+ }
+ }
+
+ if (result) {
+ *buf_len = calc_ctx.size;
+ *packed_vals_allowed = !calc_ctx.has_custom_obj_handlers &&
+ state_memo != NULL &&
+ zend_hash_num_elements(state_memo) == 0
+ ;
+ }
+
+ ucache_sgraph_calc_destroy(&calc_ctx);
+
+ return result;
+}
+
+bool ucache_build_sgraph_in_place(
+ const zval *val,
+ HashTable *state_memo,
+ const HashTable *verbatim_verdicts,
+ bool packed_vals_allowed,
+ uint8_t *buf,
+ size_t buf_len,
+ size_t *glen,
+ bool *has_verbatim_arr,
+ uint32_t **fixup_offsets,
+ uint32_t *fixup_count)
+{
+ ucache_sgraph_copy_ctx copy_ctx;
+ ucache_sgraph_hdr *hdr;
+ ucache_sgraph_val root_val;
+ uint32_t memo_count, attempt;
+ size_t padding;
+ bool result, protects_gc, gc_was_protected = false;
+
+ if (buf == NULL) {
+ return false;
+ }
+
+ padding = ucache_sgraph_alignment_padding(buf);
+ if (padding > buf_len || buf_len - padding < UCACHE_SGRAPH_HDR_SIZE(0)) {
+ return false;
+ }
+
+ if (padding != 0) {
+ memset(buf, 0, padding);
+ }
+
+ buf += padding;
+ buf_len -= padding;
+
+ memo_count = state_memo != NULL ? zend_hash_num_elements(state_memo) : 0;
+ protects_gc = state_memo == NULL;
+
+ if (protects_gc) {
+ gc_was_protected = gc_protect(true);
+ }
+
+ for (attempt = 0; ; attempt++) {
+ ucache_sgraph_copy_init(©_ctx, buf, buf_len);
+
+ copy_ctx.verbatim_verdicts = attempt == 0 ? verbatim_verdicts : NULL;
+ copy_ctx.state_memo = state_memo;
+ copy_ctx.packed_vals_allowed = attempt == 0 && packed_vals_allowed;
+
+ result = ucache_sgraph_copy_root(©_ctx, val, &root_val);
+
+ if (state_memo == NULL || EG(exception) || zend_hash_num_elements(state_memo) == memo_count) {
+ break;
+ }
+
+ if (attempt == UCACHE_SGRAPH_COPY_RETRIES) {
+ result = false;
+
+ break;
+ }
+
+ memo_count = zend_hash_num_elements(state_memo);
+
+ ucache_sgraph_copy_destroy(©_ctx);
+ }
+
+ if (!result || EG(exception)) {
+ ucache_sgraph_copy_destroy(©_ctx);
+
+ if (protects_gc) {
+ gc_protect(gc_was_protected);
+ }
+
+ return false;
+ }
+
+ hdr = (ucache_sgraph_hdr *) buf;
+ hdr->root_type = (uint8_t) root_val.type;
+ hdr->pin_word_count = copy_ctx.pin_word_count;
+ hdr->flags =
+ (root_val.offset == 0
+ ? UCACHE_SGRAPH_FLAG_EMPTY_ROOT
+ : 0
+ ) |
+ (copy_ctx.has_shared_identity
+ ? UCACHE_SGRAPH_FLAG_HAS_SHARED_IDENTITY
+ : 0
+ ) |
+ (copy_ctx.has_obj
+ ? UCACHE_SGRAPH_FLAG_HAS_OBJ
+ : 0
+ ) |
+ ((copy_ctx.prefers_proto && !copy_ctx.has_userland_restore_obj)
+ ? UCACHE_SGRAPH_FLAG_PREFERS_PROTO
+ : 0
+ )
+ ;
+
+ atomic_init(&hdr->ref_state, 0);
+
+ ucache_sgraph_pin_owners_init(hdr);
+
+ if (glen != NULL) {
+ *glen = copy_ctx.pos;
+ }
+
+ if (has_verbatim_arr != NULL) {
+ *has_verbatim_arr = copy_ctx.has_verbatim_arr;
+ }
+
+ ZEND_ASSERT(copy_ctx.has_verbatim_arr || copy_ctx.fixup_count == 0);
+
+ if (fixup_offsets != NULL) {
+ *fixup_offsets = copy_ctx.fixup_offsets;
+ *fixup_count = copy_ctx.fixup_count;
+
+ copy_ctx.fixup_offsets = NULL;
+ copy_ctx.fixup_count = 0;
+ copy_ctx.fixup_capacity = 0;
+ }
+
+ ucache_sgraph_copy_destroy(©_ctx);
+
+ if (protects_gc) {
+ gc_protect(gc_was_protected);
+ }
+
+ return true;
+}
+
+bool ucache_sgraph_copy_fits_buf(
+ const uint8_t *dst_buf,
+ const uint8_t *src_buf,
+ size_t buf_len,
+ size_t src_glen)
+{
+ size_t dst_padding;
+
+ if (src_buf == NULL ||
+ dst_buf == NULL ||
+ src_glen == 0 ||
+ src_glen > buf_len
+ ) {
+ return false;
+ }
+
+ dst_padding = ucache_sgraph_alignment_padding(dst_buf);
+
+ return dst_padding <= buf_len &&
+ (src_glen <= buf_len - dst_padding)
+ ;
+}
+
+bool ucache_sgraph_publish_copied_payload_locked(
+ uint8_t *dst_buf,
+ const uint8_t *src_buf,
+ size_t buf_len,
+ size_t src_glen,
+ bool has_verbatim_arr,
+ const uint32_t *fixup_offsets,
+ uint32_t fixup_count)
+{
+ const uint8_t *src_base;
+ ucache_sgraph_hdr *hdr;
+ uint32_t i;
+ uint8_t *dst_base;
+ uintptr_t delta, ptr;
+ size_t src_padding, dst_padding;
+
+ if (!ucache_sgraph_copy_fits_buf(dst_buf, src_buf, buf_len, src_glen)) {
+ return false;
+ }
+
+ src_padding = ucache_sgraph_alignment_padding(src_buf);
+ if (src_padding > buf_len ||
+ buf_len - src_padding < src_glen ||
+ src_glen < sizeof(*hdr)
+ ) {
+ return false;
+ }
+
+ src_base = src_buf + src_padding;
+ dst_padding = ucache_sgraph_alignment_padding(dst_buf);
+ if (dst_padding != 0) {
+ memset(dst_buf, 0, dst_padding);
+ }
+
+ dst_base = dst_buf + dst_padding;
+
+ memcpy(dst_base, src_base, src_glen);
+
+ hdr = (ucache_sgraph_hdr *) dst_base;
+
+ atomic_init(&hdr->ref_state, 0);
+
+ ucache_sgraph_pin_owners_init(hdr);
+
+ if (!has_verbatim_arr) {
+ ZEND_ASSERT(fixup_count == 0);
+
+#if ZEND_DEBUG
+ ucache_sgraph_check_rebase_complete(dst_base, src_glen, src_base);
+#endif
+
+ return true;
+ }
+
+ ZEND_ASSERT(fixup_offsets != NULL && fixup_count > 0);
+
+ delta = (uintptr_t) dst_base - (uintptr_t) src_base;
+ for (i = 0; i < fixup_count; i++) {
+ ZEND_ASSERT(fixup_offsets[i] <= src_glen - sizeof(uintptr_t));
+ ZEND_ASSERT((((uintptr_t) dst_base + fixup_offsets[i]) & (sizeof(uintptr_t) - 1)) == 0);
+
+ memcpy(&ptr, dst_base + fixup_offsets[i], sizeof(ptr));
+
+ ptr += delta;
+
+ memcpy(dst_base + fixup_offsets[i], &ptr, sizeof(ptr));
+ }
+
+#if ZEND_DEBUG
+ ucache_sgraph_check_rebase_complete(dst_base, src_glen, src_base);
+#endif
+
+ return true;
+}
diff --git a/ext/user_cache/user_cache_shm.h b/ext/user_cache/user_cache_shm.h
new file mode 100644
index 000000000000..cafc05affb91
--- /dev/null
+++ b/ext/user_cache/user_cache_shm.h
@@ -0,0 +1,134 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef UCACHE_SHM_H
+#define UCACHE_SHM_H
+
+#include "php.h"
+
+#ifdef HAVE_POSIX_FALLOCATE
+# include
+# include
+#endif
+
+#if defined(__APPLE__) && defined(__MACH__)
+# ifdef HAVE_SHM_MMAP_POSIX
+# define UCACHE_USE_SHM_OPEN 1
+# endif
+# ifdef HAVE_SHM_MMAP_ANON
+# define UCACHE_USE_MMAP 1
+# endif
+#elif defined(__linux__) || defined(_AIX)
+# ifdef HAVE_SHM_MMAP_POSIX
+# define UCACHE_USE_SHM_OPEN 1
+# endif
+# ifdef HAVE_SHM_IPC
+# define UCACHE_USE_SHM 1
+# endif
+# ifdef HAVE_SHM_MMAP_ANON
+# define UCACHE_USE_MMAP 1
+# endif
+#elif defined(__sparc) || defined(__sun)
+# ifdef HAVE_SHM_MMAP_POSIX
+# define UCACHE_USE_SHM_OPEN 1
+# endif
+# ifdef HAVE_SHM_IPC
+# define UCACHE_USE_SHM 1
+# endif
+# if defined(__i386)
+# ifdef HAVE_SHM_MMAP_ANON
+# define UCACHE_USE_MMAP 1
+# endif
+# endif
+#else
+# ifdef HAVE_SHM_MMAP_POSIX
+# define UCACHE_USE_SHM_OPEN 1
+# endif
+# ifdef HAVE_SHM_MMAP_ANON
+# define UCACHE_USE_MMAP 1
+# endif
+# ifdef HAVE_SHM_IPC
+# define UCACHE_USE_SHM 1
+# endif
+#endif
+
+#define UCACHE_ALLOC_FAILURE 0
+#define UCACHE_ALLOC_SUCCESS 1
+
+#define UCACHE_SHM_RESERVE_ATTEMPTS 8U
+
+#define UCACHE_PLATFORM_ALIGNMENT (alignof(ucache_align_test) < 8 ? 8 : alignof(ucache_align_test))
+
+#define UCACHE_ALIGNED_SIZE(size) \
+ ZEND_MM_ALIGNED_SIZE_EX(size, UCACHE_PLATFORM_ALIGNMENT)
+
+typedef union {
+ void *ptr;
+ double dbl;
+ zend_long lng;
+} ucache_align_test;
+
+typedef struct {
+ size_t size;
+ void *p;
+ bool zero_filled;
+} ucache_shm_seg;
+
+typedef int (*ucache_create_seg_t)(size_t requested_size, ucache_shm_seg **shared_seg, const char **err_in);
+typedef void (*ucache_detach_seg_t)(ucache_shm_seg *shared_seg);
+
+typedef struct {
+ ucache_create_seg_t create_seg;
+ ucache_detach_seg_t detach_seg;
+} ucache_shm_handlers;
+
+typedef struct {
+ const char *name;
+ const ucache_shm_handlers *handler;
+} ucache_shm_handler_entry;
+
+#ifdef UCACHE_USE_SHM
+extern const ucache_shm_handlers ucache_alloc_shm_handlers;
+#endif
+#ifdef UCACHE_USE_SHM_OPEN
+extern const ucache_shm_handlers ucache_alloc_posix_handlers;
+
+/* Sparse shm_open() pages raise SIGBUS on first touch when tmpfs is full. */
+static zend_always_inline bool ucache_shm_fd_reserve(int fd, size_t size)
+{
+# ifdef HAVE_POSIX_FALLOCATE
+ uint32_t attempts = 0;
+ int err;
+
+ do {
+ err = posix_fallocate(fd, 0, (off_t) size);
+ } while (err == EINTR && ++attempts < UCACHE_SHM_RESERVE_ATTEMPTS);
+
+ if (err == 0 || err == EINVAL || err == EOPNOTSUPP) {
+ return true;
+ }
+
+ errno = err;
+
+ return false;
+# else
+ (void) fd;
+ (void) size;
+
+ return true;
+# endif
+}
+#endif
+
+#endif /* UCACHE_SHM_H */
diff --git a/ext/user_cache/user_cache_status.c b/ext/user_cache/user_cache_status.c
new file mode 100644
index 000000000000..a7598eeeefba
--- /dev/null
+++ b/ext/user_cache/user_cache_status.c
@@ -0,0 +1,644 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_internal.h"
+#include "user_cache_decl.h"
+
+#define UCACHE_DEFINE_STATUS_LONG_GETTER(method, field) \
+ ZEND_METHOD(UserCache_CacheStatus, method) \
+ { \
+ ucache_status_obj *status; \
+ \
+ ZEND_PARSE_PARAMETERS_NONE(); \
+ \
+ status = ucache_status_from_this(ZEND_THIS); \
+ if (status == NULL) { \
+ RETURN_THROWS(); \
+ } \
+ \
+ RETURN_LONG(status->stats.field); \
+ }
+
+typedef struct {
+ const ucache_ctx *ctx;
+ const ucache_hdr *hdr;
+ uint64_t owner_pid;
+ uint64_t time_base;
+ uint64_t bucket_epoch;
+ zend_long entry_count;
+ zend_long used_mem;
+ zval entry_keys;
+} ucache_pool_status_snapshot;
+
+static zend_always_inline const char *ucache_entry_key_ptr(const ucache_entry *entry)
+{
+ return (const char *) ucache_base() + ucache_entry_key_pos(entry);
+}
+
+static ucache_pool_status_obj *ucache_pool_status_from_this(zval *this_ptr)
+{
+ ucache_pool_status_obj *status =
+ ucache_pool_status_obj_from_obj(Z_OBJ_P(this_ptr))
+ ;
+
+ if (status->scope == NULL) {
+ zend_throw_error(NULL, "%s instance was not initialized", ZSTR_VAL(Z_OBJCE_P(this_ptr)->name));
+
+ return NULL;
+ }
+
+ return status;
+}
+
+static ucache_status_obj *ucache_status_from_this(zval *this_ptr)
+{
+ ucache_status_obj *status =
+ ucache_status_obj_from_obj(Z_OBJ_P(this_ptr))
+ ;
+
+ if (!status->initialized) {
+ zend_throw_error(NULL, "%s instance was not initialized", ZSTR_VAL(Z_OBJCE_P(this_ptr)->name));
+
+ return NULL;
+ }
+
+ return status;
+}
+
+static zend_long ucache_clamp_zend_long(uint64_t count)
+{
+ return count > (uint64_t) ZEND_LONG_MAX ? ZEND_LONG_MAX : (zend_long) count;
+}
+
+static zend_object *ucache_availability_enum_case(php_ucache_reason reason)
+{
+ zend_enum_UserCache_CacheAvailability case_id
+ = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByUnknownReason
+ ;
+
+ switch (reason) {
+ case PHP_UCACHE_REASON_NONE:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_Available;
+
+ break;
+ case PHP_UCACHE_REASON_DISABLED_BY_INI:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_DisabledByIni;
+
+ break;
+ case PHP_UCACHE_REASON_SHM_INIT_FAILED:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableBySharedMemoryInitializationFailed;
+
+ break;
+ case PHP_UCACHE_REASON_SAPI_NOT_ENABLED:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_DisabledBySapi;
+
+ break;
+ case PHP_UCACHE_REASON_BACKEND_NOT_INITIALIZED_BEFORE_WORKER:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByBackendNotInitializedBeforeWorkerStartup;
+
+ break;
+ case PHP_UCACHE_REASON_BACKEND_INITIALIZED_AFTER_WORKER:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByBackendInitializedAfterWorkerStartup;
+
+ break;
+ case PHP_UCACHE_REASON_CGI_BOUNDARY_UNAVAILABLE:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByCgiFastCgiBoundary;
+
+ break;
+ case PHP_UCACHE_REASON_APACHE_BOUNDARY_UNAVAILABLE:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByApacheBoundary;
+
+ break;
+ case PHP_UCACHE_REASON_LSAPI_BOUNDARY_UNAVAILABLE:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByLsapiBoundary;
+
+ break;
+ case PHP_UCACHE_REASON_REQ_SHUTDOWN:
+ case_id = ZEND_ENUM_UserCache_CacheAvailability_UnavailableByUnknownReason;
+
+ break;
+ }
+
+ return zend_enum_get_case_by_id(ucache_availability_ce, case_id);
+}
+
+static void ucache_add_pool_mem(size_t *used_mem, size_t size)
+{
+ if (*used_mem >= (size_t) ZEND_LONG_MAX ||
+ size > (size_t) ZEND_LONG_MAX - *used_mem
+ ) {
+ *used_mem = (size_t) ZEND_LONG_MAX;
+
+ return;
+ }
+
+ *used_mem += size;
+}
+
+static size_t ucache_payload_block_size(
+ const ucache_hdr *hdr,
+ uint32_t payload_offset)
+{
+ uint32_t block_size;
+
+ if (!ucache_payload_in_bounds(hdr, payload_offset, 0)) {
+ return 0;
+ }
+
+ block_size = ucache_block_size(ucache_block_ptr(ucache_payload_block_offset(payload_offset)));
+ if (block_size < sizeof(ucache_block) ||
+ !ucache_payload_in_bounds(hdr, payload_offset, block_size - sizeof(ucache_block))
+ ) {
+ return 0;
+ }
+
+ return block_size;
+}
+
+static void ucache_account_pool_entry(
+ const ucache_hdr *hdr,
+ const ucache_entry *entry,
+ zend_string *prefix,
+ zend_long *entry_count,
+ size_t *used_mem,
+ zval *entry_keys)
+{
+ const char *key;
+ uint32_t val_offset;
+ size_t prefix_len;
+ bool combined_val_key;
+
+ combined_val_key = (entry->flags & UCACHE_ENTRY_FLAG_COMBINED_VAL_KEY) != 0;
+ val_offset = ucache_entry_val_offset(entry);
+ key = ucache_entry_key_ptr(entry);
+ prefix_len = ZSTR_LEN(prefix);
+
+ (*entry_count)++;
+
+ add_next_index_stringl(entry_keys, key + prefix_len, entry->key_len - prefix_len);
+
+ ucache_add_pool_mem(used_mem, sizeof(ucache_entry) + sizeof(ucache_pool_links));
+
+ if (combined_val_key) {
+ ucache_add_pool_mem(
+ used_mem,
+ ucache_payload_block_size(hdr, val_offset)
+ );
+ } else {
+ ucache_add_pool_mem(
+ used_mem,
+ ucache_payload_block_size(hdr, entry->key_offset)
+ );
+
+ if (val_offset != 0) {
+ ucache_add_pool_mem(
+ used_mem,
+ ucache_payload_block_size(hdr, val_offset)
+ );
+ }
+ }
+}
+
+static void ucache_pool_status_snapshot_dtor(zval *val)
+{
+ ucache_pool_status_snapshot *snapshot = Z_PTR_P(val);
+
+ if (snapshot != NULL) {
+ zval_ptr_dtor_nogc(&snapshot->entry_keys);
+
+ efree(snapshot);
+ }
+}
+
+static ucache_pool_status_snapshot *ucache_pool_status_snapshot_get_or_create(zend_ulong handle)
+{
+ ucache_pool_status_snapshot *snapshot;
+ HashTable *snapshots = UC_G(pool_status_snapshots);
+ zval *entry, empty;
+
+ if (snapshots == NULL) {
+ ALLOC_HASHTABLE(snapshots);
+ zend_hash_init(snapshots, 8, NULL, ucache_pool_status_snapshot_dtor, 0);
+
+ UC_G(pool_status_snapshots) = snapshots;
+ }
+
+ entry = zend_hash_index_find(snapshots, handle);
+ if (entry == NULL) {
+ ZVAL_PTR(&empty, NULL);
+
+ entry = zend_hash_index_add_new(snapshots, handle, &empty);
+ }
+
+ snapshot = Z_PTR_P(entry);
+ if (snapshot == NULL) {
+ snapshot = ecalloc(1, sizeof(*snapshot));
+
+ ZVAL_UNDEF(&snapshot->entry_keys);
+ ZVAL_PTR(entry, snapshot);
+ }
+
+ return snapshot;
+}
+
+void ucache_collect_info_stats(ucache_info_stats *stats)
+{
+ ucache_runtime *runtime;
+ ucache_storage *storage;
+ ucache_hdr *hdr;
+ ucache_graph_pin_slot *pin_slot;
+ uint32_t i;
+ size_t free_mem = 0, wasted_mem = 0, tail_mem = 0;
+
+ memset(stats, 0, sizeof(*stats));
+
+ runtime = ucache_active_runtime();
+ storage = &ucache_active_ctx()->storage;
+
+ stats->configured_mem = ucache_clamp_zend_long(runtime->configured_mem);
+
+ if (!runtime->available) {
+ return;
+ }
+
+ stats->shared_mem_size = ucache_clamp_zend_long(storage->size);
+
+ if (!ucache_storage_startup_is_complete(storage) || !ucache_rlock()) {
+ return;
+ }
+
+ hdr = ucache_hdr_ptr();
+ if (hdr == NULL || !ucache_hdr_adoptable_locked()) {
+ stats->free_mem = stats->shared_mem_size;
+
+ ucache_unlock();
+
+ return;
+ }
+
+ if (hdr->next_free <= hdr->data_size) {
+ tail_mem = hdr->data_size - hdr->next_free;
+ }
+
+ wasted_mem = hdr->free_list_bytes;
+ free_mem = tail_mem + wasted_mem;
+
+ stats->entry_count = (zend_long) hdr->count;
+ stats->entry_capacity = (zend_long) hdr->capacity;
+ stats->tombstone_count = (zend_long) hdr->tombstone_count;
+ stats->expunge_count = ucache_clamp_zend_long(hdr->expunge_count);
+ stats->store_failure_count = ucache_clamp_zend_long(hdr->store_failure_count);
+ stats->eviction_count = ucache_clamp_zend_long(hdr->eviction_count);
+ stats->dead_pin_owners_reclaimed = ucache_clamp_zend_long(hdr->graph_dead_pin_owners_reclaimed);
+ stats->dead_pins_stripped = ucache_clamp_zend_long(hdr->graph_dead_pins_stripped);
+ stats->committed_mem = ucache_clamp_zend_long(ucache_committed_bytes_locked(hdr));
+ stats->commit_failure_count = ucache_clamp_zend_long(hdr->commit_failure_count);
+
+ for (i = 0; i < ucache_graph_pin_slot_count(hdr); i++) {
+ pin_slot = &hdr->graph_pin_slots[i];
+
+ if (atomic_load(&pin_slot->owner_pid) != 0) {
+ stats->graph_pin_slots_in_use++;
+ }
+
+ stats->graph_pinned_refs +=
+ (zend_long) atomic_load(&pin_slot->pin_count)
+ ;
+ }
+
+ stats->free_mem = ucache_clamp_zend_long(free_mem);
+ stats->wasted_mem = ucache_clamp_zend_long(wasted_mem);
+ stats->used_mem = storage->size > free_mem
+ ? ucache_clamp_zend_long(storage->size - free_mem)
+ : 0
+ ;
+
+ ucache_unlock();
+}
+
+void ucache_release_pool_status_snapshots(void)
+{
+ HashTable *snapshots = UC_G(pool_status_snapshots);
+
+ UC_G(pool_status_snapshots) = NULL;
+
+ if (snapshots != NULL) {
+ zend_hash_destroy(snapshots);
+
+ FREE_HASHTABLE(snapshots);
+ }
+}
+
+void ucache_collect_pool_status(
+ ucache_obj *cache,
+ zend_long *entry_count,
+ zend_long *used_mem,
+ zval *entry_keys)
+{
+ ucache_ctx *ctx = cache->ctx;
+ zend_string *prefix = cache->scope_prefix;
+ ucache_ctx *prev_ctx;
+ ucache_storage *storage;
+ ucache_hdr *hdr;
+ ucache_entry *entries, *entry;
+ ucache_pool_links *links;
+ ucache_pool_status_snapshot *snapshot;
+ zval stale_keys;
+ uint64_t time_base, bucket_epoch;
+ uint32_t bucket, idx, walked = 0;
+ size_t used_size = 0;
+
+ *entry_count = 0;
+ *used_mem = 0;
+
+ prev_ctx = ucache_activate_ctx(ctx);
+
+ storage = &ctx->storage;
+ if (!ucache_storage_startup_is_complete(storage) || !ucache_rlock()) {
+ ucache_restore_ctx(prev_ctx);
+
+ return;
+ }
+
+ hdr = ucache_hdr_ptr();
+ if (hdr == NULL ||
+ !ucache_hdr_adoptable_locked()
+ ) {
+ ucache_unlock();
+ ucache_restore_ctx(prev_ctx);
+
+ return;
+ }
+
+ bucket = (uint32_t) (zend_string_hash_val(prefix) & (UCACHE_POOL_BUCKETS - 1));
+ snapshot = UC_G(pool_status_snapshots) != NULL
+ ? zend_hash_index_find_ptr(UC_G(pool_status_snapshots), cache->std.handle)
+ : NULL
+ ;
+ if (snapshot != NULL &&
+ snapshot->ctx == ctx &&
+ snapshot->hdr == hdr &&
+ snapshot->owner_pid == ucache_cached_pid() &&
+ snapshot->time_base == hdr->time_base &&
+ snapshot->bucket_epoch == hdr->pool_bucket_epochs[bucket]
+ ) {
+ *entry_count = snapshot->entry_count;
+ *used_mem = snapshot->used_mem;
+
+ ZVAL_COPY(entry_keys, &snapshot->entry_keys);
+
+ ucache_unlock();
+ ucache_restore_ctx(prev_ctx);
+
+ return;
+ }
+
+ entries = ucache_entries_ptr(hdr);
+ links = ucache_pool_links_ptr(hdr);
+
+ zend_try {
+ array_init(entry_keys);
+
+ for (idx = hdr->pool_bucket_heads[bucket]; idx != 0; idx = links[idx - 1].next) {
+ ZEND_ASSERT(ucache_pool_link_ref_fits(hdr, idx) && walked < hdr->capacity);
+
+ if (!ucache_pool_link_ref_fits(hdr, idx) || walked == hdr->capacity) {
+ break;
+ }
+
+ walked++;
+ entry = &entries[idx - 1];
+ if (ucache_entry_is_used(entry) &&
+ ucache_bytes_in_bounds(hdr, ucache_entry_key_pos(entry), entry->key_len) &&
+ entry->key_len >= ZSTR_LEN(prefix) &&
+ memcmp(ucache_entry_key_ptr(entry), ZSTR_VAL(prefix), ZSTR_LEN(prefix)) == 0
+ ) {
+ ucache_account_pool_entry(
+ hdr,
+ entry,
+ prefix,
+ entry_count,
+ &used_size,
+ entry_keys
+ );
+ }
+ }
+ } zend_catch {
+ ucache_unlock_if_held();
+ ucache_restore_ctx(prev_ctx);
+
+ zend_bailout();
+ } zend_end_try();
+
+ time_base = hdr->time_base;
+ bucket_epoch = hdr->pool_bucket_epochs[bucket];
+
+ ucache_unlock();
+ ucache_restore_ctx(prev_ctx);
+
+ *used_mem = ucache_clamp_zend_long(used_size);
+
+ if (UC_G(in_req_shutdown)) {
+ return;
+ }
+
+ snapshot = ucache_pool_status_snapshot_get_or_create(cache->std.handle);
+
+ ZVAL_COPY_VALUE(&stale_keys, &snapshot->entry_keys);
+ ZVAL_COPY(&snapshot->entry_keys, entry_keys);
+
+ snapshot->ctx = ctx;
+ snapshot->hdr = hdr;
+ snapshot->owner_pid = ucache_cached_pid();
+ snapshot->time_base = time_base;
+ snapshot->bucket_epoch = bucket_epoch;
+ snapshot->entry_count = *entry_count;
+ snapshot->used_mem = *used_mem;
+
+ zval_ptr_dtor(&stale_keys);
+}
+
+void ucache_pool_status_obj_free(zend_object *obj)
+{
+ ucache_pool_status_obj *status =
+ ucache_pool_status_obj_from_obj(obj)
+ ;
+
+ zend_object_std_dtor(&status->std);
+
+ if (status->scope != NULL) {
+ zend_string_release(status->scope);
+ }
+
+ zval_ptr_dtor(&status->entry_keys);
+}
+
+zend_object *ucache_pool_status_obj_create(zend_class_entry *ce)
+{
+ ucache_pool_status_obj *status;
+
+ status = zend_object_alloc(sizeof(ucache_pool_status_obj), ce);
+
+ zend_object_std_init(&status->std, ce);
+ object_properties_init(&status->std, ce);
+
+ status->scope = NULL;
+ status->entry_count = 0;
+ status->used_mem = 0;
+
+ ZVAL_UNDEF(&status->entry_keys);
+
+ status->std.handlers = &ucache_pool_status_obj_handlers;
+
+ return &status->std;
+}
+
+zend_object *ucache_status_obj_create(zend_class_entry *ce)
+{
+ ucache_status_obj *status;
+
+ status = zend_object_alloc(sizeof(ucache_status_obj), ce);
+
+ zend_object_std_init(&status->std, ce);
+ object_properties_init(&status->std, ce);
+
+ memset(&status->stats, 0, sizeof(status->stats));
+
+ status->availability_reason = PHP_UCACHE_REASON_NONE;
+ status->initialized = false;
+ status->std.handlers = &ucache_status_obj_handlers;
+
+ return &status->std;
+}
+
+ZEND_METHOD(UserCache_CacheStatus, __construct)
+{
+}
+
+ZEND_METHOD(UserCache_CacheStatus, getAvailability)
+{
+ ucache_status_obj *status;
+ zend_object *availability;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ status = ucache_status_from_this(ZEND_THIS);
+ if (status == NULL) {
+ RETURN_THROWS();
+ }
+
+ availability = ucache_availability_enum_case(
+ status->availability_reason
+ );
+
+ RETURN_OBJ_COPY(availability);
+}
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getConfiguredMemory, configured_mem)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getSharedMemorySize, shared_mem_size)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getUsedMemory, used_mem)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getFreeMemory, free_mem)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getWastedMemory, wasted_mem)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getEntryCount, entry_count)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getEntryCapacity, entry_capacity)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getTombstoneCount, tombstone_count)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getExpungeCount, expunge_count)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getEvictionCount, eviction_count)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getStoreFailureCount, store_failure_count)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getGraphPinSlotsInUse, graph_pin_slots_in_use)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getGraphPinnedReferences, graph_pinned_refs)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getDeadPinOwnersReclaimed, dead_pin_owners_reclaimed)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getDeadPinsStripped, dead_pins_stripped)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getCommittedMemory, committed_mem)
+
+UCACHE_DEFINE_STATUS_LONG_GETTER(getCommitFailureCount, commit_failure_count)
+
+ZEND_METHOD(UserCache_CachePoolStatus, __construct)
+{
+}
+
+ZEND_METHOD(UserCache_CachePoolStatus, getPoolName)
+{
+ ucache_pool_status_obj *status;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ status = ucache_pool_status_from_this(ZEND_THIS);
+ if (status == NULL) {
+ RETURN_THROWS();
+ }
+
+ RETURN_STR_COPY(status->scope);
+}
+
+ZEND_METHOD(UserCache_CachePoolStatus, getEntryCount)
+{
+ ucache_pool_status_obj *status;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ status = ucache_pool_status_from_this(ZEND_THIS);
+ if (status == NULL) {
+ RETURN_THROWS();
+ }
+
+ RETURN_LONG(status->entry_count);
+}
+
+ZEND_METHOD(UserCache_CachePoolStatus, getEntryKeys)
+{
+ ucache_pool_status_obj *status;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ status = ucache_pool_status_from_this(ZEND_THIS);
+ if (status == NULL) {
+ RETURN_THROWS();
+ }
+
+ if (Z_TYPE(status->entry_keys) != IS_ARRAY) {
+ RETURN_EMPTY_ARRAY();
+ }
+
+ RETURN_COPY(&status->entry_keys);
+}
+
+ZEND_METHOD(UserCache_CachePoolStatus, getUsedMemory)
+{
+ ucache_pool_status_obj *status;
+
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ status = ucache_pool_status_from_this(ZEND_THIS);
+ if (status == NULL) {
+ RETURN_THROWS();
+ }
+
+ RETURN_LONG(status->used_mem);
+}
diff --git a/ext/user_cache/user_cache_storage.c b/ext/user_cache/user_cache_storage.c
new file mode 100644
index 000000000000..c3d51859e345
--- /dev/null
+++ b/ext/user_cache/user_cache_storage.c
@@ -0,0 +1,1274 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "user_cache_storage.h"
+
+#define UCACHE_READER_DRAIN_SPIN 1024U
+#define UCACHE_READER_DRAIN_TIMEOUT_US 10000U
+
+#define UCACHE_SAPI_CLI 1U
+#define UCACHE_SAPI_PRE_REQ_STORAGE 2U
+
+#define UCACHE_MEM_MODEL_SHM_ALIAS "cgi"
+
+typedef struct {
+ const char *alloc_err_in;
+ int alloc_err_code;
+ const char *preferred_failed_model;
+ const char *preferred_err_in;
+ int preferred_err_code;
+ const char *fallback_model;
+ uint32_t clamped_capacity;
+ bool alloc_failed;
+} ucache_startup_warnings;
+
+static bool ucache_capacity_clamp_warned = false;
+
+static const char *ucache_classified_sapi_name = NULL;
+static uint32_t ucache_classified_sapi_flags = 0;
+
+#ifdef ZTS
+static ucache_startup_lock ucache_startup_storage_lock_state =
+ UCACHE_STARTUP_LOCK_INITIALIZER
+;
+#endif
+
+static zend_always_inline uint32_t ucache_free_bin_count_for_seg(size_t seg_size)
+{
+ return ucache_size_class((uint32_t) MIN(seg_size, (size_t) UCACHE_BLOCK_SIZE_MAX)) + 1U;
+}
+
+static zend_always_inline size_t ucache_shm_size_floor(size_t seg_size)
+{
+ return UCACHE_ALIGNED_SIZE(
+ UCACHE_ALIGNED_SIZE(
+ UCACHE_ALIGNED_SIZE(
+ sizeof(ucache_hdr) + UCACHE_MIN_CAPACITY * UCACHE_TABLE_SLOT_SIZE
+ ) +
+ UCACHE_ENTRY_LOCK_MIN_CAPACITY * sizeof(ucache_entry_lock_record)
+ ) +
+ ucache_free_bins_bytes(ucache_free_bin_count_for_seg(seg_size))
+ );
+}
+
+static zend_always_inline uint32_t ucache_sapi_flags(void)
+{
+ if (UNEXPECTED(sapi_module.name != ucache_classified_sapi_name)) {
+ ucache_classify_sapi();
+ }
+
+ return ucache_classified_sapi_flags;
+}
+
+static zend_always_inline bool ucache_requires_pre_req_storage(void)
+{
+ return (ucache_sapi_flags() & UCACHE_SAPI_PRE_REQ_STORAGE) != 0;
+}
+
+static zend_always_inline bool ucache_is_disabled_for_sapi(void)
+{
+ if (!UC_G(enable)) {
+ return true;
+ }
+
+ return !UC_G(enable_cli) && (ucache_sapi_flags() & UCACHE_SAPI_CLI) != 0;
+}
+
+static zend_always_inline void ucache_set_unavailable(php_ucache_reason reason)
+{
+ ucache_runtime *runtime = ucache_active_runtime();
+
+ runtime->available = false;
+ runtime->failure_reason = reason;
+}
+
+static zend_always_inline void ucache_set_available(void)
+{
+ ucache_runtime *runtime = ucache_active_runtime();
+
+ runtime->available = true;
+ runtime->failure_reason = PHP_UCACHE_REASON_NONE;
+}
+
+#ifdef ZTS
+# ifndef ZEND_WIN32
+static inline void ucache_zts_lock_reinit_after_fork(ucache_storage *storage)
+{
+ if (storage->zts_lock != NULL) {
+ pthread_mutex_init(storage->zts_lock, NULL);
+ }
+}
+# endif
+
+static inline void ucache_startup_storage_lock(void)
+{
+# ifdef ZEND_WIN32
+ AcquireSRWLockExclusive(&ucache_startup_storage_lock_state);
+# else
+ pthread_mutex_lock(&ucache_startup_storage_lock_state);
+# endif
+}
+
+static inline void ucache_startup_storage_unlock(void)
+{
+# ifdef ZEND_WIN32
+ ReleaseSRWLockExclusive(&ucache_startup_storage_lock_state);
+# else
+ pthread_mutex_unlock(&ucache_startup_storage_lock_state);
+# endif
+}
+#else
+static inline void ucache_startup_storage_lock(void)
+{
+}
+
+static inline void ucache_startup_storage_unlock(void)
+{
+}
+#endif /* ZTS */
+
+static bool ucache_capacity_is_prime(uint32_t candidate)
+{
+ uint32_t i;
+
+ if (candidate < 4) {
+ return candidate > 1;
+ }
+
+ if (candidate % 2 == 0 || candidate % 3 == 0) {
+ return false;
+ }
+
+ for (i = 5; (uint64_t) i * i <= candidate; i += 6) {
+ if (candidate % i == 0 || candidate % (i + 2) == 0) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static uint32_t ucache_next_prime(uint32_t candidate)
+{
+ while (!ucache_capacity_is_prime(candidate)) {
+ candidate++;
+ }
+
+ return candidate;
+}
+
+static uint32_t ucache_prev_prime(uint32_t candidate)
+{
+ while (candidate > UCACHE_MIN_CAPACITY && !ucache_capacity_is_prime(candidate)) {
+ candidate--;
+ }
+
+ return candidate;
+}
+
+static bool ucache_hdr_boundary_identity_matches_locked(
+ const ucache_hdr *hdr,
+ size_t requested_size)
+{
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ ucache_ctx *ctx = ucache_active_ctx();
+
+ if (!ucache_ctx_is_boundary(ctx)) {
+ return hdr->boundary_identity_digest_set == 0;
+ }
+
+ if (hdr->boundary_identity_digest_set == 0) {
+ return false;
+ }
+
+ return memcmp(
+ hdr->boundary_identity_digest,
+ ucache_shared_boundary_digest_memo(ctx, requested_size),
+ sizeof(ctx->storage.boundary_digest_memo)
+ ) == 0;
+#else
+ (void) hdr;
+ (void) requested_size;
+
+ return true;
+#endif
+}
+
+static void ucache_hdr_layout_memo(
+ ucache_storage *storage,
+ uint32_t *capacity,
+ uint32_t *data_offset)
+{
+ if (!storage->layout_memo_valid) {
+ storage->capacity_memo = ucache_calc_capacity(storage->size, &storage->capacity_clamped);
+ storage->entry_lock_capacity_memo =
+ ucache_calc_entry_lock_capacity(storage->capacity_memo)
+ ;
+ storage->entry_lock_offset_memo = (uint32_t) UCACHE_ALIGNED_SIZE(
+ sizeof(ucache_hdr)
+ + storage->capacity_memo * UCACHE_TABLE_SLOT_SIZE
+ );
+ storage->free_bins_offset_memo = (uint32_t) UCACHE_ALIGNED_SIZE(
+ storage->entry_lock_offset_memo
+ + storage->entry_lock_capacity_memo * sizeof(ucache_entry_lock_record)
+ );
+ storage->free_bin_count_memo = ucache_free_bin_count_for_seg(storage->size);
+ storage->data_offset_memo = (uint32_t) UCACHE_ALIGNED_SIZE(
+ storage->free_bins_offset_memo + ucache_free_bins_bytes(storage->free_bin_count_memo)
+ );
+ storage->layout_memo_valid = true;
+ }
+
+ *capacity = storage->capacity_memo;
+ *data_offset = storage->data_offset_memo;
+}
+
+static bool ucache_hdr_layout_matches_memo_locked(
+ const ucache_storage *storage,
+ const ucache_hdr *hdr,
+ uint32_t capacity,
+ uint32_t data_offset)
+{
+ return hdr->capacity == capacity &&
+ hdr->data_offset == data_offset &&
+ hdr->entry_lock_capacity == storage->entry_lock_capacity_memo &&
+ hdr->entry_lock_offset == storage->entry_lock_offset_memo &&
+ hdr->free_bins_offset == storage->free_bins_offset_memo &&
+ hdr->free_bin_count == storage->free_bin_count_memo &&
+ hdr->graph_pin_slot_count == ucache_ctx_graph_pin_slot_count(ucache_active_ctx()) &&
+ ucache_hdr_data_bounds_match(storage, hdr)
+ ;
+}
+
+static bool ucache_commit_seg_prefix(ucache_startup_warnings *warnings)
+{
+#ifdef ZEND_WIN32
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+ uint32_t capacity, data_offset;
+
+ ucache_hdr_layout_memo(storage, &capacity, &data_offset);
+
+ if (ucache_win32_commit_to((ucache_win32_seg *) storage->seg, data_offset)) {
+ return true;
+ }
+
+ warnings->alloc_err_in = "VirtualAlloc";
+ warnings->alloc_err_code = ucache_platform.alloc_err_code();
+ warnings->alloc_failed = true;
+
+ return false;
+#else
+ (void) warnings;
+
+ return true;
+#endif
+}
+
+static zend_never_inline bool ucache_hdr_format_fresh_locked(
+ ucache_hdr *hdr,
+ uint32_t capacity,
+ uint32_t data_offset)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+ uint64_t epoch;
+
+ if ((size_t) data_offset + UCACHE_BLOCK_MIN_SIZE > storage->size) {
+ return false;
+ }
+
+ epoch = MAX((uint64_t) time(NULL) << 32, hdr->mutation_epoch + 1);
+
+ ucache_atomic_store_64(&hdr->mutation_epoch, epoch != 0 ? epoch : 1);
+
+ if (!storage->seg->zero_filled) {
+ memset(hdr, 0, offsetof(ucache_hdr, mutation_epoch));
+ memset(
+ &hdr->mutation_epoch + 1,
+ 0,
+ data_offset - offsetof(ucache_hdr, mutation_epoch) - sizeof(hdr->mutation_epoch)
+ );
+ }
+
+ storage->seg->zero_filled = false;
+
+ hdr->capacity = capacity;
+ hdr->data_offset = data_offset;
+ hdr->data_size = storage->size - data_offset;
+ hdr->entry_lock_capacity = storage->entry_lock_capacity_memo;
+ hdr->entry_lock_offset = storage->entry_lock_offset_memo;
+ hdr->free_bins_offset = storage->free_bins_offset_memo;
+ hdr->free_bin_count = storage->free_bin_count_memo;
+ hdr->graph_pin_slot_count = ucache_ctx_graph_pin_slot_count(ucache_active_ctx());
+ hdr->committed_end = ucache_view_committed_bytes(storage);
+ hdr->stale_tail_end = hdr->committed_end;
+ hdr->next_free = 0;
+
+ hdr->count = 0;
+ hdr->expiring_count = 0;
+ hdr->expiry_floor = UCACHE_EXPIRY_FLOOR_NONE;
+ hdr->time_base = ucache_clock_now();
+
+ if (hdr->time_base == 0) {
+ hdr->time_base = 1;
+ }
+
+ hdr->write_seq = UC_G(write_seq_bumped) ? 1u : 2u;
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+ hdr->lock_model = ucache_shared_mutex_init(hdr)
+ ? UCACHE_LOCK_MODEL_MUTEX
+ : UCACHE_LOCK_MODEL_FCNTL
+ ;
+#else
+ hdr->lock_model = UCACHE_LOCK_MODEL_FCNTL;
+#endif
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ if (ucache_ctx_is_boundary(ucache_active_ctx())) {
+ memcpy(
+ hdr->boundary_identity_digest,
+ ucache_shared_boundary_digest_memo(ucache_active_ctx(), storage->size),
+ sizeof(hdr->boundary_identity_digest)
+ );
+
+ hdr->boundary_identity_digest_set = 1;
+ hdr->lock_file_dev = ucache_boundary_seg_of(storage)->lock_dev;
+ hdr->lock_file_ino = ucache_boundary_seg_of(storage)->lock_ino;
+ }
+#endif
+#if ZEND_DEBUG
+ ucache_debug_reserve_data_below_4g_locked(hdr);
+#endif
+
+ ucache_atomic_fence_seq_cst();
+
+ hdr->magic = UCACHE_MAGIC;
+
+ ucache_write_section_announce(hdr);
+
+ return true;
+}
+
+static bool ucache_try_storage_handler(
+ const ucache_shm_handler_entry *handler_entry,
+ const char **err_in_ptr,
+ int *err_code_ptr)
+{
+ const char *err_in = NULL;
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_runtime *runtime = ucache_active_runtime();
+ ucache_storage *storage = &ctx->storage;
+ ucache_shm_seg *seg = NULL;
+
+ if (handler_entry->handler->create_seg(
+ runtime->configured_mem,
+ &seg,
+ &err_in
+ ) != UCACHE_ALLOC_SUCCESS
+ ) {
+ *err_code_ptr = ucache_platform.alloc_err_code();
+ *err_in_ptr = err_in;
+
+ return false;
+ }
+
+ storage->handler = handler_entry->handler;
+ storage->handler_name = handler_entry->name;
+ storage->seg = seg;
+ storage->base = seg->p;
+ storage->size = runtime->configured_mem;
+ storage->initialized = true;
+
+ return true;
+}
+
+static const ucache_shm_handler_entry *ucache_find_storage_handler(const char *model)
+{
+ const ucache_shm_handler_entry *handler_entry;
+
+ if (strcmp(model, UCACHE_MEM_MODEL_SHM_ALIAS) == 0) {
+ model = "shm";
+ }
+
+ for (handler_entry = ucache_handler_table(); handler_entry->name; handler_entry++) {
+ if (strcmp(model, handler_entry->name) == 0) {
+ return handler_entry;
+ }
+ }
+
+ return NULL;
+}
+
+static bool ucache_try_preferred_storage_handler(
+ const ucache_shm_handler_entry *handler_entry,
+ ucache_startup_warnings *warnings)
+{
+ if (UCACHE_DEBUG_FAULT("FAIL_PREFERRED_MEMORY_MODEL")) {
+ warnings->alloc_err_in = "debug fault";
+ warnings->alloc_err_code = 0;
+ } else if (ucache_try_storage_handler(handler_entry, &warnings->alloc_err_in, &warnings->alloc_err_code)) {
+ return true;
+ }
+
+ warnings->preferred_failed_model = handler_entry->name;
+ warnings->preferred_err_in = warnings->alloc_err_in;
+ warnings->preferred_err_code = warnings->alloc_err_code;
+
+ return false;
+}
+
+static void ucache_log_preferred_model_failure(const ucache_startup_warnings *warnings, bool started)
+{
+ char failure[128];
+
+ if (started && warnings->fallback_model != NULL) {
+ snprintf(
+ failure,
+ sizeof(failure),
+ "preferred memory model \"%s\" failed, using \"%s\"",
+ warnings->preferred_failed_model,
+ warnings->fallback_model
+ );
+ } else {
+ snprintf(failure, sizeof(failure), "preferred memory model \"%s\" failed", warnings->preferred_failed_model);
+ }
+
+ ucache_platform.log_alloc_failure(failure, warnings->preferred_err_in, warnings->preferred_err_code);
+}
+
+static bool ucache_select_storage_handler(ucache_startup_warnings *warnings)
+{
+ const ucache_shm_handler_entry *handler_entry, *preferred = NULL;
+ const char *model = UC_G(mem_model);
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ if (ucache_ctx_is_boundary(ucache_active_ctx())) {
+ if (ucache_try_storage_handler(
+ ucache_shared_boundary_handler_entry(),
+ &warnings->alloc_err_in,
+ &warnings->alloc_err_code
+ )
+ ) {
+ return true;
+ }
+
+ warnings->alloc_failed = true;
+
+ return false;
+ }
+#endif
+
+ if (model != NULL && model[0] != '\0') {
+ preferred = ucache_find_storage_handler(model);
+ if (preferred != NULL && ucache_try_preferred_storage_handler(preferred, warnings)) {
+ return true;
+ }
+ }
+
+ for (handler_entry = ucache_handler_table(); handler_entry->name; handler_entry++) {
+ if (handler_entry == preferred) {
+ continue;
+ }
+
+ if (ucache_try_storage_handler(handler_entry, &warnings->alloc_err_in, &warnings->alloc_err_code)) {
+ warnings->fallback_model = handler_entry->name;
+
+ return true;
+ }
+ }
+
+ warnings->alloc_failed = true;
+
+ return false;
+}
+
+static bool ucache_startup_storage_impl(ucache_startup_warnings *warnings)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (storage->initialized) {
+ return true;
+ }
+
+ if (!ucache_select_storage_handler(warnings)) {
+ ucache_reset_storage();
+
+ return false;
+ }
+
+ if (!ucache_commit_seg_prefix(warnings) || !ucache_create_lock()) {
+ goto bailout;
+ }
+
+ if (!ucache_wlock_negotiated_lock_model()) {
+ ucache_destroy_lock();
+
+ goto bailout;
+ }
+
+ if (!ucache_hdr_init_locked()) {
+ ucache_unlock();
+
+ ucache_destroy_lock();
+
+ goto bailout;
+ }
+
+ ucache_unlock();
+
+ if (storage->capacity_clamped && !ucache_capacity_clamp_warned) {
+ ucache_capacity_clamp_warned = true;
+
+ warnings->clamped_capacity = storage->capacity_memo;
+ }
+
+ if (ucache_hdr_ptr() != NULL) {
+ storage->lock_ops = ucache_lock_ops_for_model(ucache_hdr_ptr()->lock_model);
+ }
+
+#ifdef ZTS
+ ucache_atomic_store_32(&storage->startup_complete, 1);
+#endif
+
+ return true;
+
+bailout:
+ ucache_cleanup_seg(storage->handler, storage->seg);
+
+ ucache_reset_storage();
+
+ return false;
+}
+
+static bool ucache_startup_storage(void)
+{
+ ucache_startup_warnings warnings = {0};
+ bool result;
+
+ if (ucache_storage_startup_is_complete(&ucache_active_ctx()->storage)) {
+ return true;
+ }
+
+ ucache_startup_storage_lock();
+
+ result = ucache_startup_storage_impl(&warnings);
+
+ ucache_startup_storage_unlock();
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ ucache_shared_boundary_flush_dir_failure_log();
+#endif
+
+ if (warnings.preferred_failed_model != NULL) {
+ ucache_log_preferred_model_failure(&warnings, result);
+ }
+
+ if (warnings.alloc_failed) {
+ ucache_platform.log_alloc_failure(
+ "shared memory initialization failed",
+ warnings.alloc_err_in,
+ warnings.alloc_err_code
+ );
+ }
+
+ if (warnings.clamped_capacity != 0) {
+ ucache_warn(
+ "user_cache.entries_hint (" ZEND_LONG_FMT ") exceeds what user_cache.shm_size can index; clamping capacity to %u",
+ UC_G(entries_hint),
+ warnings.clamped_capacity
+ );
+ }
+
+ return result;
+}
+
+static void ucache_resolve_runtime(void)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+ ucache_runtime *runtime;
+ ucache_storage *storage = &ctx->storage;
+
+ ucache_reset_runtime();
+
+ runtime = ucache_active_runtime();
+ if (!runtime->enabled) {
+ return;
+ }
+
+ if (UC_G(in_req_shutdown)) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_REQ_SHUTDOWN);
+
+ return;
+ }
+
+ if (UC_G(req_unavailable_reason) != PHP_UCACHE_REASON_NONE) {
+ ucache_set_unavailable(UC_G(req_unavailable_reason));
+
+ return;
+ }
+
+ if (!ucache_runtime_opted_in ||
+ !ucache_exec_available()
+ ) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_SAPI_NOT_ENABLED);
+
+ return;
+ }
+
+ if (ucache_is_disabled_for_sapi()) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_DISABLED_BY_INI);
+
+ return;
+ }
+
+ if (!storage->initialized &&
+ ucache_requires_pre_req_storage()
+ ) {
+ ucache_set_unavailable(
+ ucache_ctx_is_boundary(ctx)
+ ? PHP_UCACHE_REASON_SHM_INIT_FAILED
+ : PHP_UCACHE_REASON_BACKEND_NOT_INITIALIZED_BEFORE_WORKER
+ );
+
+ return;
+ }
+
+ if (!ucache_startup_storage()) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_SHM_INIT_FAILED);
+
+ return;
+ }
+
+ if (ucache_requires_pre_req_storage() &&
+ !storage->initialized_before_req
+ ) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_BACKEND_INITIALIZED_AFTER_WORKER);
+
+ return;
+ }
+
+ ucache_set_available();
+}
+
+static bool ucache_claim_hdr_is_attached(const ucache_hdr *hdr)
+{
+ const php_ucache_partition *partition;
+ bool attached = false;
+
+ if (ucache_ctx_state.storage.base == hdr) {
+ return true;
+ }
+
+ ucache_boundary_partitions_lock();
+
+ for (partition = ucache_partitions; partition != NULL; partition = partition->next) {
+ if (partition->ctx.storage.base == hdr) {
+ attached = true;
+
+ break;
+ }
+ }
+
+ ucache_boundary_partitions_unlock();
+
+ return attached;
+}
+
+static void ucache_release_oldest_reader_claim(uint64_t my_pid)
+{
+ ucache_reader_claim *claims = UC_G(reader_claims);
+ ucache_reader_slot *slot = &claims[0].hdr->reader_slots[claims[0].slot_idx];
+
+ ucache_atomic_store_64(&slot->owner_start_time, 0);
+ ucache_atomic_cas_64(&slot->owner_pid, my_pid, 0);
+
+ UC_G(reader_claim_count)--;
+
+ memmove(claims, claims + 1, UC_G(reader_claim_count) * sizeof(*claims));
+}
+
+static int32_t ucache_claim_reader_slot(ucache_hdr *hdr)
+{
+ ucache_reader_slot *slot;
+ uint64_t my_pid, expected, owner_start_time;
+ uint32_t i, used;
+
+ my_pid = ucache_cached_pid();
+
+ if (UNEXPECTED(UC_G(reader_claim_pid) != my_pid)) {
+ UC_G(reader_claim_count) = 0;
+ UC_G(reader_claim_pid) = my_pid;
+ }
+
+ for (i = 0; i < UC_G(reader_claim_count); i++) {
+ if (UC_G(reader_claims)[i].hdr == hdr) {
+ return (int32_t) UC_G(reader_claims)[i].slot_idx;
+ }
+ }
+
+ if (UNEXPECTED(UC_G(reader_claim_failed_hdr) == hdr) && UC_G(reader_claim_failed_pid) == my_pid) {
+ return -1;
+ }
+
+ if (UC_G(reader_claim_count) == UCACHE_READER_CLAIM_MAX) {
+ ucache_release_oldest_reader_claim(my_pid);
+ }
+
+ for (i = 0; i < UCACHE_READER_SLOTS; i++) {
+ slot = &hdr->reader_slots[i];
+
+ if (ucache_atomic_load_64(&slot->owner_pid) == 0 &&
+ ucache_atomic_cas_64(&slot->owner_pid, 0, my_pid)
+ ) {
+ goto done;
+ }
+ }
+
+ for (i = 0; i < UCACHE_READER_SLOTS; i++) {
+ slot = &hdr->reader_slots[i];
+
+ owner_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ expected = ucache_atomic_load_64(&slot->owner_pid);
+
+ if (expected == 0 ||
+ ucache_atomic_load_32(&slot->active) != 0 ||
+ !ucache_owner_is_dead(expected, owner_start_time) ||
+ !ucache_atomic_cas_64(&slot->owner_start_time, owner_start_time, 0) ||
+ !ucache_atomic_cas_64(&slot->owner_pid, expected, my_pid)
+ ) {
+ continue;
+ }
+
+ goto done;
+ }
+
+ UC_G(reader_claim_failed_hdr) = hdr;
+ UC_G(reader_claim_failed_pid) = my_pid;
+
+ return -1;
+
+done:
+ ucache_atomic_store_64(&slot->owner_start_time, ucache_cached_self_start_time_token(my_pid));
+
+ for (;;) {
+ used = ucache_atomic_load_32(&hdr->reader_slots_used);
+ if (used > i || ucache_atomic_cas_32(&hdr->reader_slots_used, used, i + 1)) {
+ break;
+ }
+ }
+
+ UC_G(reader_claims)[UC_G(reader_claim_count)].hdr = hdr;
+ UC_G(reader_claims)[UC_G(reader_claim_count)].slot_idx = i;
+ UC_G(reader_claim_count)++;
+
+ return (int32_t) i;
+}
+
+uint32_t ucache_calc_entry_lock_capacity(uint32_t capacity)
+{
+ uint32_t want = capacity / 16,
+ lock_capacity = UCACHE_ENTRY_LOCK_MAX_CAPACITY
+ ;
+
+ while (lock_capacity > UCACHE_ENTRY_LOCK_MIN_CAPACITY && lock_capacity > want) {
+ lock_capacity >>= 1;
+ }
+
+ return lock_capacity;
+}
+
+uint32_t ucache_calc_capacity(size_t size, bool *clamped)
+{
+ uint64_t hint, want, max_capacity;
+ uint32_t capacity, lock_capacity, next_lock_capacity;
+ size_t reserve, lock_bytes, fixed_bytes;
+
+ *clamped = false;
+
+ hint = UC_G(entries_hint) > 0
+ ? (uint64_t) UC_G(entries_hint)
+ : (uint64_t) (size / UCACHE_AUTO_SEG_BYTES_PER_ENTRY)
+ ;
+
+ if (hint < UCACHE_MIN_CAPACITY) {
+ hint = UCACHE_MIN_CAPACITY;
+ }
+
+ want = hint + (hint + 2) / 3;
+ capacity = ucache_next_prime((uint32_t) want);
+
+ reserve = size / 2;
+ lock_capacity = ucache_calc_entry_lock_capacity(capacity);
+
+ for (;;) {
+ lock_bytes = (size_t) lock_capacity * sizeof(ucache_entry_lock_record);
+ fixed_bytes = sizeof(ucache_hdr) + lock_bytes +
+ ucache_free_bins_bytes(ucache_free_bin_count_for_seg(size))
+ ;
+
+ if (reserve <= fixed_bytes) {
+ *clamped = UC_G(entries_hint) > 0;
+
+ return UCACHE_MIN_CAPACITY;
+ }
+
+ max_capacity = (reserve - fixed_bytes) / UCACHE_TABLE_SLOT_SIZE;
+ if (capacity > max_capacity) {
+ capacity = ucache_prev_prime(
+ max_capacity > UCACHE_MIN_CAPACITY
+ ? (uint32_t) max_capacity
+ : UCACHE_MIN_CAPACITY
+ );
+
+ *clamped = UC_G(entries_hint) > 0;
+ }
+
+ next_lock_capacity = ucache_calc_entry_lock_capacity(capacity);
+ if (next_lock_capacity == lock_capacity) {
+ break;
+ }
+
+ lock_capacity = next_lock_capacity;
+ }
+
+ return capacity;
+}
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+const uint8_t *ucache_shared_boundary_digest_memo(ucache_ctx *ctx, size_t requested_size)
+{
+ ucache_storage *storage = &ctx->storage;
+
+ if (!storage->boundary_digest_memoized) {
+ ucache_shared_boundary_digest(ctx, requested_size, storage->boundary_digest_memo);
+
+ storage->boundary_digest_memoized = true;
+ }
+
+ return storage->boundary_digest_memo;
+}
+
+bool ucache_hdr_boundary_lock_file_replaced_locked(const ucache_hdr *hdr)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+
+ return ucache_ctx_is_boundary(ctx) &&
+ hdr->lock_model == UCACHE_LOCK_MODEL_FCNTL &&
+ (
+ hdr->lock_file_dev != ucache_boundary_seg_of(&ctx->storage)->lock_dev ||
+ hdr->lock_file_ino != ucache_boundary_seg_of(&ctx->storage)->lock_ino
+ )
+ ;
+}
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+
+bool ucache_quiesce_graph_payloads_locked(void)
+{
+ ucache_hdr *hdr;
+ ucache_reader_slot *slot;
+ uint64_t waited_us = 0, owner_pid, owner_start_time;
+ uint32_t i, used, count, spin = 0;
+
+ if (UCACHE_DEBUG_FAULT("FORCE_GRAPH_NOT_QUIESCENT")) {
+ return false;
+ }
+
+ if (!UC_G(write_seq_bumped)) {
+ return true;
+ }
+
+ if (UC_G(reader_drain_state) != 0) {
+ return UC_G(reader_drain_state) > 0;
+ }
+
+ hdr = ucache_hdr_ptr();
+ if (hdr == NULL) {
+ return true;
+ }
+
+ ucache_atomic_fence_seq_cst();
+
+ for (;;) {
+ used = MIN(ucache_atomic_load_32(&hdr->reader_slots_used), UCACHE_READER_SLOTS);
+
+ count = 0;
+ for (i = 0; i < used; i++) {
+ if (ucache_atomic_load_32(&hdr->reader_slots[i].active) != 0) {
+ count++;
+ }
+ }
+
+ if (count == 0) {
+ UC_G(reader_drain_state) = 1;
+
+ return true;
+ }
+
+ spin++;
+
+ if ((spin & 0xFFU) == 0) {
+ for (i = 0; i < used; i++) {
+ slot = &hdr->reader_slots[i];
+
+ owner_start_time = ucache_atomic_load_64(&slot->owner_start_time);
+ owner_pid = ucache_atomic_load_64(&slot->owner_pid);
+
+ if (ucache_atomic_load_32(&slot->active) == 0 ||
+ owner_pid == 0 ||
+ !ucache_owner_is_dead(owner_pid, owner_start_time)
+ ) {
+ continue;
+ }
+
+ if (ucache_atomic_load_64(&slot->owner_pid) == owner_pid &&
+ ucache_atomic_load_64(&slot->owner_start_time) == owner_start_time
+ ) {
+ ucache_atomic_cas_32(&slot->active, 1, 0);
+ }
+ }
+ }
+
+ if (spin > UCACHE_READER_DRAIN_SPIN) {
+ waited_us += ucache_platform.sleep_us(50);
+
+ if (waited_us > UCACHE_READER_DRAIN_TIMEOUT_US) {
+ UC_G(reader_drain_state) = -1;
+
+ return false;
+ }
+ }
+ }
+}
+
+#if defined(ZTS) && !defined(ZEND_WIN32)
+void ucache_lock_storage_startup_before_fork(void)
+{
+ ucache_startup_storage_lock();
+}
+
+void ucache_unlock_storage_startup_after_fork(void)
+{
+ ucache_startup_storage_unlock();
+}
+
+void ucache_reinit_storage_locks_after_fork(void)
+{
+ php_ucache_partition *partition;
+
+ ucache_zts_lock_reinit_after_fork(&ucache_ctx_state.storage);
+
+ for (partition = ucache_partitions; partition != NULL; partition = partition->next) {
+ ucache_zts_lock_reinit_after_fork(&partition->ctx.storage);
+ }
+}
+#endif
+
+bool ucache_optimistic_reader_begin(ucache_hdr *hdr, uint32_t *slot_idx_ptr)
+{
+ int32_t slot_idx;
+
+ if (!UCACHE_OPTIMISTIC_ENABLED) {
+ return false;
+ }
+
+ slot_idx = ucache_claim_reader_slot(hdr);
+ if (slot_idx < 0) {
+ return false;
+ }
+
+ *slot_idx_ptr = (uint32_t) slot_idx;
+
+ ucache_atomic_store_32(&hdr->reader_slots[slot_idx].active, 1);
+
+ ucache_atomic_fence_seq_cst();
+
+ return true;
+}
+
+void ucache_optimistic_reader_end(ucache_hdr *hdr, uint32_t slot_idx)
+{
+ ucache_atomic_store_32(&hdr->reader_slots[slot_idx].active, 0);
+}
+
+void ucache_abandon_graph_pin_claims(void)
+{
+ ucache_graph_pin_claim *claims = UC_G(graph_pin_claims);
+ ucache_graph_pin_slot *slot;
+ uint32_t i = 0;
+ int expected, my_pid = (int) (uint32_t) ucache_cached_pid();
+
+ while (i < UC_G(graph_pin_claim_count)) {
+ slot = &claims[i].hdr->graph_pin_slots[claims[i].slot_idx];
+ expected = atomic_load(&slot->owner_pid);
+ if (expected == my_pid && atomic_load(&slot->pin_count) == 0) {
+ i++;
+
+ continue;
+ }
+
+ claims[i] = claims[--UC_G(graph_pin_claim_count)];
+ if (expected == my_pid) {
+ ucache_atomic_store_64(&slot->owner_start_time, 0);
+
+ atomic_compare_exchange_strong(
+ &slot->owner_pid,
+ &expected,
+ UCACHE_GRAPH_PIN_OWNER_ABANDONED
+ );
+ }
+ }
+}
+
+void ucache_release_thread_graph_pin_claims(ucache_globals *globals)
+{
+ ucache_graph_pin_slot *slot;
+ uint32_t i;
+ int expected;
+
+ for (i = 0; i < globals->graph_pin_claim_count; i++) {
+ if (!ucache_claim_hdr_is_attached(globals->graph_pin_claims[i].hdr)) {
+ continue;
+ }
+
+ slot = &globals->graph_pin_claims[i].hdr->graph_pin_slots[
+ globals->graph_pin_claims[i].slot_idx
+ ];
+
+ expected = atomic_load(&slot->owner_pid);
+ if (expected != (int) (uint32_t) ucache_cached_pid()) {
+ continue;
+ }
+
+ ucache_atomic_store_64(&slot->owner_start_time, 0);
+
+ if (atomic_load(&slot->pin_count) != 0) {
+ atomic_compare_exchange_strong(
+ &slot->owner_pid,
+ &expected,
+ UCACHE_GRAPH_PIN_OWNER_ABANDONED
+ );
+
+ continue;
+ }
+
+ atomic_compare_exchange_strong(&slot->owner_pid, &expected, 0);
+ }
+
+ globals->graph_pin_claim_count = 0;
+}
+
+void ucache_release_thread_reader_claims(ucache_globals *globals)
+{
+ ucache_reader_slot *slot;
+ uint64_t my_pid;
+ uint32_t i;
+
+ if (globals->reader_claim_count == 0) {
+ return;
+ }
+
+ my_pid = ucache_cached_pid();
+
+ for (i = 0; i < globals->reader_claim_count; i++) {
+ if (!ucache_claim_hdr_is_attached(globals->reader_claims[i].hdr)) {
+ continue;
+ }
+
+ slot = &globals->reader_claims[i].hdr->reader_slots[globals->reader_claims[i].slot_idx];
+ if (ucache_atomic_load_64(&slot->owner_pid) != my_pid) {
+ continue;
+ }
+
+ ucache_atomic_store_32(&slot->active, 0);
+
+ ucache_atomic_store_64(&slot->owner_start_time, 0);
+ ucache_atomic_cas_64(&slot->owner_pid, my_pid, 0);
+ }
+
+ globals->reader_claim_count = 0;
+}
+
+ZEND_API bool php_ucache_is_enabled_by_ini(void)
+{
+ return ucache_globals_allocated() && !ucache_is_disabled_for_sapi() && UC_G(shm_size) != 0;
+}
+
+void ucache_reset_runtime(void)
+{
+ ucache_runtime *runtime = ucache_active_runtime();
+
+ UC_G(runtime_resolved) = false;
+
+ memset(runtime, 0, sizeof(*runtime));
+
+ runtime->configured_mem = UC_G(shm_size);
+
+ runtime->enabled = runtime->configured_mem != 0;
+ if (!runtime->enabled) {
+ runtime->available = false;
+ runtime->failure_reason = PHP_UCACHE_REASON_DISABLED_BY_INI;
+ }
+}
+
+bool ucache_storage_startup_is_complete(const ucache_storage *storage)
+{
+#ifdef ZTS
+ return ucache_atomic_load_32(&storage->startup_complete) != 0;
+#else
+ return storage->initialized && storage->lock_initialized;
+#endif
+}
+
+void ucache_reset_storage(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ memset(storage, 0, sizeof(*storage));
+
+ storage->lock_file = -1;
+}
+
+bool ucache_hdr_init_locked(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ uint32_t capacity, data_offset;
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ ucache_hdr_layout_memo(storage, &capacity, &data_offset);
+
+ if (hdr->magic == UCACHE_MAGIC) {
+ if (ucache_hdr_layout_matches_memo_locked(storage, hdr, capacity, data_offset)) {
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+ if (ucache_hdr_boundary_lock_file_replaced_locked(hdr)) {
+ ucache_shared_boundary_retire_seg_name();
+
+ return false;
+ }
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+
+ return ucache_hdr_boundary_identity_matches_locked(hdr, storage->size);
+ }
+
+ if (ucache_ctx_is_boundary(ucache_active_ctx())) {
+ return false;
+ }
+ }
+
+ return ucache_hdr_format_fresh_locked(hdr, capacity, data_offset);
+}
+
+bool ucache_mem_model_is_available(const char *model)
+{
+ return ucache_find_storage_handler(model) != NULL;
+}
+
+size_t ucache_shm_size_min(void)
+{
+ size_t size = 0, smallest;
+
+ while ((smallest = ucache_shm_size_floor(size) + UCACHE_BLOCK_MIN_SIZE) > size) {
+ size = smallest;
+ }
+
+ return size;
+}
+
+bool ucache_hdr_adoptable_locked(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+ ucache_hdr *hdr = ucache_hdr_ptr();
+ uint32_t capacity, data_offset;
+
+ if (hdr == NULL) {
+ return false;
+ }
+
+ ucache_hdr_layout_memo(storage, &capacity, &data_offset);
+
+ if (hdr->magic != UCACHE_MAGIC ||
+ !ucache_hdr_layout_matches_memo_locked(storage, hdr, capacity, data_offset)
+ ) {
+ return false;
+ }
+
+ return ucache_hdr_boundary_identity_matches_locked(hdr, storage->size);
+}
+
+bool ucache_startup_storage_before_req(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ if (!ucache_runtime_opted_in) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_SAPI_NOT_ENABLED);
+
+ return true;
+ }
+
+ if (!ucache_startup_storage()) {
+ ucache_set_unavailable(PHP_UCACHE_REASON_SHM_INIT_FAILED);
+
+ return false;
+ }
+
+ storage->initialized_before_req = true;
+
+ return true;
+}
+
+void ucache_shutdown_storage(void)
+{
+ ucache_storage *storage = &ucache_active_ctx()->storage;
+
+ ucache_startup_storage_lock();
+
+ ucache_destroy_lock();
+
+ ucache_cleanup_seg(storage->handler, storage->seg);
+
+ ucache_reset_storage();
+
+ ucache_startup_storage_unlock();
+}
+
+void ucache_classify_sapi(void)
+{
+ const char *name = sapi_module.name;
+ uint32_t flags = 0;
+
+ if (name != NULL) {
+ if (strcmp(name, "cli") == 0 || strcmp(name, "phpdbg") == 0) {
+ flags |= UCACHE_SAPI_CLI;
+ }
+
+ if (strcmp(name, "fpm-fcgi") == 0 || strcmp(name, "apache2handler") == 0 || strcmp(name, "cli-server") == 0) {
+ flags |= UCACHE_SAPI_PRE_REQ_STORAGE;
+ }
+ }
+
+ ucache_classified_sapi_flags = flags;
+ ucache_classified_sapi_name = name;
+}
+
+void ucache_ensure_ready_impl(void)
+{
+ ucache_ctx *ctx = ucache_active_ctx();
+
+ ucache_resolve_runtime();
+
+ UC_G(runtime_resolved) = true;
+ UC_G(runtime_resolved_ctx) = ctx;
+ UC_G(runtime_resolved_enabled) = UC_G(enable);
+}
diff --git a/ext/user_cache/user_cache_storage.h b/ext/user_cache/user_cache_storage.h
new file mode 100644
index 000000000000..c9eb422af85d
--- /dev/null
+++ b/ext/user_cache/user_cache_storage.h
@@ -0,0 +1,279 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef UCACHE_STORAGE_H
+#define UCACHE_STORAGE_H
+
+#include "user_cache_internal.h"
+#include "user_cache_storage_portability.h"
+
+#define UCACHE_START_TIME_EXITED UINT64_MAX
+#define UCACHE_OWNER_TOKEN_PID_MIX UINT64_C(0x9E3779B97F4A7C15)
+
+#define UCACHE_ENTRY_LOCK_MAX_CAPACITY 1024U
+#define UCACHE_ENTRY_LOCK_WAIT_TIMEOUT_US (10U * 1000U * 1000U)
+#define UCACHE_ENTRY_LOCK_WAIT_DEBUG_TIMEOUT_US (300U * 1000U)
+
+typedef struct {
+ uint64_t owner_pid;
+ uint64_t owner_start_time;
+ uint64_t owner_token;
+ zend_ulong hash;
+ uint32_t slot_idx;
+ uint32_t expires_at;
+} ucache_entry_lock_holder;
+
+typedef struct {
+ uint32_t (*sleep_us)(uint32_t interval_us);
+ uint64_t (*proc_start_time_token)(uint64_t pid);
+ bool (*proc_has_exited)(uint64_t pid);
+ int (*alloc_err_code)(void);
+ void (*log_alloc_failure)(const char *failure, const char *err_in, int err_code);
+} ucache_platform_ops;
+
+extern const ucache_platform_ops ucache_platform;
+
+uint32_t ucache_calc_entry_lock_capacity(uint32_t capacity);
+uint32_t ucache_calc_capacity(size_t size, bool *clamped);
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+const uint8_t *ucache_shared_boundary_digest_memo(ucache_ctx *ctx, size_t requested_size);
+bool ucache_hdr_boundary_lock_file_replaced_locked(const ucache_hdr *hdr);
+void ucache_shared_boundary_digest(
+ const ucache_ctx *ctx,
+ size_t requested_size,
+ uint8_t digest[32]);
+void ucache_shared_boundary_retire_seg_name(void);
+void ucache_shared_boundary_flush_dir_failure_log(void);
+const ucache_shm_handler_entry *ucache_shared_boundary_handler_entry(void);
+#endif /* UCACHE_HAVE_BOUNDARY_SHM */
+#ifdef ZEND_WIN32
+bool ucache_win32_ctx_is_proc_private(const ucache_ctx *ctx);
+bool ucache_win32_owned_by_cur_user(HANDLE handle, SE_OBJECT_TYPE type);
+bool ucache_win32_load_salt(const char **err_in);
+void ucache_win32_format_name(char *buf, size_t buf_size, const char *name, size_t unique_id);
+bool ucache_win32_commit_to(ucache_win32_seg *seg, size_t end_bytes);
+void ucache_win32_reset_range(ucache_win32_seg *seg, size_t start_bytes, size_t end_bytes);
+#endif /* ZEND_WIN32 */
+const ucache_shm_handler_entry *ucache_handler_table(void);
+void ucache_cleanup_seg(const ucache_shm_handlers *handler, ucache_shm_seg *seg);
+#ifdef UCACHE_HAVE_SHARED_MUTEX
+bool ucache_shared_mutex_init(ucache_hdr *hdr);
+#endif /* UCACHE_HAVE_SHARED_MUTEX */
+const ucache_lock_ops *ucache_lock_ops_for_model(uint32_t lock_model);
+bool ucache_create_lock(void);
+void ucache_destroy_lock(void);
+bool ucache_wlock_entry_lock_table(void);
+void ucache_write_section_announce(ucache_hdr *hdr);
+bool ucache_wlock_negotiated_lock_model(void);
+bool ucache_entry_lock_record_is_active_locked(
+ ucache_entry_lock_record *record,
+ uint64_t now_rel);
+void ucache_sweep_entry_locks_locked(ucache_hdr *hdr, uint64_t now);
+bool ucache_find_entry_lock_record_slot_locked(
+ ucache_hdr *hdr,
+ zend_string *key,
+ zend_ulong hash,
+ uint32_t *slot_idx,
+ bool *found);
+void ucache_entry_lock_holder_capture(
+ ucache_entry_lock_holder *holder,
+ ucache_hdr *hdr,
+ uint32_t slot_idx);
+bool ucache_entry_lock_holder_unchanged(
+ const ucache_storage *storage,
+ const ucache_entry_lock_holder *holder);
+void ucache_drain_deferred_entry_lock_releases(void);
+void ucache_ensure_entry_lock_owner(void);
+#ifdef ZEND_WIN32
+uint32_t ucache_win32_sleep_us(uint32_t interval_us);
+uint64_t ucache_win32_proc_start_time_token(uint64_t pid);
+bool ucache_win32_proc_has_exited(uint64_t pid);
+int ucache_win32_alloc_err_code(void);
+void ucache_win32_log_alloc_failure(const char *failure, const char *err_in, int err_code);
+#else
+uint32_t ucache_posix_sleep_us(uint32_t interval_us);
+uint64_t ucache_posix_proc_start_time_token(uint64_t pid);
+bool ucache_posix_proc_has_exited(uint64_t pid);
+int ucache_posix_alloc_err_code(void);
+void ucache_posix_log_alloc_failure(const char *failure, const char *err_in, int err_code);
+#endif /* ZEND_WIN32 */
+bool ucache_recover_after_owner_death_locked(void);
+#if ZEND_DEBUG
+void ucache_debug_reserve_data_below_4g_locked(ucache_hdr *hdr);
+#endif
+
+static zend_always_inline size_t ucache_free_bins_bytes(uint32_t bin_count)
+{
+ return ((size_t) bin_count + ucache_free_bin_words(bin_count)) * sizeof(uint32_t);
+}
+
+static zend_always_inline uint32_t ucache_atomic_load_32(const uint32_t *target)
+{
+ return UCACHE_ATOMIC_LOAD_32(target);
+}
+
+static zend_always_inline void ucache_atomic_store_32(uint32_t *target, uint32_t val)
+{
+ UCACHE_ATOMIC_STORE_32(target, val);
+}
+
+static zend_always_inline void ucache_atomic_fence_seq_cst(void)
+{
+ UCACHE_ATOMIC_FENCE_SEQ_CST();
+}
+
+static zend_always_inline uint32_t ucache_size_class(uint32_t size)
+{
+ uint32_t shift;
+
+ if (size < UCACHE_SIZE_CLASS_EXACT_LIMIT) {
+ return size < 16U ? 0U : size / 8U - 2U;
+ }
+
+ shift = ucache_floor_log2(size);
+
+ return UCACHE_SIZE_CLASS_EXACT_BINS
+ + ((shift - UCACHE_SIZE_CLASS_MIN_SHIFT) << UCACHE_SIZE_CLASS_STEP_SHIFT)
+ + ((size - (1U << shift)) >> (shift - UCACHE_SIZE_CLASS_STEP_SHIFT))
+ ;
+}
+
+static zend_always_inline uint32_t ucache_entry_lock_table_idx(
+ const ucache_hdr *hdr,
+ zend_ulong hash)
+{
+ return (uint32_t) (hash & (hdr->entry_lock_capacity - 1));
+}
+
+static zend_always_inline ucache_entry_lock *ucache_find_local_entry_lock(
+ const ucache_ctx *ctx,
+ zend_string *key)
+{
+ ucache_entry_lock *lock;
+
+ if (UC_G(entry_lock_table) == NULL) {
+ return NULL;
+ }
+
+ lock = zend_hash_find_ptr(UC_G(entry_lock_table), key);
+ if (lock == NULL || lock->ctx != ctx) {
+ return NULL;
+ }
+
+ return lock;
+}
+
+static zend_always_inline uint64_t ucache_proc_owner_token(uint64_t pid)
+{
+ uint64_t start_time = ucache_platform.proc_start_time_token(pid), token;
+
+ if (start_time == 0 || start_time == UCACHE_START_TIME_EXITED) {
+ return start_time;
+ }
+
+ token = start_time ^ (pid * UCACHE_OWNER_TOKEN_PID_MIX);
+
+ return token == 0 || token == UCACHE_START_TIME_EXITED ? 1 : token;
+}
+
+static zend_always_inline uint64_t ucache_cached_self_start_time_token(uint64_t self_pid)
+{
+ if (UC_G(self_start_time_pid) != self_pid ||
+ UC_G(self_start_time_token) == 0
+ ) {
+ UC_G(self_start_time_pid) = self_pid;
+ UC_G(self_start_time_token) = ucache_proc_owner_token(self_pid);
+ }
+
+ return UC_G(self_start_time_token);
+}
+
+static zend_always_inline void ucache_seq_announce(uint64_t *seq, uint64_t val)
+{
+ ucache_atomic_store_64(seq, val);
+
+ ucache_atomic_fence_seq_cst();
+}
+
+static zend_always_inline void ucache_seq_publish(uint64_t *seq, uint64_t val)
+{
+ ucache_atomic_store_64(seq, val);
+}
+
+static zend_always_inline uint64_t ucache_view_committed_bytes(const ucache_storage *storage)
+{
+#ifdef ZEND_WIN32
+ return ((const ucache_win32_seg *) storage->seg)->committed_bytes;
+#else
+ return storage->size;
+#endif
+}
+
+static zend_always_inline bool ucache_hdr_committed_end_in_seg(
+ const ucache_storage *storage,
+ const ucache_hdr *hdr)
+{
+#ifdef ZEND_WIN32
+ return hdr->committed_end <= storage->size;
+#else
+ (void) storage;
+ (void) hdr;
+
+ return true;
+#endif
+}
+
+static zend_always_inline bool ucache_hdr_data_bounds_match(
+ const ucache_storage *storage,
+ const ucache_hdr *hdr)
+{
+ return (size_t) hdr->data_offset <= storage->size &&
+ hdr->data_size == (uint64_t) (storage->size - hdr->data_offset) &&
+ hdr->next_free <= hdr->data_size &&
+ ucache_hdr_committed_end_in_seg(storage, hdr)
+ ;
+}
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+static zend_always_inline ucache_boundary_seg *ucache_boundary_seg_of(const ucache_storage *storage)
+{
+ ZEND_ASSERT(storage->seg != NULL);
+
+ return (ucache_boundary_seg *) storage->seg;
+}
+#endif
+
+static zend_always_inline uint32_t ucache_entry_lock_wait_timeout_us(void)
+{
+ return UCACHE_DEBUG_FAULT("SHORT_ENTRY_LOCK_WAIT")
+ ? UCACHE_ENTRY_LOCK_WAIT_DEBUG_TIMEOUT_US
+ : UCACHE_ENTRY_LOCK_WAIT_TIMEOUT_US
+ ;
+}
+
+static inline uint32_t ucache_sleep_entry_lock_retry_interval(uint64_t waited_us)
+{
+ uint32_t interval_us = UCACHE_ENTRY_LOCK_RETRY_INTERVAL_US;
+
+ if (waited_us < UCACHE_ENTRY_LOCK_RETRY_INTERVAL_US) {
+ interval_us = MIN(
+ (uint32_t) waited_us + UCACHE_ENTRY_LOCK_RETRY_INITIAL_US,
+ UCACHE_ENTRY_LOCK_RETRY_INTERVAL_US - (uint32_t) waited_us
+ );
+ }
+
+ return ucache_platform.sleep_us(interval_us);
+}
+
+#endif /* UCACHE_STORAGE_H */
diff --git a/ext/user_cache/user_cache_storage_portability.h b/ext/user_cache/user_cache_storage_portability.h
new file mode 100644
index 000000000000..1951f34886c8
--- /dev/null
+++ b/ext/user_cache/user_cache_storage_portability.h
@@ -0,0 +1,129 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef UCACHE_STORAGE_PORTABILITY_H
+#define UCACHE_STORAGE_PORTABILITY_H
+
+#include "user_cache_internal.h"
+
+#ifdef ZEND_WIN32
+# include "zend_execute.h"
+# include "zend_system_id.h"
+# include "win32/ioutil.h"
+
+# include
+# include
+# include
+# include
+# include
+# include
+#else
+# include
+# include
+# include
+# include
+# include
+# include
+# ifdef HAVE_UNISTD_H
+# include
+# endif
+# if defined(UCACHE_USE_MMAP) || \
+ defined(UCACHE_HAVE_BOUNDARY_SHM) || \
+ (defined(__linux__) && defined(HAVE_MEMFD_CREATE))
+# include
+# endif
+#endif
+
+#if defined(__APPLE__) || defined(__FreeBSD__)
+# include
+# ifdef __FreeBSD__
+# include
+# include
+# include
+# endif
+#elif defined(__NetBSD__) || defined(__OpenBSD__)
+# include
+# include
+# include
+#elif defined(__DragonFly__)
+# include
+# include
+# include
+# include
+#elif defined(__sun)
+# include
+#elif defined(_AIX)
+# include
+# include
+#endif
+
+#ifdef UCACHE_USE_MMAP
+# if defined(MAP_ANON) && !defined(MAP_ANONYMOUS)
+# define MAP_ANONYMOUS MAP_ANON
+# endif
+#endif
+
+#ifdef ZEND_WIN32
+# define UCACHE_WIN32_MAPPING_NAME "PhpUserCache.SharedMemoryArea"
+# define UCACHE_WIN32_MAPPING_MUTEX_NAME "PhpUserCache.SharedMemoryMutex"
+# define UCACHE_WIN32_LOCK_FILE_NAME "PhpUserCache.LockFile"
+# define UCACHE_WIN32_COMMIT_CHUNK ((size_t) 1024 * 1024)
+#endif
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+# define UCACHE_BOUNDARY_ID_LEN 24U
+#endif
+
+#define UCACHE_ENTRY_LOCK_RETRY_INTERVAL_US 10000U
+#define UCACHE_ENTRY_LOCK_RETRY_INITIAL_US 250U
+
+#ifdef ZTS
+# ifdef ZEND_WIN32
+# define UCACHE_STARTUP_LOCK_INITIALIZER SRWLOCK_INIT
+# else
+# define UCACHE_STARTUP_LOCK_INITIALIZER PTHREAD_MUTEX_INITIALIZER
+# endif
+#endif
+
+#ifdef ZEND_WIN32
+typedef struct _ucache_win32_seg {
+ ucache_shm_seg seg;
+ HANDLE memfile;
+ size_t committed_bytes;
+} ucache_win32_seg;
+#endif
+
+#ifdef UCACHE_HAVE_BOUNDARY_SHM
+typedef struct _ucache_boundary_seg {
+ ucache_shm_seg seg;
+ struct _ucache_boundary_seg *next;
+ uint64_t shm_dev;
+ uint64_t shm_ino;
+ uint64_t lock_dev;
+ uint64_t lock_ino;
+ int dir_fd;
+ int lock_fd;
+ char id[UCACHE_BOUNDARY_ID_LEN + 1];
+} ucache_boundary_seg;
+#endif
+
+#ifdef ZTS
+# ifdef ZEND_WIN32
+typedef SRWLOCK ucache_startup_lock;
+# else
+typedef pthread_mutex_t ucache_startup_lock;
+# endif
+#endif
+
+#endif /* UCACHE_STORAGE_PORTABILITY_H */
diff --git a/ext/zend_test/config.m4 b/ext/zend_test/config.m4
index f49221b9fb64..7ee856036a32 100644
--- a/ext/zend_test/config.m4
+++ b/ext/zend_test/config.m4
@@ -9,6 +9,7 @@ if test "$PHP_ZEND_TEST" != "no"; then
iterators.c
object_handlers.c
observer.c
+ serialization.c
test.c
zend_mm_custom_handlers.c
]),
diff --git a/ext/zend_test/config.w32 b/ext/zend_test/config.w32
index 1e9ae1545dcb..19daf8b14076 100644
--- a/ext/zend_test/config.w32
+++ b/ext/zend_test/config.w32
@@ -3,5 +3,5 @@
ARG_ENABLE("zend-test", "enable zend_test extension", "no");
if (PHP_ZEND_TEST != "no") {
- EXTENSION("zend_test", "test.c observer.c fiber.c iterators.c object_handlers.c zend_mm_custom_handlers.c", PHP_ZEND_TEST_SHARED, "/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1");
+ EXTENSION("zend_test", "test.c observer.c fiber.c iterators.c object_handlers.c serialization.c zend_mm_custom_handlers.c", PHP_ZEND_TEST_SHARED, "/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1");
}
diff --git a/ext/zend_test/serialization.c b/ext/zend_test/serialization.c
new file mode 100644
index 000000000000..42fac516bee0
--- /dev/null
+++ b/ext/zend_test/serialization.c
@@ -0,0 +1,174 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+*/
+
+#include "php.h"
+#include "ext/standard/php_var.h"
+#include "zend_exceptions.h"
+#include "zend_smart_str.h"
+#include "serialization.h"
+#include "serialization_arginfo.h"
+
+static zend_class_entry *zend_test_sleep_object_ce;
+static zend_class_entry *zend_test_sleep_object_with_custom_create_ce;
+static zend_class_entry *zend_test_legacy_serialize_object_ce;
+static zend_object_handlers zend_test_custom_create_object_handlers;
+
+static zend_object *zend_test_custom_create_object_new(zend_class_entry *ce)
+{
+ zend_object *obj = zend_objects_new(ce);
+
+ object_properties_init(obj, ce);
+ obj->handlers = &zend_test_custom_create_object_handlers;
+
+ return obj;
+}
+
+static void zend_test_initialized_property_names(zend_object *obj, zval *return_value)
+{
+ HashTable *props = zend_std_get_properties(obj);
+ zend_string *key;
+ const char *class_name, *prop_name;
+ size_t prop_name_len;
+ zval *value;
+
+ array_init(return_value);
+
+ ZEND_HASH_FOREACH_STR_KEY_VAL(props, key, value) {
+ if (Z_TYPE_P(value) == IS_INDIRECT) {
+ value = Z_INDIRECT_P(value);
+ }
+
+ if (key == NULL || Z_TYPE_P(value) == IS_UNDEF) {
+ continue;
+ }
+
+ zend_unmangle_property_name_ex(key, &class_name, &prop_name, &prop_name_len);
+ add_next_index_stringl(return_value, prop_name, prop_name_len);
+ } ZEND_HASH_FOREACH_END();
+}
+
+static int zend_test_legacy_serialize(
+ zval *object,
+ unsigned char **buffer,
+ size_t *buf_len,
+ zend_serialize_data *data)
+{
+ php_serialize_data_t var_hash = (php_serialize_data_t) data;
+ smart_str buf = {0};
+ zval rv, *value;
+
+ value = zend_read_property(zend_test_legacy_serialize_object_ce, Z_OBJ_P(object), ZEND_STRL("data"), true, &rv);
+
+ php_var_serialize(&buf, value, &var_hash);
+
+ if (EG(exception) || buf.s == NULL) {
+ smart_str_free(&buf);
+
+ return FAILURE;
+ }
+
+ *buffer = (unsigned char *) estrndup(ZSTR_VAL(buf.s), ZSTR_LEN(buf.s));
+ *buf_len = ZSTR_LEN(buf.s);
+
+ smart_str_free(&buf);
+
+ return SUCCESS;
+}
+
+static int zend_test_legacy_unserialize(
+ zval *object,
+ zend_class_entry *ce,
+ const unsigned char *buf,
+ size_t buf_len,
+ zend_unserialize_data *data)
+{
+ php_unserialize_data_t *var_hash = (php_unserialize_data_t *) data;
+ const unsigned char *pos = buf;
+ zval *value;
+
+ if (object_init_ex(object, ce) != SUCCESS) {
+ return FAILURE;
+ }
+
+ value = var_tmp_var(var_hash);
+ if (!php_var_unserialize(value, &pos, buf + buf_len, var_hash) || pos != buf + buf_len) {
+ if (!EG(exception)) {
+ zend_throw_exception(NULL, "Invalid ZendTestLegacySerializeObject payload", 0);
+ }
+
+ return FAILURE;
+ }
+
+ zend_update_property(ce, Z_OBJ_P(object), ZEND_STRL("data"), value);
+
+ return SUCCESS;
+}
+
+ZEND_METHOD(ZendTestSleepObject, __construct)
+{
+ zval *public_value = NULL, *protected_value = NULL, *private_value = NULL;
+ zend_object *obj = Z_OBJ_P(ZEND_THIS);
+
+ ZEND_PARSE_PARAMETERS_START(0, 3)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_ZVAL(public_value)
+ Z_PARAM_ZVAL(protected_value)
+ Z_PARAM_ZVAL(private_value)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (public_value != NULL) {
+ zend_update_property(zend_test_sleep_object_ce, obj, ZEND_STRL("public"), public_value);
+ }
+
+ if (protected_value != NULL) {
+ zend_update_property(zend_test_sleep_object_ce, obj, ZEND_STRL("protected"), protected_value);
+ }
+
+ if (private_value != NULL) {
+ zend_update_property(zend_test_sleep_object_ce, obj, ZEND_STRL("private"), private_value);
+ }
+}
+
+ZEND_METHOD(ZendTestSleepObject, __sleep)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_test_initialized_property_names(Z_OBJ_P(ZEND_THIS), return_value);
+}
+
+ZEND_METHOD(ZendTestSleepObject, __wakeup)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_update_property_bool(zend_test_sleep_object_ce, Z_OBJ_P(ZEND_THIS), ZEND_STRL("wokenUp"), true);
+}
+
+ZEND_METHOD(ZendTestSleepObjectWithCustomCreate, __sleep)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_test_initialized_property_names(Z_OBJ_P(ZEND_THIS), return_value);
+}
+
+void zend_test_serialization_init(void)
+{
+ zend_test_sleep_object_ce = register_class_ZendTestSleepObject();
+
+ memcpy(&zend_test_custom_create_object_handlers, &std_object_handlers, sizeof(zend_object_handlers));
+
+ zend_test_sleep_object_with_custom_create_ce = register_class_ZendTestSleepObjectWithCustomCreate();
+ zend_test_sleep_object_with_custom_create_ce->create_object = zend_test_custom_create_object_new;
+
+ zend_test_legacy_serialize_object_ce = register_class_ZendTestLegacySerializeObject();
+ zend_test_legacy_serialize_object_ce->serialize = zend_test_legacy_serialize;
+ zend_test_legacy_serialize_object_ce->unserialize = zend_test_legacy_unserialize;
+}
diff --git a/ext/zend_test/serialization.h b/ext/zend_test/serialization.h
new file mode 100644
index 000000000000..e02c51570230
--- /dev/null
+++ b/ext/zend_test/serialization.h
@@ -0,0 +1,18 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef ZEND_TEST_SERIALIZATION_H
+#define ZEND_TEST_SERIALIZATION_H
+
+void zend_test_serialization_init(void);
+
+#endif
diff --git a/ext/zend_test/serialization.stub.php b/ext/zend_test/serialization.stub.php
new file mode 100644
index 000000000000..0208a7e45f9d
--- /dev/null
+++ b/ext/zend_test/serialization.stub.php
@@ -0,0 +1,32 @@
+config, name, name_len);
+ if (pe == NULL || pe->status != PHP_INI_SYSTEM) {
+ return false;
+ }
+
+ *value = pe->value;
+ *value_len = pe->value_len;
+
+ return true;
+}
+
void apply_config(void *dummy)
{
php_conf_rec *d = dummy;
diff --git a/sapi/apache2handler/php_apache.h b/sapi/apache2handler/php_apache.h
index 18b4e5a75910..c41ec3de6563 100644
--- a/sapi/apache2handler/php_apache.h
+++ b/sapi/apache2handler/php_apache.h
@@ -48,6 +48,7 @@ typedef struct php_struct {
void *merge_php_config(apr_pool_t *p, void *base_conf, void *new_conf);
void *create_php_config(apr_pool_t *p, char *dummy);
char *get_php_config(void *conf, char *name, size_t name_len);
+bool get_php_admin_config(void *conf, const char *name, size_t name_len, const char **value, size_t *value_len);
void apply_config(void *);
extern const command_rec php_dir_cmds[];
void php_ap2_register_hook(apr_pool_t *p);
diff --git a/sapi/apache2handler/sapi_apache2.c b/sapi/apache2handler/sapi_apache2.c
index 44c6b2ca1b3e..5cf1dcfe9e4e 100644
--- a/sapi/apache2handler/sapi_apache2.c
+++ b/sapi/apache2handler/sapi_apache2.c
@@ -32,7 +32,9 @@
#include "zend_smart_str.h"
#include "ext/standard/php_standard.h"
+#include "ext/user_cache/php_user_cache.h"
+#include "apr_file_info.h"
#include "apr_strings.h"
#include "ap_config.h"
#include "util_filter.h"
@@ -64,6 +66,22 @@ char *apache2_php_ini_path_override = NULL;
ZEND_TSRMLS_CACHE_DEFINE()
#endif
+typedef struct _php_apache_ucache_partition_entry {
+ const server_rec *server;
+ const char *server_identity;
+ const char *configured_document_root;
+ php_ucache_partition *partition;
+ struct _php_apache_ucache_partition_entry *next;
+} php_apache_ucache_partition_entry;
+
+typedef struct {
+ const char *name;
+ zend_ini_entry *entry;
+ zend_string *vhost_value;
+} php_apache_ucache_ini;
+
+static php_apache_ucache_partition_entry *php_apache_ucache_partitions = NULL;
+
static size_t
php_apache_sapi_ub_write(const char *str, size_t str_length)
{
@@ -417,6 +435,7 @@ static sapi_module_struct apache2_sapi_module = {
static apr_status_t php_apache_server_shutdown(void *tmp)
{
apache2_sapi_module.shutdown(&apache2_sapi_module);
+ php_apache_ucache_partitions = NULL;
sapi_shutdown();
#ifdef ZTS
tsrm_shutdown();
@@ -457,6 +476,216 @@ static int php_pre_config(apr_pool_t *pconf, apr_pool_t *plog, apr_pool_t *ptemp
return OK;
}
+static const char *php_apache_ucache_normalize_document_root(apr_pool_t *pool, const char *document_root)
+{
+ char *normalized;
+
+ if (document_root == NULL || document_root[0] == '\0') {
+ return NULL;
+ }
+
+ if (apr_filepath_merge(
+ &normalized,
+ NULL,
+ document_root,
+ APR_FILEPATH_NOTRELATIVE,
+ pool
+ ) == APR_SUCCESS
+ ) {
+ return normalized;
+ }
+
+ return apr_pstrdup(pool, document_root);
+}
+
+/* Linear over the vhost list, run once per request. */
+static php_apache_ucache_partition_entry *php_apache_ucache_partition_entry_for_server(
+ const server_rec *server)
+{
+ php_apache_ucache_partition_entry *entry;
+
+ for (entry = php_apache_ucache_partitions; entry != NULL; entry = entry->next) {
+ if (entry->server == server) {
+ return entry;
+ }
+ }
+
+ return NULL;
+}
+
+static void php_apache_ucache_activate_request_partition(request_rec *r)
+{
+ const char *document_root, *boundary;
+ php_apache_ucache_partition_entry *entry;
+
+ entry = php_apache_ucache_partition_entry_for_server(r->server);
+ document_root = php_apache_ucache_normalize_document_root(r->pool, ap_context_document_root(r));
+ if (document_root == NULL) {
+ document_root = php_apache_ucache_normalize_document_root(r->pool, ap_document_root(r));
+ }
+
+ if (entry != NULL &&
+ entry->configured_document_root != NULL &&
+ document_root != NULL &&
+ strcmp(document_root, entry->configured_document_root) == 0
+ ) {
+ php_ucache_partition_activate(entry->partition);
+
+ return;
+ }
+
+ if (entry == NULL || document_root == NULL) {
+ php_ucache_activate_boundary_partition_by_id(
+ "apache2handler",
+ NULL,
+ 0,
+ PHP_UCACHE_REASON_APACHE_BOUNDARY_UNAVAILABLE
+ );
+
+ return;
+ }
+
+ boundary = apr_psprintf(
+ r->pool,
+ "server:%" APR_SIZE_T_FMT ":%s;document-root:%" APR_SIZE_T_FMT ":%s",
+ (apr_size_t) strlen(entry->server_identity),
+ entry->server_identity,
+ (apr_size_t) strlen(document_root),
+ document_root
+ );
+ php_ucache_activate_boundary_partition_by_id(
+ "apache2handler",
+ boundary,
+ strlen(boundary),
+ PHP_UCACHE_REASON_APACHE_BOUNDARY_UNAVAILABLE
+ );
+}
+
+static void php_apache_ucache_apply_vhost_ini(
+ const server_rec *server,
+ php_apache_ucache_ini *settings,
+ size_t count)
+{
+ void *conf = ap_get_module_config(server->lookup_defaults, &php_module);
+ zend_ini_entry *entry;
+ zend_string *value;
+ const char *raw;
+ size_t i, raw_len;
+
+ for (i = 0; i < count; i++) {
+ settings[i].vhost_value = NULL;
+
+ entry = settings[i].entry;
+ if (conf == NULL ||
+ entry == NULL ||
+ entry->on_modify == NULL ||
+ !get_php_admin_config(conf, settings[i].name, strlen(settings[i].name), &raw, &raw_len)
+ ) {
+ continue;
+ }
+
+ value = zend_string_init(raw, raw_len, 1);
+ GC_MAKE_PERSISTENT_LOCAL(value);
+
+ if (entry->on_modify(entry, value, entry->mh_arg1, entry->mh_arg2, entry->mh_arg3, PHP_INI_STAGE_ACTIVATE) == SUCCESS) {
+ settings[i].vhost_value = value;
+ } else {
+ zend_string_release_ex(value, 1);
+ }
+ }
+}
+
+static void php_apache_ucache_restore_vhost_ini(php_apache_ucache_ini *settings, size_t count)
+{
+ zend_ini_entry *entry;
+ size_t i;
+ bool restored;
+
+ for (i = 0; i < count; i++) {
+ if (settings[i].vhost_value == NULL) {
+ continue;
+ }
+
+ entry = settings[i].entry;
+ restored = entry->on_modify(entry, entry->value, entry->mh_arg1, entry->mh_arg2, entry->mh_arg3, PHP_INI_STAGE_DEACTIVATE) == SUCCESS;
+ ZEND_ASSERT(restored);
+
+ zend_string_release_ex(settings[i].vhost_value, 1);
+ settings[i].vhost_value = NULL;
+ }
+}
+
+static void php_apache_ucache_init_partitions(apr_pool_t *pconf, server_rec *server)
+{
+ php_apache_ucache_ini settings[] = {
+ { "user_cache.enable", NULL, NULL },
+ { "user_cache.shm_size", NULL, NULL },
+ { "user_cache.entries_hint", NULL, NULL },
+ { "user_cache.preferred_memory_model", NULL, NULL },
+ { "user_cache.lockfile_path", NULL, NULL },
+ };
+ const size_t count = sizeof(settings) / sizeof(settings[0]);
+ const char *hostname, *partition_name;
+ php_apache_ucache_partition_entry *entry;
+ core_server_config *core_config;
+ server_rec *cur;
+ unsigned int i;
+ size_t setting;
+
+ /* pconf owns the entries; user_cache frees their partitions in MSHUTDOWN. */
+ php_apache_ucache_partitions = NULL;
+
+ for (setting = 0; setting < count; setting++) {
+ settings[setting].entry = zend_hash_str_find_ptr(
+ EG(ini_directives),
+ settings[setting].name,
+ strlen(settings[setting].name)
+ );
+ }
+
+ i = 0;
+ for (cur = server; cur != NULL; cur = cur->next, i++) {
+ hostname = cur->server_hostname != NULL ? cur->server_hostname : "default";
+ partition_name = apr_psprintf(
+ pconf,
+ "apache2handler:%u:%s:%u",
+ i,
+ hostname,
+ (unsigned int) cur->port
+ );
+
+ entry = apr_pcalloc(pconf, sizeof(*entry));
+ entry->server = cur;
+ entry->server_identity = partition_name;
+ core_config = ap_get_core_module_config(cur->module_config);
+ entry->configured_document_root = php_apache_ucache_normalize_document_root(
+ pconf,
+ core_config != NULL ? core_config->ap_document_root : NULL
+ );
+ php_apache_ucache_apply_vhost_ini(cur, settings, count);
+
+ entry->partition = php_ucache_partition_create(partition_name);
+ if (entry->partition == NULL) {
+ if (php_ucache_is_enabled_by_ini()) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, cur, "Unable to allocate UserCache partition");
+ }
+
+ php_apache_ucache_restore_vhost_ini(settings, count);
+
+ continue;
+ }
+
+ if (!php_ucache_partition_startup_storage(entry->partition) && php_ucache_is_enabled_by_ini()) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, cur, "UserCache partition startup failed; UserCache will be unavailable");
+ }
+
+ php_apache_ucache_restore_vhost_ini(settings, count);
+
+ entry->next = php_apache_ucache_partitions;
+ php_apache_ucache_partitions = entry;
+ }
+}
+
static int
php_apache_server_startup(apr_pool_t *pconf, apr_pool_t *plog, apr_pool_t *ptemp, server_rec *s)
{
@@ -499,6 +728,15 @@ php_apache_server_startup(apr_pool_t *pconf, apr_pool_t *plog, apr_pool_t *ptemp
if (apache2_sapi_module.startup(&apache2_sapi_module) != SUCCESS) {
return DONE;
}
+
+ if (php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == FAILURE) {
+ if (php_ucache_is_enabled_by_ini()) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s, "Unable to register UserCache request mode; UserCache will be unavailable");
+ }
+ } else {
+ php_apache_ucache_init_partitions(pconf, s);
+ }
+
apr_pool_cleanup_register(pconf, NULL, php_apache_server_shutdown, apr_pool_cleanup_null);
php_apache_add_version(pconf);
@@ -547,12 +785,21 @@ static int php_apache_request_ctor(request_rec *r, php_struct *ctx)
ctx->r->user = apr_pstrdup(ctx->r->pool, SG(request_info).auth_user);
- return php_request_startup();
+ php_apache_ucache_activate_request_partition(r);
+
+ if (php_request_startup() == FAILURE) {
+ php_ucache_partition_activate(NULL);
+
+ return FAILURE;
+ }
+
+ return SUCCESS;
}
static void php_apache_request_dtor(request_rec *r)
{
php_request_shutdown(NULL);
+ php_ucache_partition_activate(NULL);
}
static void php_apache_ini_dtor(request_rec *r, request_rec *p)
diff --git a/sapi/cgi/cgi_main.c b/sapi/cgi/cgi_main.c
index 929c3db42733..b3154de16670 100644
--- a/sapi/cgi/cgi_main.c
+++ b/sapi/cgi/cgi_main.c
@@ -63,6 +63,7 @@
#include "ext/standard/php_standard.h"
#include "ext/standard/dl_arginfo.h"
#include "ext/standard/url.h"
+#include "ext/user_cache/php_user_cache.h"
#ifdef PHP_WIN32
# include
@@ -778,6 +779,33 @@ static void sapi_cgi_log_message(const char *message, int syslog_type_int)
}
}
+static const char *cgi_ucache_document_root(void)
+{
+ const char *document_root;
+ fcgi_request *request;
+
+ if (SG(server_context) != NULL && SG(server_context) != (void *) 1) {
+ request = (fcgi_request*) SG(server_context);
+ if (fcgi_has_env(request)) {
+ document_root = fcgi_getenv(request, "DOCUMENT_ROOT", sizeof("DOCUMENT_ROOT") - 1);
+ if (document_root != NULL) {
+ return document_root;
+ }
+ }
+ }
+
+ return getenv("DOCUMENT_ROOT");
+}
+
+static void cgi_ucache_activate_request_partition(void)
+{
+ php_ucache_activate_boundary_partition(
+ "cgi-fcgi",
+ cgi_ucache_document_root(),
+ PHP_UCACHE_REASON_CGI_BOUNDARY_UNAVAILABLE
+ );
+}
+
/* {{{ php_cgi_ini_activate_user_config */
static void php_cgi_ini_activate_user_config(char *path, size_t path_len, const char *doc_root, size_t doc_root_len)
{
@@ -860,6 +888,9 @@ static void php_cgi_ini_activate_user_config(char *path, size_t path_len, const
static int sapi_cgi_activate(void)
{
+ /* Resolve the partition before early returns: sapi_activate() ignores failure. */
+ cgi_ucache_activate_request_partition();
+
/* PATH_TRANSLATED should be defined at this stage but better safe than sorry :) */
if (!SG(request_info).path_translated) {
return FAILURE;
@@ -963,12 +994,23 @@ static int sapi_cgi_deactivate(void)
sapi_cgi_flush(SG(server_context));
}
}
+
+ php_ucache_partition_activate(NULL);
+
return SUCCESS;
}
static int php_cgi_startup(sapi_module_struct *sapi_module_ptr)
{
- return php_module_startup(sapi_module_ptr, &cgi_module_entry);
+ if (php_module_startup(sapi_module_ptr, &cgi_module_entry) == FAILURE) {
+ return FAILURE;
+ }
+
+ if (php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == FAILURE && php_ucache_is_enabled_by_ini()) {
+ php_error_docref(NULL, E_WARNING, "Unable to register UserCache request mode; UserCache will be unavailable");
+ }
+
+ return SUCCESS;
}
/* {{{ sapi_module_struct cgi_sapi_module */
diff --git a/sapi/cli/php_cli.c b/sapi/cli/php_cli.c
index 3702da89f9f0..981a7b5c155e 100644
--- a/sapi/cli/php_cli.c
+++ b/sapi/cli/php_cli.c
@@ -52,6 +52,9 @@
#include "fopen_wrappers.h"
#include "ext/standard/php_standard.h"
#include "ext/standard/dl_arginfo.h"
+
+#include "ext/user_cache/php_user_cache.h"
+
#include "cli.h"
#ifdef PHP_WIN32
#include
@@ -385,7 +388,15 @@ static void sapi_cli_send_header(sapi_header_struct *sapi_header, void *server_c
static int php_cli_startup(sapi_module_struct *sapi_module_ptr) /* {{{ */
{
- return php_module_startup(sapi_module_ptr, NULL);
+ if (php_module_startup(sapi_module_ptr, NULL) == FAILURE) {
+ return FAILURE;
+ }
+
+ if (php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == FAILURE && php_ucache_is_enabled_by_ini()) {
+ php_error_docref(NULL, E_WARNING, "Unable to register UserCache request mode; UserCache will be unavailable");
+ }
+
+ return SUCCESS;
}
/* }}} */
diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c
index ece42a1e93f9..24cf9f6c60ea 100644
--- a/sapi/cli/php_cli_server.c
+++ b/sapi/cli/php_cli_server.c
@@ -44,6 +44,8 @@
#include
+#include "ext/user_cache/php_user_cache.h"
+
#ifdef HAVE_DLFCN_H
#include
#endif
@@ -496,7 +498,15 @@ const zend_function_entry server_additional_functions[] = {
static int sapi_cli_server_startup(sapi_module_struct *sapi_module_ptr) /* {{{ */
{
- return php_module_startup(sapi_module_ptr, &cli_server_module_entry);
+ if (php_module_startup(sapi_module_ptr, &cli_server_module_entry) == FAILURE) {
+ return FAILURE;
+ }
+
+ if (php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == FAILURE && php_ucache_is_enabled_by_ini()) {
+ php_error_docref(NULL, E_WARNING, "Unable to register UserCache request mode; UserCache will be unavailable");
+ }
+
+ return SUCCESS;
} /* }}} */
static size_t sapi_cli_server_ub_write(const char *str, size_t str_length) /* {{{ */
@@ -2640,6 +2650,10 @@ static zend_result php_cli_server_ctor(php_cli_server *server, const char *addr,
}
server->server_sock = server_sock;
+ if (php_ucache_is_enabled_by_ini() && !php_ucache_startup_default_ctx_storage()) {
+ php_cli_server_logf(PHP_CLI_SERVER_LOG_ERROR, "UserCache startup failed; UserCache will be unavailable");
+ }
+
php_cli_server_startup_workers();
php_cli_server_poller_ctor(&server->poller);
diff --git a/sapi/fpm/config.m4 b/sapi/fpm/config.m4
index 89c53a0c4d28..3f7590337b35 100644
--- a/sapi/fpm/config.m4
+++ b/sapi/fpm/config.m4
@@ -462,6 +462,7 @@ if test "$PHP_FPM" != "no"; then
fpm/fpm_status.c \
fpm/fpm_stdio.c \
fpm/fpm_unix.c \
+ fpm/fpm_user_cache.c \
fpm/fpm_worker_pool.c \
fpm/zlog.c \
fpm/events/select.c \
diff --git a/sapi/fpm/fpm/fpm.c b/sapi/fpm/fpm/fpm.c
index cb86f403b89c..c48c1c84f4c8 100644
--- a/sapi/fpm/fpm/fpm.c
+++ b/sapi/fpm/fpm/fpm.c
@@ -19,6 +19,7 @@
#include "fpm_scoreboard.h"
#include "fpm_stdio.h"
#include "fpm_log.h"
+#include "fpm_user_cache.h"
#include "zlog.h"
struct fpm_globals_s fpm_globals = {
@@ -64,6 +65,7 @@ enum fpm_init_return_status fpm_init(int argc, char **argv, char *config, char *
0 > fpm_children_init_main() ||
0 > fpm_sockets_init_main() ||
0 > fpm_worker_pool_init_main() ||
+ 0 > fpm_ucache_init_main() ||
0 > fpm_event_init_main()) {
if (fpm_globals.test_successful) {
diff --git a/sapi/fpm/fpm/fpm_children.c b/sapi/fpm/fpm/fpm_children.c
index 285df91a9b69..4779e322a0ac 100644
--- a/sapi/fpm/fpm/fpm_children.c
+++ b/sapi/fpm/fpm/fpm_children.c
@@ -29,6 +29,8 @@
#include "zlog.h"
+#include "ext/user_cache/php_user_cache.h"
+
static time_t *last_faults;
static int fault;
@@ -190,6 +192,9 @@ static void fpm_child_init(struct fpm_worker_pool_s *wp) /* {{{ */
fpm_globals.max_requests = wp->config->pm_max_requests;
fpm_globals.listening_socket = dup(wp->listening_socket);
+ php_ucache_partition_activate(wp->ucache_partition);
+ php_ucache_partition_detach_all_except(wp->ucache_partition);
+
if (0 > fpm_stdio_init_child(wp) ||
0 > fpm_log_init_child(wp) ||
0 > fpm_status_init_child(wp) ||
diff --git a/sapi/fpm/fpm/fpm_user_cache.c b/sapi/fpm/fpm/fpm_user_cache.c
new file mode 100644
index 000000000000..df15ef6e9412
--- /dev/null
+++ b/sapi/fpm/fpm/fpm_user_cache.c
@@ -0,0 +1,145 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#include "fpm_config.h"
+
+#include "php.h"
+#include "php_ini.h"
+
+#include "fpm.h"
+#include "fpm_worker_pool.h"
+#include "fpm_user_cache.h"
+#include "zlog.h"
+
+#include "ext/user_cache/php_user_cache.h"
+
+typedef struct {
+ const char *name;
+ zend_ini_entry *entry;
+ zend_string *pool_value;
+} fpm_ucache_ini;
+
+static void fpm_ucache_apply_pool_values(
+ struct key_value_s *values,
+ fpm_ucache_ini *settings,
+ size_t count)
+{
+ struct key_value_s *kv;
+ zend_ini_entry *entry;
+ zend_string *value;
+ size_t i;
+
+ for (kv = values; kv != NULL; kv = kv->next) {
+ for (i = 0; i < count; i++) {
+ if (strcmp(kv->key, settings[i].name) != 0) {
+ continue;
+ }
+
+ entry = settings[i].entry;
+ if (entry == NULL || entry->on_modify == NULL) {
+ break;
+ }
+
+ value = zend_string_init(kv->value, strlen(kv->value), 1);
+ GC_MAKE_PERSISTENT_LOCAL(value);
+ if (entry->on_modify(entry, value, entry->mh_arg1, entry->mh_arg2,
+ entry->mh_arg3, PHP_INI_STAGE_ACTIVATE) == SUCCESS) {
+ if (settings[i].pool_value != NULL) {
+ zend_string_release_ex(settings[i].pool_value, 1);
+ }
+ settings[i].pool_value = value;
+ } else {
+ zend_string_release_ex(value, 1);
+ }
+ break;
+ }
+ }
+}
+
+static void fpm_ucache_startup_pool(struct fpm_worker_pool_s *wp, bool opted_in)
+{
+ fpm_ucache_ini settings[] = {
+ { "user_cache.enable", NULL, NULL },
+ { "user_cache.shm_size", NULL, NULL },
+ { "user_cache.entries_hint", NULL, NULL },
+ { "user_cache.preferred_memory_model", NULL, NULL },
+ { "user_cache.lockfile_path", NULL, NULL },
+ };
+ const size_t count = sizeof(settings) / sizeof(settings[0]);
+ zend_string *master_value;
+ zend_ini_entry *entry;
+ size_t i;
+ bool pool_enabled, restored;
+
+ for (i = 0; i < count; i++) {
+ settings[i].entry = zend_hash_str_find_ptr(
+ EG(ini_directives), settings[i].name, strlen(settings[i].name));
+ }
+
+ fpm_ucache_apply_pool_values(wp->config->php_values, settings, count);
+ fpm_ucache_apply_pool_values(wp->config->php_admin_values, settings, count);
+
+ pool_enabled = php_ucache_is_enabled_by_ini();
+ if (!opted_in) {
+ if (pool_enabled) {
+ zlog(ZLOG_WARNING, "[pool %s] unable to register UserCache request mode; UserCache will be unavailable", wp->config->name);
+ }
+ } else {
+ wp->ucache_partition = php_ucache_partition_create(wp->config->name);
+ if (wp->ucache_partition == NULL) {
+ if (pool_enabled) {
+ zlog(ZLOG_WARNING, "[pool %s] unable to allocate UserCache partition; UserCache will be unavailable", wp->config->name);
+ }
+ } else {
+ php_ucache_partition_set_max_procs(wp->ucache_partition, (uint32_t) wp->config->pm_max_children);
+
+ if (!php_ucache_partition_startup_storage(wp->ucache_partition) && pool_enabled) {
+ zlog(ZLOG_WARNING, "[pool %s] UserCache partition startup failed; UserCache will be unavailable", wp->config->name);
+ }
+ }
+ }
+
+ for (i = 0; i < count; i++) {
+ if (settings[i].pool_value == NULL) {
+ continue;
+ }
+
+ entry = settings[i].entry;
+ master_value = entry->value;
+
+ restored = entry->on_modify(
+ entry,
+ master_value,
+ entry->mh_arg1,
+ entry->mh_arg2,
+ entry->mh_arg3,
+ PHP_INI_STAGE_DEACTIVATE
+ ) == SUCCESS;
+ ZEND_ASSERT(restored);
+
+ zend_string_release_ex(settings[i].pool_value, 1);
+ }
+}
+
+int fpm_ucache_init_main(void)
+{
+ struct fpm_worker_pool_s *wp;
+ bool opted_in = php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == SUCCESS;
+
+ for (wp = fpm_worker_all_pools; wp; wp = wp->next) {
+ fpm_ucache_startup_pool(wp, opted_in);
+ }
+
+ return 0;
+}
diff --git a/sapi/fpm/fpm/fpm_user_cache.h b/sapi/fpm/fpm/fpm_user_cache.h
new file mode 100644
index 000000000000..cd05d68eb241
--- /dev/null
+++ b/sapi/fpm/fpm/fpm_user_cache.h
@@ -0,0 +1,20 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Go Kudo |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef FPM_UCACHE_H
+#define FPM_UCACHE_H 1
+
+int fpm_ucache_init_main(void);
+
+#endif /* FPM_UCACHE_H */
diff --git a/sapi/fpm/fpm/fpm_worker_pool.h b/sapi/fpm/fpm/fpm_worker_pool.h
index aa06f6109bc7..b7e6bd154adf 100644
--- a/sapi/fpm/fpm/fpm_worker_pool.h
+++ b/sapi/fpm/fpm/fpm_worker_pool.h
@@ -43,6 +43,7 @@ struct fpm_worker_pool_s {
struct fpm_scoreboard_s *scoreboard;
int log_fd;
char **limit_extensions;
+ struct _php_ucache_partition *ucache_partition;
/* for ondemand PM */
struct fpm_event_s *ondemand_event;
diff --git a/sapi/litespeed/lsapi_main.c b/sapi/litespeed/lsapi_main.c
index d4e8328b3a2f..0201f19d4b0e 100644
--- a/sapi/litespeed/lsapi_main.c
+++ b/sapi/litespeed/lsapi_main.c
@@ -26,6 +26,8 @@
#include "lsapilib.h"
#include "lsapi_main_arginfo.h"
+#include "ext/user_cache/php_user_cache.h"
+
#include
#include
@@ -176,6 +178,8 @@ static int sapi_lsapi_deactivate(void)
SG(request_info).path_translated = NULL;
}
+ php_ucache_partition_activate(NULL);
+
return SUCCESS;
}
/* }}} */
@@ -513,6 +517,15 @@ static void sapi_lsapi_log_message(const char *message, int syslog_type_int)
}
/* }}} */
+static void lsapi_ucache_activate_request_partition(void)
+{
+ php_ucache_activate_boundary_partition(
+ "litespeed",
+ sapi_lsapi_getenv("DOCUMENT_ROOT", 0),
+ PHP_UCACHE_REASON_LSAPI_BOUNDARY_UNAVAILABLE
+ );
+}
+
/* Set to 1 to turn on log messages useful during development:
*/
#if 0
@@ -541,6 +554,9 @@ static int sapi_lsapi_activate(void)
char *path, *server_name;
size_t path_len, server_name_len;
+ /* Resolve the partition before early returns: sapi_activate() ignores failure. */
+ lsapi_ucache_activate_request_partition();
+
/* PATH_TRANSLATED should be defined at this stage but better safe than sorry :) */
if (!SG(request_info).path_translated) {
return FAILURE;
@@ -1516,6 +1532,10 @@ int main( int argc, char * argv[] )
return FAILURE;
}
+ if (php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == FAILURE && php_ucache_is_enabled_by_ini()) {
+ php_error_docref(NULL, E_WARNING, "Unable to register UserCache request mode; UserCache will be unavailable");
+ }
+
if ( climode ) {
return cli_main(argc, argv);
}
diff --git a/sapi/phpdbg/phpdbg.c b/sapi/phpdbg/phpdbg.c
index eb003e8a2ead..d6b83a2687f3 100644
--- a/sapi/phpdbg/phpdbg.c
+++ b/sapi/phpdbg/phpdbg.c
@@ -32,6 +32,8 @@
#include "ext/standard/basic_functions.h"
+#include "ext/user_cache/php_user_cache.h"
+
#if defined(PHP_WIN32) && defined(HAVE_OPENSSL_EXT)
# include "openssl/applink.c"
#endif
@@ -699,6 +701,10 @@ static inline int php_sapi_phpdbg_module_startup(sapi_module_struct *module) /*
return FAILURE;
}
+ if (php_ucache_opt_in(PHP_UCACHE_MODE_REQ) == FAILURE && php_ucache_is_enabled_by_ini()) {
+ php_error_docref(NULL, E_WARNING, "Unable to register UserCache request mode; UserCache will be unavailable");
+ }
+
phpdbg_booted = 1;
return SUCCESS;
|