🐛 Fix MCP SSE sessions leaking on zombie connections (#9432) (#9464)

SSE sessions were never included in the periodic inactivity timeout
checker, so a stale connection whose TCP close event never fired would
retain its SSEServerTransport and McpServer indefinitely.

Changes:
- Add lastActiveTime: number to the sseTransports entry type
- Initialise lastActiveTime at SSE session creation (GET /sse)
- Refresh lastActiveTime on every incoming message (POST /messages)
- Extend startSessionTimeoutChecker() to sweep and forcibly close SSE
  sessions idle for more than SESSION_TIMEOUT_MINUTES, mirroring the
  existing Streamable HTTP logic
- Update the checker log to count both transport maps

The existing res.on('close') cleanup path is preserved unchanged:
it remains the primary cleanup for normal disconnections; the timer
is a safety net for zombie sessions only.

Closes #9432

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
bitloi 2026-05-11 08:55:11 -03:00 committed by GitHub
parent b54fa2f11c
commit 58ca0a16ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 20 additions and 5 deletions

View File

@ -13,6 +13,7 @@
### :bug: Bugs fixed
- Fix MCP SSE sessions leaking memory on zombie connections by adding inactivity timeout parity with Streamable HTTP sessions (by @bitloi) [Github #9432](https://github.com/penpot/penpot/issues/9432)
- Fix missing `labels.open` translation (by @MilosM348) [Github #9320](https://github.com/penpot/penpot/pull/9320)
- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
- Fix plugin API `shape.fills` and `shape.strokes` arrays being read-only [Github #8357](https://github.com/penpot/penpot/issues/8357)

View File

@ -45,7 +45,7 @@ class ToolInfo {
export class PenpotMcpServer {
/**
* Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed.
* Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed.
*/
private static readonly SESSION_TIMEOUT_MINUTES = 60;
@ -65,7 +65,10 @@ export class PenpotMcpServer {
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly streamableTransports: Record<string, StreamableSession> = {};
private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {};
private readonly sseTransports: Record<
string,
{ transport: SSEServerTransport; userToken?: string; lastActiveTime: number }
> = {};
public readonly host: string;
public readonly port: number;
@ -179,7 +182,7 @@ export class PenpotMcpServer {
}
/**
* Starts a periodic timer that closes and removes Streamable HTTP sessions that have been
* Starts a periodic timer that closes and removes Streamable HTTP and SSE sessions that have been
* idle for longer than {@link SESSION_TIMEOUT_MINUTES}.
*/
private startSessionTimeoutChecker(): void {
@ -195,8 +198,18 @@ export class PenpotMcpServer {
removed++;
}
}
for (const [id, session] of Object.entries(this.sseTransports)) {
if (now - session.lastActiveTime > timeoutMs) {
this.logger.info(`Closing stale SSE session ${id}`);
session.transport.close();
delete this.sseTransports[id];
removed++;
}
}
this.logger.info(
`Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}`
`Removed ${removed} stale session(s); total sessions remaining: ${
Object.keys(this.streamableTransports).length + Object.keys(this.sseTransports).length
}`
);
}, checkIntervalMs);
}
@ -262,7 +275,7 @@ export class PenpotMcpServer {
await this.sessionContext.run({ userToken }, async () => {
const transport = new SSEServerTransport("/messages", res);
this.sseTransports[transport.sessionId] = { transport, userToken };
this.sseTransports[transport.sessionId] = { transport, userToken, lastActiveTime: Date.now() };
const server = this.createMcpServer();
await server.connect(transport);
@ -281,6 +294,7 @@ export class PenpotMcpServer {
const session = this.sseTransports[sessionId];
if (session) {
session.lastActiveTime = Date.now();
await this.sessionContext.run({ userToken: session.userToken }, async () => {
await session.transport.handlePostMessage(req, res, req.body);
});