Repository navigation
[Server] Add an opt-in per-session lock for concurrent requests - #584
vbcherepanov wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Lock-file cleanup, lease expiry, session destruction, and swallowed fiber-yield failures can still break serialization.
4 open findings
What changed in this PR
Adds opt-in per-session locking to prevent concurrent requests from overwriting shared session state.
Changes:
- Adds file-based and Symfony-backed lock implementations.
- Integrates locking into protocol processing, polling, fiber yields, and session destruction.
- Adds configuration, documentation, and comprehensive tests.
| File | Description |
|---|---|
CHANGELOG.md |
Documents session locking. |
composer.json |
Adds Symfony Lock support. |
docs/run/server-builder.md |
Documents the builder method. |
docs/run/sessions.md |
Explains lock configuration and behavior. |
src/Exception/SessionLockException.php |
Adds the lock exception. |
src/Server/Builder.php |
Wires locks into Protocol. |
src/Server/Protocol.php |
Applies locking to session operations. |
src/Server/Session/FileSessionLock.php |
Implements filesystem locking. |
src/Server/Session/FileSessionStore.php |
Adds lock-file garbage collection. |
src/Server/Session/SessionLockInterface.php |
Defines the locking contract. |
src/Server/Session/SymfonyLockSessionLock.php |
Implements distributed locking. |
tests/Unit/Server/BuilderTest.php |
Tests builder integration. |
tests/Unit/Server/ProtocolSessionLockTest.php |
Tests protocol lock ordering. |
tests/Unit/Server/Session/Fixture/RecordingSessionLock.php |
Adds a recording lock fixture. |
tests/Unit/Server/Session/Fixture/RecordingSessionStore.php |
Adds a recording store fixture. |
tests/Unit/Server/Session/FileSessionLockTest.php |
Tests file locking. |
tests/Unit/Server/Session/FileSessionStoreTest.php |
Tests lock-file collection. |
tests/Unit/Server/Session/SymfonyLockSessionLockTest.php |
Tests Symfony locking. |
tests/Unit/Server/Transport/StreamableHttpTransportTest.php |
Tests concurrent HTTP behavior. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // The request the fiber waits on must be stored, so an unavailable lock is an error here. | ||
| $this->sessionLock?->acquire($sessionId); |
| // Destroyed either way: a request still running would only write the session back, | ||
| // which is the lesser harm compared to keeping a session the client ended. | ||
| $locked = $this->acquireSessionLock($sessionId); | ||
|
|
||
| try { | ||
| $this->sessionManager->destroy($sessionId); |
|
|
||
| $isSession = Uuid::isValid($entry); | ||
| if (!$isSession && !$this->isTemporaryFile($entry)) { | ||
| if (!$isSession && !$this->isTemporaryFile($entry) && !$this->isLockFile($entry)) { |
| throw new SessionLockException(\sprintf('The lock of session "%s" is already held.', $key)); | ||
| } | ||
|
|
||
| $lock = $this->lockFactory->createLock($this->prefix.$key, $this->ttl); |
There was a problem hiding this comment.
Thanks @vbcherepanov - nice work on the fiber side, the lock is never held across a suspension 👍 A few things, see comments, plus a rebase (nvm the CHANGELOG but the builder docs conflict).
| } | ||
|
|
||
| // The request the fiber waits on must be stored, so an unavailable lock is an error here. | ||
| $this->sessionLock?->acquire($sessionId); |
There was a problem hiding this comment.
BaseTransport::handleFiberYield() swallows this - the fiber then gets resumed with null since the awaited id was already consumed. Reproduced with a tool that suspends twice: the second request never reaches the client and the tool sees null (ClientGateway turns that into an internal error, a notification is just lost). Could we resume the fiber with an Error instead and cover it through the transport loop, not only Protocol::handleFiberYield() directly?
| // the session reads a state this one is about to replace. A session created here for | ||
| // an `initialize` is not known to anyone else yet and needs no lock. | ||
| if (null !== $sessionId && !$this->acquireSessionLock($sessionId)) { | ||
| $this->sendResponse($transport, Error::forServerError(self::SESSION_BUSY_MESSAGE, self::findResponseId($input)), null, ['status_code' => 503]); |
There was a problem hiding this comment.
findResponseId() also picks the id of a client response - a busy lock on an elicitation answer {"id":1000,"result":…} gets an error back under id 1000, which the client matches to its own request, and the answer is gone. Only answer when the message has a method, please.
| if ([] !== $queue) { | ||
| $session->set(self::SESSION_OUTGOING_QUEUE, []); | ||
| $session->save(); | ||
| // Polled again on the stream's next turn, so a lock that is not free now costs nothing. |
There was a problem hiding this comment.
not really true - acquire() blocks for the full timeout, so with the default 30s every SSE loop turn stalls ~60s while another request of the session runs. I measured 2s for both calls with a 1s timeout. Should polls use a non-blocking acquire instead?
| $responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}"); | ||
|
|
||
| if (null === $responseData) { | ||
| // Polled again on the stream's next turn, so a lock that is not free now costs nothing. |
| // Taken before the session is read and held until it is saved, so no other request of | ||
| // the session reads a state this one is about to replace. A session created here for | ||
| // an `initialize` is not known to anyone else yet and needs no lock. | ||
| if (null !== $sessionId && !$this->acquireSessionLock($sessionId)) { |
There was a problem hiding this comment.
this locks before resolveSession() checks the session exists - every random Mcp-Session-Id leaves a .lock file behind (never collected if the lock dir isn't the session dir). How about checking exists() first?
| public function destroySession(Uuid $sessionId): void | ||
| { | ||
| $this->sessionManager->destroy($sessionId); | ||
| // Destroyed either way: a request still running would only write the session back, |
There was a problem hiding this comment.
agree it's not worse than today, but #568 makes Session::save() skip a session destroyed since it was read - with that the busy case is covered, so I'd rather rely on it than document the resurrection.
| $deadline = hrtime(true) + (int) ($this->timeout * 1_000_000_000); | ||
|
|
||
| while (!flock($handle, \LOCK_EX | \LOCK_NB, $wouldBlock)) { | ||
| if (1 !== $wouldBlock) { |
There was a problem hiding this comment.
GC unlinking lock files is fine if acquire() checks after flock() that it locked the file still at $path (fstat() vs stat() inode, retry if not) - otherwise a holder older than the TTL loses its file and a second worker locks a new one. Reproduced that with a clock 2h ahead.
|
|
||
| /** | ||
| * @param float $timeout how long to wait for a lock held by another request, in seconds | ||
| * @param float|null $ttl how long a held lock lives before the store expires it, in seconds; null for no expiration |
There was a problem hiding this comment.
"null for no expiration" doesn't hold for Redis/PDO - they fall back to their initialTtl (300s). And since nothing refreshes the lease while a handler runs, the docs should say ttl must exceed the longest handler, right?
cdea6e2 to
e392b6d
Compare
|
Thanks for the review and for reproducing each case. All points are addressed in the last commit, rebased on main.
Checked locally: unit 1856, integration 105, phpstan, cs-fixer. Also the unit suite on PHP 8.1 and on PHP 8.2 with Symfony 5.4. |
| throw new SessionLockException(\sprintf('Failed to acquire the lock of session "%s": %s', $key, $e->getMessage()), 0, $e); | ||
| } | ||
|
|
||
| $this->locks[$key] = $lock; |
There was a problem hiding this comment.
A fatal error inside a handler (OOM, max_execution_time) never reaches the release() in Protocol: neither finally nor Lock::__destruct() runs after an E_ERROR. With Redis the session then stays locked for the full $ttl (300s by default). Every request on it meanwhile waits the 30s timeout, gets a 503 and pins a worker for that time. I could reproduce it with a PdoStore. Could you register a shutdown function once, on the first acquire, that releases whatever is still in $this->locks and swallows lock exceptions? Shutdown functions still run after a fatal. A test that acquires and then calls the registered callback directly would be enough. FileSessionLock doesn't need this, since the flock dies with the request.
| // again under the lock, as it may have ended while this request waited. | ||
| $takesLock = null !== $sessionId && null !== $this->sessionLock && $this->sessionManager->exists($sessionId); | ||
|
|
||
| if ($takesLock && !$this->acquireSessionLock($sessionId)) { |
There was a problem hiding this comment.
A lock store that is down ends up here as "Another request of this session is still being processed." too. SymfonyLockSessionLock wraps every LockExceptionInterface into the same SessionLockException it throws on a timeout, and acquireSessionLock() treats both as busy. With a Redis that refuses connections I get the busy 503 right away, and only a warning in the log, without the actual cause. 503 is fine for both, but could we keep them apart? For example, a timeout is a false return or its own exception, and a store failure throws: answered with an error that says the lock is unavailable, logged as error with the previous exception's message. FileSessionLock has the same problem with a failed fopen()/flock().
symfony/lock 5.4 calls PersistingStoreInterface::exists() from Lock::__destruct and the interface has no return type there, so a bare mock returned null and isAcquired() raised a TypeError. The mock now reports the lock as held between save() and delete().
Resume a fiber with an error when what it yields cannot be stored, answer only requests when the lock is busy, poll without waiting, lock only known sessions, let gc() unlink a lock file only under its lock, and require a finite ttl for the symfony/lock lease.
… lock backend from a busy session
e392b6d to
ed412d5
Compare
|
Thanks for the load testing. Both are fixed in the last commit, and the branch is rebased on main.
Rebase notes:
unit, integration, phpstan and cs pass locally. Locally 6 client snapshot tests in interop fail on my machine's locale (" am" after the time from the everything server). They pass with |

Follow-up to #535 for the part it left open in #275: an opt-in per-session lock, as agreed with @guillaume-sainthillier and @chr-hertel in #275 (comment).
With #535, responses no longer go through the session. The other session keys still do: two requests of one session that both change it (a pending request to the client, the client's answer, client info) are written back whole, and the last save wins. In practice that is
tools/listandresources/listsent together on connect, or parallel tool calls from an agent.What this adds
SessionLockInterfacewithacquire(Uuid)/release(Uuid), andSessionLockException.FileSessionLock:flock()on one file per session, for the workers of one machine, next toFileSessionStore. Lock files are not unlinked on release (a worker holding the old inode would not see a new file of the same name);FileSessionStore::gc()collects those of expired sessions when both share the directory. Acquiring refreshes the file's mtime so gc keeps the file of a live session.SymfonyLockSessionLock: a symfony/lock store (Redis, database, ...) for sessions shared across machines, with the store's TTL so a dead worker does not block a session for good. symfony/lock is asuggestand a dev dependency only.Builder::setSessionLock(), and an optional last constructor argument ofProtocol.Off by default. With no lock configured nothing changes.
Where the lock is held
Protocoltakes the lock before a request's session is read and releases it after the save, so a handler runs under it. It is released when a handler suspends to wait for the client, and taken briefly on each turn of the SSE loop (consumeOutgoingMessages(),checkResponse(),handleFiberYield()), so the POST carrying the client's answer gets through.destroySession()takes it too, so a request still running cannot write a session back that the client ended.initializecreates a session nobody else knows yet and takes no lock. Reads that do not write (getPendingRequests()) take none.A request that cannot get the lock within the timeout (30 s by default) is answered with
503and a JSON-RPC-32000error, under its id, instead of running on a session another request is about to overwrite. The polling helpers return nothing in that case and try again next turn;handleFiberYield()throws, since the request it stores must not be lost silently.Tests
FileSessionLockTest,SymfonyLockSessionLockTest: a lock held by another instance cannot be acquired until released and times out with the SDK's exception; different sessions do not block each other; acquiring twice and releasing an unheld lock are errors; the lock file's mtime is refreshed; a failing lock store surfaces asSessionLockException.ProtocolSessionLockTest: one shared log of the store's reads and writes and the lock's calls, assertingacquire, read, write, releasearound a request, around each polling helper and around destroy; the lock is released when resolving the session throws; a busy session is refused with503before it is read; a busy poll leaves the session untouched; no lock without a session id; nothing but the store is touched when no lock is configured.StreamableHttpTransportTest: withFileSessionLock, a second POST that loads the session while the first one runs (the lost-update interleaving of [Server] Fix lost responses on concurrent requests of the same session (Streamable HTTP) #535's test) is answered503while the first one gets its200; two tool calls that suspend on a request to the client both proceed and store ids1000and1001, so the lock is not held across the wait. Both fail when the lock is not taken arounddoProcessInput().FileSessionStoreTest:gc()collects expired lock files, leaves live and foreign.lockfiles alone.BuilderTest:setSessionLock()is wired into the built server.Unit and integration suites pass, PHPStan (level 8) and php-cs-fixer are clean. Docs: a "Concurrent Requests" section in
docs/run/sessions.mdand a row in the builder reference.Not in this PR: an option in symfony/mcp-bundle to wire the lock.
Parts of this change were drafted with an AI coding assistant and reviewed and tested by me.