Repository navigation
[Server] Concurrent requests corrupt the session file in FileSessionStore, crashing Session::readData() with JsonException #498
Description
Activity
- addedServerIssues & PRs related to the Server componentIssues & PRs related to the Server component
on Sep 7, 2026 Hi @Inso2008, thanks for reporting this! The built-in
FileSessionStorefor sure comes with limitations, and if you have an application context that already has scaled session management, you should consider to implement aSessionStoreInterfaceyourself and provide it to the SDK.However, it would be great to improve the implementation that we ship. Can you reproduce this issue reliably? If so, can you check if the branch of PR #500 would fix the issue?
Cheers & Thanks again,
Chris- addedP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable feature
on Sep 7, 2026 Hi Chris, thanks for the quick turnaround.
Yes, it reproduces reliably. Upstream at 1a816c8: unit 1580->1586 tests, same 11 pre-existing failures by name (chmod- and JWT-related, they fail on f39625b too); examples/inspector/integration identical to base. All 6 new tests fail against reverted src -- the concurrency one catching 542 partial reads of 2273 -- and pass with it. PHPStan clean, php-cs-fixer 0/575.
We also backported both src changes onto v0.8.1 (our pinned version; we dropped the two logger calls, that param landed after 0.8.1) and verified on two setups:
- Windows 11 / PHP 8.5.3 CLI: 6 writers x 400 writes on one session went from 464 empty reads / 471 JsonExceptions to zero.
- Linux / PHP-FPM (the box where we originally hit this), same server and same session before and after the patch: a
tools/listagainst a hand-truncated session file went from-32603with twoJsonException: Syntax errorlog entries ("while handling message" and "while processing input") to a clean 200 through the full StreamableHttpTransport path, no log entry. In production we had also seen the second path from the report, where the exception escapes fromProtocol::consumeOutgoingMessages()into the application error handler.
So #500 fixes #498 for us. A few observations, none of them blockers:
- The copy() fallback is still non-atomic (truncate + 8 KiB streaming), and it is reachable on Windows, where rename() over a target another process holds open fails with code 5. We still see ~40 zero-byte files and the odd 8192/16384-byte partial read there; with [Server] Stop concurrent writes from corrupting session files #500 those no longer throw, but the session -- including the outgoing queue -- is silently replaced by an empty one. Unreachable on Linux, so it does not affect the fix -- but your new concurrency test may prove flaky if you ever add a Windows runner. Since the temp file lives in the same directory, the cross-device case cannot happen; dropping the fallback and returning
false(logged) would be safer. - Concurrency on Linux/PHP-FPM: ~3,100 parallel
tools/listPOSTs (40 in flight) on one session never hit the JsonException, but 4 came back as HTTP 202 with an empty body -- the request was processed and the queued response was lost to a concurrent overwrite ([Server][Streamable HTTP] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs #275). Same rate with the patch (1 of 1,600). Nothing is logged for those, partly because the return value ofSession::save()is still discarded inProtocol(:223,:519,:551,:644). Out of scope here, separate issue if you like. - Minor nits: a pre-patch
<uuid>.tmpdoes not match isTemporaryFile(), so orphans predating the upgrade are never collected; and a non-empty but undecodable payload is dropped without a log line (Sessionhas no logger), only the 0-byte case inFileSessionStore::read()is logged.
Will this land in a patch release (0.8.2)? We pin the SDK version and would like to pick it up without moving to
main.
Describe the bug
Under PHP-FPM, two concurrent requests carrying the same
Mcp-Session-Idcan leave the session file empty (0 bytes). The next read of that session throws an uncaughtJsonExceptionout ofSession::readData(), and the server answers-32603for a tool call that had already completed successfully — the tool ran, its response was queued in the session, and then the response could not be retrieved.Two independent defects combine here; each is a necessary condition.
FileSessionStore::write()uses a fixed temporary filename.$tmp = $path.'.tmp';is identical for every concurrent writer of the same session, andfile_put_contents()opens the stream inwmode — so truncation happens beforeLOCK_EXis acquired. One process can thereforerename()a temp file that another process has just truncated to 0 bytes, publishing an empty session file. The// Atomic movecomment applies torename()alone; the operation as a whole is not atomic because the temp name is shared. Thecopy()fallback has the same problem (it truncates the destination and then streams into it), andread()takes noLOCK_SH.Session::readData()does not guard against an empty string. Onlyfalseis handled, so''reachesjson_decode(..., JSON_THROW_ON_ERROR):FileSessionStore::read()returns''for a zero-byte file — it returnsfalseonly when the file is missing, expired, or unreadable. This half is store-agnostic: any store that can return an empty string triggers it.To Reproduce
Steps to reproduce the behavior:
StreamableHttpTransportandFileSessionStore(handshake era).POSTaninitializerequest without a session id; takeMcp-Session-Idfrom the response headers.POSTnotifications/initializedwith that header.: > <session-dir>/<session-id>(or write invalid JSON:printf '{"a":' > <session-dir>/<session-id>).POSTtools/listwith the sameMcp-Session-Id. The request fails with-32603and theJsonExceptionbelow.tools/listPOSTs sharing oneMcp-Session-Id, e.g.seq 40 | xargs -P 8 -I{} curl -s -o /dev/null -w '%{http_code}\n' -X POST ..., and repeat a few rounds. Occasional requests fail with-32603.Expected behavior
!\is_array($decoded)branch already shows this is the intended behaviour — it is simply unreachable whileJSON_THROW_ON_ERRORfires first.Logs
Additional context
Why the exception escapes every catch block. The crash happens in
Protocol::consumeOutgoingMessages(), which builds a freshSessioninstance:A single POST therefore reads the session file twice, through two objects that share no memory — and the second read happens after the request's own
save()atProtocol.php:223. That second read is reached fromBaseTransport::getOutgoingMessages()while the response is being built, which is outsideProtocol::processInput()'s try/catch (that one wraps onlydoProcessInput());Server::run()has justtry/finally. The exception propagates out ofServer::run()into the application's own error handler.Silent lost writes. After the losing process's
rename()fails (its temp file is gone) andcopy()fails too,write()returnsfalse— butSession::save()'s return value is discarded atProtocol.php:223,:519,:551and:644, so the failed write is invisible.Where a fix would belong. Both halves look small and independent — the shared temp filename in
FileSessionStore::write(), and the missing''case inSession::readData().One more observation, separate from the bug. Since #479,
gc()skips anything whose filename is not a valid UUID, so orphaned*.tmpfiles are never cleaned up. Harmless at one temp file per session, but worth knowing if the temp name ever becomes unique.Related but distinct issues. #275 (lost update on concurrent read-modify-write) and #467 (concurrent POSTs returning a JSON array). A per-session lock as proposed in #275 would incidentally prevent this crash, but would not close item 2.
Environment.
mcp/sdkv0.8.1, PHP 8.5, PHP-FPM,StreamableHttpTransportwithwithoutModernEra(),FileSessionStoreon local disk (Linux), TTL 14400 s. The same code is present onmain, so this is not fixed by upgrading.