fix: memory leak in session removal - close MCP server properly (#471) (#472)

- Add close() method to N8NDocumentationMCPServer that:
  - Calls server.close() (MCP SDK cleanup)
  - Clears internal cache
  - Nullifies service references to help GC
- Update removeSession() to call server.close() before releasing references

Root cause: removeSession() deleted server from map but didn't call cleanup
Evidence: Production server memory grew ~1GB in 43 minutes (10% to 35%)

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Romuald Członkowski <romualdczlonkowski@MacBook-Pro-Romuald.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2025-12-05 18:30:51 +01:00
committed by GitHub
parent 60479e0eb4
commit cdaa29e7a2
12 changed files with 104 additions and 10 deletions

View File

@@ -180,17 +180,29 @@ export class SingleSessionHTTPServer {
*/
private async removeSession(sessionId: string, reason: string): Promise<void> {
try {
// Store reference to transport before deletion
// Store references before deletion
const transport = this.transports[sessionId];
const server = this.servers[sessionId];
// Delete transport FIRST to prevent onclose handler from triggering recursion
// Delete references FIRST to prevent onclose handler from triggering recursion
// This breaks the circular reference: removeSession -> close -> onclose -> removeSession
delete this.transports[sessionId];
delete this.servers[sessionId];
delete this.sessionMetadata[sessionId];
delete this.sessionContexts[sessionId];
// Close transport AFTER deletion
// Close server first (may have references to transport)
// This fixes memory leak where server resources weren't freed (issue #471)
// Handle server close errors separately so transport close still runs
if (server && typeof server.close === 'function') {
try {
await server.close();
} catch (serverError) {
logger.warn('Error closing server', { sessionId, error: serverError });
}
}
// Close transport last
// When onclose handler fires, it won't find the transport anymore
if (transport) {
await transport.close();

View File

@@ -220,7 +220,46 @@ export class N8NDocumentationMCPServer {
this.setupHandlers();
}
/**
* Close the server and release resources.
* Should be called when the session is being removed.
*
* Order of cleanup:
* 1. Close MCP server connection
* 2. Destroy cache (clears entries AND stops cleanup timer)
* 3. Close database connection
* 4. Null out references to help GC
*/
async close(): Promise<void> {
try {
await this.server.close();
// Use destroy() not clear() - also stops the cleanup timer
this.cache.destroy();
// Close database connection before nullifying reference
if (this.db) {
try {
this.db.close();
} catch (dbError) {
logger.warn('Error closing database', {
error: dbError instanceof Error ? dbError.message : String(dbError)
});
}
}
// Null out references to help garbage collection
this.db = null;
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
} catch (error) {
// Log but don't throw - cleanup should be best-effort
logger.warn('Error closing MCP server', { error: error instanceof Error ? error.message : String(error) });
}
}
private async initializeDatabase(dbPath: string): Promise<void> {
try {
// Checkpoint: Database connecting (v2.18.3)