diff --git a/README.md b/README.md index 93048fa..4c07c52 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ KV store instead of a `flock()` + `fwrite()` against almost certainly already have it). - **The ePHPm runtime** — any tagged release works (the `ephpm_kv_*` SAPI functions this handler calls have shipped since ePHPm v0.1.0; - current release: v0.8.6). The functions are + current release: v0.10.2). The functions are registered by ePHPm's embedded PHP. Outside ePHPm, `SapiKvOps::__construct()` throws fast so you know immediately that you're not running where the handler can work. For development @@ -239,13 +239,17 @@ codebases with hundreds of `.php` files at the docroot. ## Configuration -The constructor takes three optional arguments: +The constructor's arguments are all optional: ```php new KvSessionHandler( string $prefix = 'php_session:', // key prefix in the KV store ?int $ttlSeconds = null, // null = read session.gc_maxlifetime ?KvOpsInterface $ops = null, // backend override (tests) + bool $lockSessions = false, // opt-in per-session-id locking (see below) + int $lockTtlSeconds = 30, // lock lifetime (frees a crashed owner) + int $lockSpinIntervalUs = 20_000, // sleep between spin retries (µs) + int $lockMaxWaitMs = 5_000, // max spin before proceeding lock-free ); ``` @@ -277,6 +281,39 @@ session_set_save_handler(new KvSessionHandler(ttlSeconds: 7200), true); // 2h The TTL is reset on every write, so an actively-used session never times out mid-conversation. +### Opt-in per-session locking + +By default this handler is lock-free (see [Limitations](#limitations)). +If you need Files-handler-style single-flighting — two concurrent +requests sharing one session cookie serialised so their `$_SESSION` +writes don't clobber each other — enable it explicitly: + +```php +session_set_save_handler(new KvSessionHandler(lockSessions: true), true); +``` + +When enabled, `read()` acquires a per-session lock at +`lock:` via the KV store's atomic `ephpm_kv_setnx` (the +lock primitive), spinning up to `lockMaxWaitMs` for a contended lock, +and `close()` releases it. Tune the lock with `lockTtlSeconds` (the +safety valve that frees a session whose owner crashed before `close()`), +`lockSpinIntervalUs`, and `lockMaxWaitMs`. + +Two caveats worth knowing: + +- **Best-effort unlock.** The SAPI has no compare-and-delete, so the + release deletes the lock key without verifying the owner token. The + lock TTL, not the unlock, is the real backstop against a stuck lock — + the same trade the Files handler's advisory `flock()` makes. +- **Requires a runtime providing `ephpm_kv_setnx`.** Enabling locking on + an older runtime that lacks it surfaces a `Call to undefined function` + the first time a session is read. The default (disabled) path never + touches `setnx`. + +Most apps are better off calling `session_write_close()` early to *avoid* +serialization (it lets concurrent AJAX proceed); reach for locking only +when you genuinely need the mutual exclusion. + --- ## Verifying the handler is active @@ -316,15 +353,16 @@ nothing here cares. ## Limitations -- **No cross-process locking on a single session id.** PHP's Files - handler uses `flock()` so two concurrent requests with the same +- **No cross-process locking on a single session id by default.** PHP's + Files handler uses `flock()` so two concurrent requests with the same session cookie serialize at the storage layer. This handler doesn't — - with ePHPm's threading model all PHP execution is in one process, - and you can opt in to single-flighting per session id at the - application level if you need it (modern apps usually call - `session_write_close()` early to *avoid* serialization, since it - kills concurrent AJAX). Most apps don't actually want session locking - — but if yours does, this is a behavior change worth knowing about. + unless you opt in with `lockSessions: true` (see + [Opt-in per-session locking](#opt-in-per-session-locking)), which + single-flights per session id via the KV store's atomic `setnx`. + Modern apps usually call `session_write_close()` early to *avoid* + serialization, since it kills concurrent AJAX, so locking stays off by + default. Most apps don't actually want session locking — but if yours + does, the opt-in gives you Files-handler parity. - **Restart loses session state.** ePHPm's KV is in-process; an `ephpm restart` clears it. If you need session persistence across restarts, either run a clustered ePHPm setup (gossip-replicated KV @@ -382,10 +420,11 @@ gossip-replicated KV survives single-node loss. ### Two browser tabs interleave session writes -There's no per-session-id locking. PHP's Files handler used to provide -this; this handler does not. Either call `session_write_close()` as -soon as you've extracted what you need (the modern preferred pattern, -allows concurrent AJAX), or keep your conflicting writes idempotent. +There's no per-session-id locking unless you opt in with +`lockSessions: true` (see [Opt-in per-session locking](#opt-in-per-session-locking)). +Either enable that, call `session_write_close()` as soon as you've +extracted what you need (the modern preferred pattern, allows concurrent +AJAX), or keep your conflicting writes idempotent. ### Counter `$_SESSION['hits']` doesn't increment past 1 @@ -401,10 +440,11 @@ verify the same id comes back across refreshes. ePHPm runs PHP inside the same OS process as the KV store via the embed SAPI. The store is a Rust [`DashMap`](https://docs.rs/dashmap/) plus TTL management. ePHPm registers a small set of host functions -(`ephpm_kv_get`, `ephpm_kv_set`, `ephpm_kv_del`, `ephpm_kv_exists`, -`ephpm_kv_expire`, `ephpm_kv_ttl`, `ephpm_kv_pttl`, `ephpm_kv_incr_by`) -into PHP's global function table. Calling one is a direct C call into -Rust — no socket, no protocol parser. +(`ephpm_kv_get`, `ephpm_kv_set`, `ephpm_kv_setnx`, `ephpm_kv_del`, +`ephpm_kv_exists`, `ephpm_kv_expire`, `ephpm_kv_ttl`, `ephpm_kv_pttl`, +`ephpm_kv_incr_by`) into PHP's global function table. Calling one is a +direct C call into Rust — no socket, no protocol parser. (`setnx` backs +the opt-in per-session lock; the rest cover the core session lifecycle.) This package wraps those functions in a `SessionHandlerInterface` + `SessionUpdateTimestampHandlerInterface` diff --git a/src/InMemoryKvOps.php b/src/InMemoryKvOps.php index 3a0d0d4..2e38887 100644 --- a/src/InMemoryKvOps.php +++ b/src/InMemoryKvOps.php @@ -37,6 +37,22 @@ public function set(string $key, string $value, int $ttlSeconds = 0): bool return true; } + public function setnx(string $key, string $value, int $ttlSeconds = 0): bool + { + // Insert-or-fail: a live entry blocks the insert, matching the SAPI's + // per-shard-locked setnx (the store's lock primitive). + if ($this->liveValue($key) !== null) { + return false; + } + $this->values[$key] = $value; + if ($ttlSeconds > 0) { + $this->deadlines[$key] = $this->nowMs() + ($ttlSeconds * 1000); + } else { + unset($this->deadlines[$key]); + } + return true; + } + public function del(string $key): int { if ($this->liveValue($key) === null) { diff --git a/src/KvOpsInterface.php b/src/KvOpsInterface.php index c0a3cc0..f6babd1 100644 --- a/src/KvOpsInterface.php +++ b/src/KvOpsInterface.php @@ -34,6 +34,20 @@ public function get(string $key): ?string; */ public function set(string $key, string $value, int $ttlSeconds = 0): bool; + /** + * Atomically set a key to a value **only if it does not already exist**. + * + * This is the KV store's lock primitive: the insert-or-fail happens under + * the per-shard lock, so exactly one concurrent caller can win the key. + * + * @param int $ttlSeconds 0 means no expiry; positive values are seconds + * + * @return bool true when this call inserted the key; false when a live + * entry already exists (or on OOM). A false return is the + * signal that another holder owns the key right now. + */ + public function setnx(string $key, string $value, int $ttlSeconds = 0): bool; + /** * Delete a key. * diff --git a/src/KvSessionHandler.php b/src/KvSessionHandler.php index f9613ab..70d5a5f 100644 --- a/src/KvSessionHandler.php +++ b/src/KvSessionHandler.php @@ -29,9 +29,11 @@ * payload — relevant for read-heavy apps). * * Implements {@see SessionIdInterface} so PHP uses our id generator - * (it just delegates to PHP's own `session_create_id()`); without it - * the handler would still work but PHP issues a deprecation notice - * on PHP 8.4+. + * (a CSPRNG-backed id honouring `session.sid_length` / + * `session.sid_bits_per_character`, generated directly rather than via + * `session_create_id()` to avoid a re-entrancy trap — see + * {@see self::create_sid()}); without it the handler would still work + * but PHP issues a deprecation notice on PHP 8.4+. */ final class KvSessionHandler implements SessionHandlerInterface, @@ -42,42 +44,93 @@ final class KvSessionHandler implements private string $prefix; private int $ttlSeconds; + private bool $lockSessions; + private int $lockTtlSeconds; + private int $lockSpinIntervalUs; + private int $lockMaxWaitMs; + + /** + * Whether this handler currently owns the per-session lock, and under + * which key. Only set when acquisition succeeded, so `close()` never + * deletes a lock owned by someone else. + */ + private bool $lockHeld = false; + private ?string $lockKey = null; + /** - * @param string $prefix Key prefix written before each session - * id. Defaults to `php_session:`. Bump - * this in config to invalidate every - * session at once. - * @param int|null $ttlSeconds Session lifetime in seconds. `null` - * reads `session.gc_maxlifetime` from - * php.ini at construction time - * (default 1440 s / 24 minutes). - * @param KvOpsInterface|null $ops Backend override (mainly for tests). - * Defaults to {@see SapiKvOps}. + * @param string $prefix Key prefix written before each session + * id. Defaults to `php_session:`. Bump + * this in config to invalidate every + * session at once. + * @param int|null $ttlSeconds Session lifetime in seconds. `null` + * reads `session.gc_maxlifetime` from + * php.ini at construction time + * (default 1440 s / 24 minutes). + * @param KvOpsInterface|null $ops Backend override (mainly for tests). + * Defaults to {@see SapiKvOps}. + * @param bool $lockSessions Opt in to per-session-id single-flighting + * (see below). Default `false` preserves the + * original lock-free behaviour exactly. + * @param int $lockTtlSeconds Lifetime of the lock entry — the safety + * valve that frees a session whose owner + * crashed before `close()`. Default 30 s. + * @param int $lockSpinIntervalUs Microseconds to sleep between spin + * retries while waiting for the lock. + * Default 20 000 µs (20 ms). + * @param int $lockMaxWaitMs Maximum total time to spin for the lock + * before giving up and proceeding lock-free + * (availability over strict exclusion). + * Default 5 000 ms. + * + * Session locking (opt-in). PHP's Files handler serialises concurrent + * requests that share a session cookie by holding a `flock()` for the + * request's lifetime. This handler does not do that by default. When + * `$lockSessions` is true, `read()` acquires a per-session lock at + * `lock:` with a bounded {@see \ephpm_kv_setnx} spin and + * `close()` releases it. Because the SAPI has no compare-and-delete, the + * release is *best-effort* (`del` without owner-token verification) — + * Files-handler parity, pending a future CAS primitive. The lock TTL caps + * the blast radius of a crashed owner. */ public function __construct( string $prefix = 'php_session:', ?int $ttlSeconds = null, ?KvOpsInterface $ops = null, + bool $lockSessions = false, + int $lockTtlSeconds = 30, + int $lockSpinIntervalUs = 20_000, + int $lockMaxWaitMs = 5_000, ) { $this->prefix = $prefix; $this->ttlSeconds = $ttlSeconds ?? (int) \ini_get('session.gc_maxlifetime') ?: 1440; $this->ops = $ops ?? new SapiKvOps(); + $this->lockSessions = $lockSessions; + $this->lockTtlSeconds = $lockTtlSeconds; + $this->lockSpinIntervalUs = $lockSpinIntervalUs; + $this->lockMaxWaitMs = $lockMaxWaitMs; } // ── SessionHandlerInterface ────────────────────────────────────────────── /** - * `open` and `close` are no-ops — the SAPI is always reachable, there - * is no connection to negotiate. Both must return true so PHP doesn't - * abort the session lifecycle. + * `open` is a no-op — the SAPI is always reachable, there is no + * connection to negotiate. It must return true so PHP doesn't abort + * the session lifecycle. (The session id isn't known here — it's + * handed to `read()` — so lock acquisition happens there.) */ public function open(string $path, string $name): bool { return true; } + /** + * Releases the per-session lock if this handler holds one. Best-effort: + * with no compare-and-delete primitive we cannot verify the owner token + * before deleting, so the TTL is the real safety net (see the constructor). + */ public function close(): bool { + $this->releaseLock(); return true; } @@ -85,9 +138,17 @@ public function close(): bool * Read a session's serialized payload. PHP expects an empty string * (NOT null/false) when the session doesn't exist — that's the cue * to start a fresh `$_SESSION`. + * + * When session locking is enabled this is where the per-session lock is + * acquired (it's the first lifecycle call that knows the id), so the read + * and everything the request does with `$_SESSION` afterwards is + * single-flighted against other requests for the same id. */ public function read(string $id): string { + if ($this->lockSessions) { + $this->acquireLock($id); + } return $this->ops->get($this->prefix . $id) ?? ''; } @@ -148,14 +209,95 @@ public function updateTimestamp(string $id, string $data): bool // ── SessionIdInterface ─────────────────────────────────────────────────── /** - * Delegate to PHP's built-in id generator — it already honors - * `session.sid_length`, `session.sid_bits_per_character`, and the - * CSPRNG. Implementing this interface explicitly is mostly about - * silencing a PHP 8.4+ deprecation about save-handlers that don't - * declare an id strategy. + * Generate a new session id. + * + * We deliberately do **not** call `session_create_id()` here. When this + * object is the active save handler, PHP dispatches id creation to this + * very method — so calling `session_create_id()` from inside it is a + * re-entrancy trap. Current PHP guards the re-entry, but that guard is an + * implementation detail that has changed across versions; depending on it + * is fragile. Generating the id directly here removes the hazard entirely + * and still honours `session.sid_length` and + * `session.sid_bits_per_character` (so ids look exactly like PHP's own). */ public function create_sid(): string { - return \session_create_id(); + $length = (int) \ini_get('session.sid_length') ?: 32; + $bits = (int) \ini_get('session.sid_bits_per_character') ?: 4; + + // PHP's session id alphabets, keyed by sid_bits_per_character. All + // three are subsets of [A-Za-z0-9,-], matching PHP's own output. + $alphabet = match ($bits) { + 5 => '0123456789abcdefghijklmnopqrstuv', + 6 => '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-,', + default => '0123456789abcdef', // 4 bits (PHP default): hex + }; + + $max = \strlen($alphabet) - 1; + $id = ''; + for ($i = 0; $i < $length; $i++) { + // random_int() is CSPRNG-backed, matching the entropy source PHP + // uses for session ids. + $id .= $alphabet[\random_int(0, $max)]; + } + + return $id; + } + + // ── session locking (opt-in) ───────────────────────────────────────────── + + /** + * The KV key for a session id's lock. Namespaced under the handler's + * prefix so multi-tenant deployments (per-host prefixes) never share a + * lock across sites. + */ + private function lockKeyFor(string $id): string + { + return $this->prefix . 'lock:' . $id; + } + + /** + * Acquire the per-session lock with a bounded spin. Returns true if the + * lock was taken. On timeout it returns false and the request proceeds + * lock-free — availability over strict mutual exclusion, and the same + * outcome as the default (locking-disabled) path. + */ + private function acquireLock(string $id): bool + { + $key = $this->lockKeyFor($id); + $token = \bin2hex(\random_bytes(16)); + $deadlineMs = $this->nowMs() + $this->lockMaxWaitMs; + + do { + if ($this->ops->setnx($key, $token, $this->lockTtlSeconds)) { + $this->lockHeld = true; + $this->lockKey = $key; + return true; + } + if ($this->nowMs() >= $deadlineMs) { + return false; + } + \usleep($this->lockSpinIntervalUs); + } while (true); + } + + /** + * Best-effort release of a lock this handler acquired. No-op if we never + * took one (e.g. locking disabled, or the acquire timed out). + */ + private function releaseLock(): void + { + if ($this->lockHeld && $this->lockKey !== null) { + // No CAS: we cannot prove we still own the token before deleting. + // The lock TTL bounds the damage; this matches Files-handler parity. + $this->ops->del($this->lockKey); + } + $this->lockHeld = false; + $this->lockKey = null; + } + + private function nowMs(): int + { + return (int) (\microtime(true) * 1000); } } diff --git a/src/SapiKvOps.php b/src/SapiKvOps.php index 473b047..7fe2aee 100644 --- a/src/SapiKvOps.php +++ b/src/SapiKvOps.php @@ -34,6 +34,11 @@ public function set(string $key, string $value, int $ttlSeconds = 0): bool return (bool) \ephpm_kv_set($key, $value, $ttlSeconds); } + public function setnx(string $key, string $value, int $ttlSeconds = 0): bool + { + return (bool) \ephpm_kv_setnx($key, $value, $ttlSeconds); + } + public function del(string $key): int { return (int) \ephpm_kv_del($key); @@ -46,7 +51,15 @@ public function exists(string $key): bool public function incrBy(string $key, int $delta): int { - return (int) \ephpm_kv_incr_by($key, $delta); + // ephpm_kv_incr_by returns `false` (not an int) when the stored value + // is not an integer. A blind `(int) false` would silently collapse that + // error to 0 — masking a type mismatch as a legitimate counter value. + // The interface documents a Throws contract, so surface it. + $result = \ephpm_kv_incr_by($key, $delta); + if ($result === false) { + throw new \RuntimeException("value at key '{$key}' is not an integer"); + } + return (int) $result; } public function expire(string $key, int $ttlSeconds): bool diff --git a/tests/InMemoryKvOpsTest.php b/tests/InMemoryKvOpsTest.php index fbb2f59..d99da2c 100644 --- a/tests/InMemoryKvOpsTest.php +++ b/tests/InMemoryKvOpsTest.php @@ -59,6 +59,46 @@ public function test_incr_throws_on_non_integer_value(): void $ops->incrBy('label', 1); } + public function test_incr_returns_int_on_success(): void + { + // Guards the shared incrBy contract: success returns a real int, never + // a (int)false === 0 masking a type error (the SapiKvOps bug this + // audit fixed). InMemoryKvOps is the executable model of that contract. + $ops = new InMemoryKvOps(); + $result = $ops->incrBy('n', 3); + self::assertIsInt($result); + self::assertSame(3, $result); + } + + // ── setnx (the lock primitive) ─────────────────────────────────────────── + + public function test_setnx_inserts_only_when_absent(): void + { + $ops = new InMemoryKvOps(); + self::assertTrue($ops->setnx('lock', 'token-a')); + // A live entry blocks a second insert — the whole point of setnx. + self::assertFalse($ops->setnx('lock', 'token-b')); + self::assertSame('token-a', $ops->get('lock')); + } + + public function test_setnx_succeeds_again_after_del(): void + { + $ops = new InMemoryKvOps(); + self::assertTrue($ops->setnx('lock', 'token-a')); + $ops->del('lock'); + self::assertTrue($ops->setnx('lock', 'token-b')); + self::assertSame('token-b', $ops->get('lock')); + } + + public function test_setnx_applies_ttl(): void + { + $ops = new InMemoryKvOps(); + self::assertTrue($ops->setnx('lock', 'token', 30)); + $pttl = $ops->pttl('lock'); + self::assertGreaterThan(0, $pttl); + self::assertLessThanOrEqual(30_000, $pttl); + } + public function test_set_with_ttl_then_pttl_within_window(): void { $ops = new InMemoryKvOps(); diff --git a/tests/KvSessionHandlerTest.php b/tests/KvSessionHandlerTest.php index fb470f7..42ca046 100644 --- a/tests/KvSessionHandlerTest.php +++ b/tests/KvSessionHandlerTest.php @@ -7,6 +7,8 @@ use Ephpm\SessionHandler\InMemoryKvOps; use Ephpm\SessionHandler\KvSessionHandler; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; #[CoversClass(KvSessionHandler::class)] @@ -180,4 +182,144 @@ public function test_create_sid_is_unique_across_calls(): void } self::assertSame(50, \count(\array_unique($ids))); } + + /** + * Regression pin for the create_sid() -> session_create_id() re-entrancy + * trap. When this handler is the ACTIVE save handler, PHP dispatches id + * creation to create_sid(); if that method itself called + * session_create_id() it could re-enter create_sid() (infinite recursion + * on PHP versions without the core guard). We generate the id directly, so + * driving a real session_start() through this handler must terminate and + * yield a valid id. Runs in a separate process because it mutates the + * global session save-handler and starts a session. + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_create_sid_does_not_recurse_when_handler_is_active(): void + { + // No cookies: this is CLI, and we only care about id generation, not + // transport. Avoids "headers already sent" under failOnWarning. + \ini_set('session.use_cookies', '0'); + \ini_set('session.cache_limiter', ''); + + $handler = new KvSessionHandler('php_session:', 1440, new InMemoryKvOps()); + \session_set_save_handler($handler, true); + + // If create_sid() recursed, this would stack-overflow rather than + // return. Reaching the assertions at all is the core of the test. + self::assertTrue(@\session_start()); + + $id = \session_id(); + self::assertNotEmpty($id); + self::assertMatchesRegularExpression('/^[A-Za-z0-9,-]+$/', $id); + + // And a direct call is likewise safe and valid. + $direct = $handler->create_sid(); + self::assertNotEmpty($direct); + self::assertMatchesRegularExpression('/^[A-Za-z0-9,-]+$/', $direct); + + @\session_write_close(); + } + + // ── opt-in session locking ─────────────────────────────────────────────── + + private function lockingHandler( + InMemoryKvOps $ops, + int $maxWaitMs = 5_000, + int $spinUs = 20_000, + int $lockTtl = 30, + ): KvSessionHandler { + return new KvSessionHandler( + 'php_session:', + 1440, + $ops, + lockSessions: true, + lockTtlSeconds: $lockTtl, + lockSpinIntervalUs: $spinUs, + lockMaxWaitMs: $maxWaitMs, + ); + } + + public function test_locking_disabled_by_default_creates_no_lock_key(): void + { + // Default construction must not change the original lock-free behaviour. + $ops = new InMemoryKvOps(); + $handler = $this->handler($ops); + $handler->read('sid'); + self::assertFalse($ops->exists('php_session:lock:sid')); + } + + public function test_locking_acquires_lock_on_read_with_ttl(): void + { + $ops = new InMemoryKvOps(); + $handler = $this->lockingHandler($ops, lockTtl: 30); + $handler->read('sid'); + + self::assertTrue($ops->exists('php_session:lock:sid')); + $pttl = $ops->pttl('php_session:lock:sid'); + self::assertGreaterThan(0, $pttl); + self::assertLessThanOrEqual(30_000, $pttl); + + $handler->close(); + self::assertFalse($ops->exists('php_session:lock:sid')); + } + + public function test_held_lock_blocks_second_acquire_until_released(): void + { + $ops = new InMemoryKvOps(); + + // Handler A takes the lock. + $a = $this->lockingHandler($ops); + $a->read('sid'); + $tokenA = $ops->get('php_session:lock:sid'); + self::assertNotNull($tokenA); + + // Handler B spins for a bounded window, then gives up — it must NOT + // steal A's lock (value stays A's token), and it must have actually + // waited roughly the max-wait window. + $b = $this->lockingHandler($ops, maxWaitMs: 100, spinUs: 10_000); + $start = \microtime(true); + $b->read('sid'); + $elapsedMs = (\microtime(true) - $start) * 1000; + + self::assertGreaterThanOrEqual(90.0, $elapsedMs); + self::assertSame($tokenA, $ops->get('php_session:lock:sid')); + + // A releases; now B can take it and the token changes hands. + $a->close(); + self::assertFalse($ops->exists('php_session:lock:sid')); + + $b->read('sid'); + $tokenB = $ops->get('php_session:lock:sid'); + self::assertNotNull($tokenB); + self::assertNotSame($tokenA, $tokenB); + } + + public function test_lock_becomes_available_after_ttl_expiry(): void + { + $ops = new InMemoryKvOps(); + + // A takes a lock with a 1s TTL but never releases it (simulating a + // crashed owner). The TTL is the safety valve that frees the session. + $a = $this->lockingHandler($ops, lockTtl: 1); + $a->read('sid'); + $tokenA = $ops->get('php_session:lock:sid'); + self::assertNotNull($tokenA); + + // Wait out the lock TTL. InMemoryKvOps expires lazily on lookup, so + // after this the key is genuinely gone from setnx's perspective. + \usleep(1_100_000); + self::assertFalse($ops->exists('php_session:lock:sid')); + + // B now acquires immediately (short max-wait proves it didn't block). + $b = $this->lockingHandler($ops, maxWaitMs: 50); + $start = \microtime(true); + $b->read('sid'); + $elapsedMs = (\microtime(true) - $start) * 1000; + + self::assertLessThan(50.0, $elapsedMs); + $tokenB = $ops->get('php_session:lock:sid'); + self::assertNotNull($tokenB); + self::assertNotSame($tokenA, $tokenB); + } }