manager = $manager; $this->cacheFactoryResolver = $cacheFactoryResolver; } /** * Handle an incoming request. * * @return mixed */ public function handle(IncomingRequest $request, Closure $next) { if (! $this->sessionConfigured()) { return $next($request); } // For API and cron requests, use in-memory array driver to prevent // persistent session accumulation. Must run BEFORE getSession() so the // session object is created with the array handler from the start. // Browser AJAX requests (JS calling JSON-RPC) are excluded so they // continue to share the user's web session. if ($request->isApiOrCronRequest() && ! $request->ajax()) { config(['session.driver' => 'array']); $this->manager->setDefaultDriver('array'); } $session = $this->getSession($request); self::dispatchEvent('session_initialized'); // API and cron requests are stateful but non-persisting and never lock // (unchanged behavior: their writes were never saved to begin with). if (! $this->shouldPersistSession($request)) { return $this->handleStatelessRequest($request, $session, $next); } // Web requests use optimistic concurrency: run lock-free, then persist only // the keys that actually changed, merging them under a brief lock so parallel // requests (e.g. dashboard widgets) can't clobber each other's writes. return $this->handleOptimisticRequest($request, $session, $next); } /** * Handle a stateful but non-persisting request (API / cron). The session is * started so it can be read, but it is never locked and never written back — * this preserves the pre-existing behavior for these request types. * * @param \Illuminate\Contracts\Session\Session $session * @return mixed */ protected function handleStatelessRequest(IncomingRequest $request, $session, Closure $next) { $request->setLaravelSession($this->startSession($request, $session)); self::dispatchEvent('session_started'); $this->collectGarbage($session); $response = $next($request); $this->addCookieToResponse($response, $session); return $response; } /** * Handle a web request with optimistic session concurrency. The request runs * without holding the session lock so concurrent requests (e.g. parallel * dashboard widgets) are not serialized. Only when the session actually * changed do we briefly lock, re-read the freshest persisted state, and merge * just this request's changed keys — preventing the lost-update race that * previously forced blanket locking. * * @param \Illuminate\Contracts\Session\Session $session * @return mixed */ protected function handleOptimisticRequest(IncomingRequest $request, $session, Closure $next) { $startTime = microtime(true); $request->setLaravelSession($this->startSession($request, $session)); self::dispatchEvent('session_started'); $this->collectGarbage($session); $initialId = $session->getId(); $initialData = $session->all(); $response = $next($request); $this->storeCurrentUrl($request, $session); $this->persistSessionChanges($request, $session, $initialId, $initialData); $duration = microtime(true) - $startTime; if ($duration > 3.0) { Log::warning("Long session operation detected: {$duration}s for session {$session->getId()}"); } $this->addCookieToResponse($response, $session); return $response; } /** * Persist session changes using a lock-on-write strategy. * * @param \Illuminate\Contracts\Session\Session $session */ protected function persistSessionChanges(IncomingRequest $request, $session, string $initialId, array $initialData): void { // The session identity changed (login/logout regenerate or invalidate). // Keys can't be safely merged onto a different id, so fall back to a full, // locked save of the live session. if ($session->getId() !== $initialId) { $this->withSessionLock($request, $session, fn () => $session->save()); return; } [$changed, $removed] = $this->diffSession($initialData, $session->all()); // Pure read: nothing changed, so we never touch the lock or storage. if ($changed === [] && $removed === []) { return; } $this->withSessionLock($request, $session, fn () => $this->mergeSessionChanges($session, $changed, $removed)); } /** * Re-read the freshest persisted session state and apply ONLY the keys this * request changed/removed onto it, then write it back. Merging by changed-key * (rather than overwriting the whole blob) is what lets a concurrent writer's * keys survive. Must be called while holding the per-session lock. * * @param \Illuminate\Contracts\Session\Session $session * @param array $changed * @param array $removed */ protected function mergeSessionChanges($session, array $changed, array $removed): void { $merged = new Store( $session->getName(), $session->getHandler(), $session->getId(), $this->manager->getSessionConfig()['serialization'] ?? 'php' ); $merged->start(); // Keep the CSRF token consistent with what the live session (and the // already-rendered response) used; a fresh Store would otherwise // regenerate a different token and break the next POST. $merged->put('_token', $session->token()); foreach ($changed as $key => $value) { $merged->put($key, $value); } foreach ($removed as $key) { $merged->forget($key); } $merged->save(); } /** * Compute the keys this request added/changed and the keys it removed, * comparing the session state captured before the request against the * state after it. * * @return array{0: array, 1: array} */ protected function diffSession(array $initial, array $current): array { $changed = []; foreach ($current as $key => $value) { if (! array_key_exists($key, $initial) || $initial[$key] !== $value) { $changed[$key] = $value; } } $removed = array_keys(array_diff_key($initial, $current)); return [$changed, $removed]; } /** * Acquire the per-session lock, run the persistence callback, and release. * Falls back to an exponential-backoff retry if the lock can't be acquired. * * @param \Illuminate\Contracts\Session\Session $session */ protected function withSessionLock(IncomingRequest $request, $session, Closure $callback): void { // Dynamic lock period for different request types $holdLockFor = $this->calculateLockDuration($request); // Hold lock for x seconds after acquiring // Maximum time to wait for acquiring the lock if already held $maxWaitForLock = 5; // Wait for up to y seconds to acquire the lock $lock = $this->cache($this->manager->blockDriver()) ->lock('session:'.$session->getId(), $holdLockFor) ->betweenBlockedAttemptsSleepFor(50); try { $lock->block($maxWaitForLock); $callback(); } catch (LockTimeoutException $e) { Log::warning("Session lock timeout for session {$session->getId()}: {$e->getMessage()}"); // Implement exponential backoff retry $this->retryWithBackoff($callback, $session); } finally { $lock->release(); } } /** * Calculate appropriate lock duration based on request type. This is v0. We'll need to make this smarter */ protected function calculateLockDuration(IncomingRequest $request): int { if ($request->isMethod('GET')) { return 1; // Shorter duration for GET requests } if ($request->ajax()) { return 2; // Medium duration for AJAX requests } return 3; // Default duration for other requests } /** * Implement exponential backoff retry strategy for the persistence callback. * * @param \Illuminate\Contracts\Session\Session $session */ protected function retryWithBackoff(Closure $callback, $session, int $attempts = 3): void { for ($i = 0; $i < $attempts; $i++) { try { $waitTime = min(100 * pow(2, $i), 1000); // Exponential backoff with max 1 second $jitter = random_int(-100, 100); // Add jitter to prevent thundering herd usleep(($waitTime + $jitter) * 1000); // Convert to microseconds $callback(); return; } catch (\Exception $e) { Log::warning("Retry attempt {$i} failed for session {$session->getId()}: {$e->getMessage()}"); continue; } } // If all retries fail, persist without the lock as a last resort. Log::error("All retry attempts failed for session {$session->getId()}, persisting without lock"); $callback(); } /** * Start the session for the given request. * * @param \Illuminate\Contracts\Session\Session $session * @return \Illuminate\Contracts\Session\Session */ protected function startSession(IncomingRequest $request, $session) { return tap($session, function ($session) use ($request) { $session->setRequestOnHandler($request); $session->start(); }); } /** * Get the session implementation from the manager. * * @return \Illuminate\Contracts\Session\Session */ public function getSession(IncomingRequest $request) { return tap($this->manager->driver(), function ($session) use ($request) { $session->setId($request->cookies->get($session->getName())); }); } /** * Remove the garbage from the session if necessary. * * @return void */ protected function collectGarbage(Session $session) { $config = $this->manager->getSessionConfig(); // Here we will see if this request hits the garbage collection lottery by hitting // the odds needed to perform garbage collection on any given request. If we do // hit it, we'll call this handler to let it delete all the expired sessions. if ($this->configHitsLottery($config)) { $session->getHandler()->gc($this->getSessionLifetimeInSeconds()); } } /** * Determine if the configuration odds hit the lottery. * * @return bool */ protected function configHitsLottery(array $config) { return random_int(1, $config['lottery'][1]) <= $config['lottery'][0]; } /** * Store the current URL for the request if necessary. * * @param \Illuminate\Contracts\Session\Session $session * @return void */ protected function storeCurrentUrl(IncomingRequest $request, $session) { // Only full-page navigations set the "previous URL" used for back-redirects. // HTMX partials must not, otherwise every background widget load would dirty // the session and force a needless lock-merge-save. if ( $request->isMethod('GET') && ! $request->isHtmxRequest() && $this->shouldPersistSession($request) ) { $session->setPreviousUrl($request->fullUrl()); } } /** * Add the session cookie to the application response. * * @return void */ protected function addCookieToResponse(Response $response, Session $session) { if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) { $response->headers->setCookie(new Cookie( $session->getName(), $session->getId(), $this->getCookieExpirationDate(), $config['path'], $config['domain'], $config['secure'] ?? false, $config['http_only'] ?? true, false, $config['same_site'] ?? null, $config['partitioned'] ?? false )); } } /** * Determine whether this request should persist its session to storage. * API and cron requests are stateful-but-throwaway and are never persisted. * * @return bool */ protected function shouldPersistSession(IncomingRequest $request) { return $request->isApiOrCronRequest() === false && $this->sessionConfigured(); } /** * Get the session lifetime in seconds. * * @return int */ protected function getSessionLifetimeInSeconds() { return ($this->manager->getSessionConfig()['lifetime'] ?? null) * 60; } /** * Get the cookie lifetime in seconds. * * @return \DateTimeInterface|int */ protected function getCookieExpirationDate() { $config = $this->manager->getSessionConfig(); return $config['expire_on_close'] ? 0 : Date::instance( Carbon::now()->addRealMinutes($config['lifetime']) ); } /** * Determine if a session driver has been configured. * * @return bool */ protected function sessionConfigured() { return ! is_null($this->manager->getSessionConfig()['driver'] ?? null); } /** * Determine if the configured session driver is persistent. * * @return bool */ protected function sessionIsPersistent(?array $config = null) { $config = $config ?: $this->manager->getSessionConfig(); return ! is_null($config['driver'] ?? null); } /** * Resolve the given cache driver. * * @param string $driver * @return \Illuminate\Contracts\Cache\Repository */ protected function cache($driver) { return Cache::store($driver); } }