mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 13:33:11 +00:00
Critical bug fix for production crashes during session cleanup. **Root Cause:** Infinite recursion caused by circular event handler chain: - removeSession() called transport.close() - transport.close() triggered onclose event handler - onclose handler called removeSession() again - Loop continued until stack overflow **Solution:** Delete transport from registry BEFORE closing to break circular reference: 1. Store transport reference 2. Delete from this.transports first 3. Close transport after deletion 4. When onclose fires, transport no longer found, no recursion **Impact:** - Eliminates "RangeError: Maximum call stack size exceeded" errors - Fixes session cleanup crashes every 5 minutes in production - Prevents potential memory leaks from failed cleanup **Testing:** - Added regression test for infinite recursion prevention - All 39 session management tests pass - Build and typecheck succeed Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Closes #427
This commit is contained in:
committed by
GitHub
parent
1bbfaabbc2
commit
5575630711
@@ -1114,6 +1114,13 @@ Current database coverage (n8n v1.117.2):
|
|||||||
|
|
||||||
## 🔄 Recent Updates
|
## 🔄 Recent Updates
|
||||||
|
|
||||||
|
### v2.22.19 - Critical Bug Fix
|
||||||
|
**Fixed:** Stack overflow in session removal (Issue #427)
|
||||||
|
- Eliminated infinite recursion in HTTP server session cleanup
|
||||||
|
- Transport resources now deleted before closing to prevent circular event handler chain
|
||||||
|
- Production logs no longer show "RangeError: Maximum call stack size exceeded"
|
||||||
|
- All session cleanup operations now complete successfully without crashes
|
||||||
|
|
||||||
See [CHANGELOG.md](./docs/CHANGELOG.md) for full version history and recent changes.
|
See [CHANGELOG.md](./docs/CHANGELOG.md) for full version history and recent changes.
|
||||||
|
|
||||||
## ⚠️ Known Issues
|
## ⚠️ Known Issues
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.22.18",
|
"version": "2.22.19",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -155,17 +155,22 @@ export class SingleSessionHTTPServer {
|
|||||||
*/
|
*/
|
||||||
private async removeSession(sessionId: string, reason: string): Promise<void> {
|
private async removeSession(sessionId: string, reason: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Close transport if exists
|
// Store reference to transport before deletion
|
||||||
if (this.transports[sessionId]) {
|
const transport = this.transports[sessionId];
|
||||||
await this.transports[sessionId].close();
|
|
||||||
delete this.transports[sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove server, metadata, and context
|
// Delete transport 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.servers[sessionId];
|
||||||
delete this.sessionMetadata[sessionId];
|
delete this.sessionMetadata[sessionId];
|
||||||
delete this.sessionContexts[sessionId];
|
delete this.sessionContexts[sessionId];
|
||||||
|
|
||||||
|
// Close transport AFTER deletion
|
||||||
|
// When onclose handler fires, it won't find the transport anymore
|
||||||
|
if (transport) {
|
||||||
|
await transport.close();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Session removed', { sessionId, reason });
|
logger.info('Session removed', { sessionId, reason });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Error removing session', { sessionId, reason, error });
|
logger.warn('Error removing session', { sessionId, reason, error });
|
||||||
|
|||||||
@@ -434,6 +434,62 @@ describe('HTTP Server Session Management', () => {
|
|||||||
// Note: The actual implementation may handle errors differently, so let's verify what we can
|
// Note: The actual implementation may handle errors differently, so let's verify what we can
|
||||||
expect(mockTransport.close).toHaveBeenCalledWith();
|
expect(mockTransport.close).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not cause infinite recursion when transport.close triggers onclose handler', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const sessionId = 'test-recursion-session';
|
||||||
|
let closeCallCount = 0;
|
||||||
|
let oncloseCallCount = 0;
|
||||||
|
|
||||||
|
// Create a mock transport that simulates the actual behavior
|
||||||
|
const mockTransport = {
|
||||||
|
close: vi.fn().mockImplementation(async () => {
|
||||||
|
closeCallCount++;
|
||||||
|
// Simulate the actual SDK behavior: close() triggers onclose handler
|
||||||
|
if (mockTransport.onclose) {
|
||||||
|
oncloseCallCount++;
|
||||||
|
await mockTransport.onclose();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onclose: null as (() => Promise<void>) | null,
|
||||||
|
sessionId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up the transport and session data
|
||||||
|
(server as any).transports = { [sessionId]: mockTransport };
|
||||||
|
(server as any).servers = { [sessionId]: {} };
|
||||||
|
(server as any).sessionMetadata = {
|
||||||
|
[sessionId]: {
|
||||||
|
lastAccess: new Date(),
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up onclose handler like the real implementation does
|
||||||
|
// This handler calls removeSession, which could cause infinite recursion
|
||||||
|
mockTransport.onclose = async () => {
|
||||||
|
await (server as any).removeSession(sessionId, 'transport_closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call removeSession - this should NOT cause infinite recursion
|
||||||
|
await (server as any).removeSession(sessionId, 'manual_removal');
|
||||||
|
|
||||||
|
// Verify the fix works:
|
||||||
|
// 1. close() should be called exactly once
|
||||||
|
expect(closeCallCount).toBe(1);
|
||||||
|
|
||||||
|
// 2. onclose handler should be triggered
|
||||||
|
expect(oncloseCallCount).toBe(1);
|
||||||
|
|
||||||
|
// 3. Transport should be deleted and not cause second close attempt
|
||||||
|
expect((server as any).transports[sessionId]).toBeUndefined();
|
||||||
|
expect((server as any).servers[sessionId]).toBeUndefined();
|
||||||
|
expect((server as any).sessionMetadata[sessionId]).toBeUndefined();
|
||||||
|
|
||||||
|
// 4. If there was a recursion bug, closeCallCount would be > 1
|
||||||
|
// or the test would timeout/crash with "Maximum call stack size exceeded"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Session Metadata Tracking', () => {
|
describe('Session Metadata Tracking', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user