chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Integration tests for ResilientTransportWrapper using real HTTP/SSE servers.
|
||||
*
|
||||
* These tests use the demo servers to simulate realistic network scenarios
|
||||
* including heartbeat concurrency, reconnection, and timing issues.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ResilientTransportWrapper } from '../src/resilient.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// ---- Mock HTTP Server for testing ----
|
||||
|
||||
class MockHttpMcpServer {
|
||||
private server: Server | null = null;
|
||||
private port: number;
|
||||
private requestDelay: number = 0;
|
||||
private shouldFail: boolean = false;
|
||||
private requestCount: number = 0;
|
||||
|
||||
constructor(port: number) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
setRequestDelay(ms: number) {
|
||||
this.requestDelay = ms;
|
||||
}
|
||||
|
||||
setShouldFail(fail: boolean) {
|
||||
this.shouldFail = fail;
|
||||
}
|
||||
|
||||
getRequestCount(): number {
|
||||
return this.requestCount;
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server = createServer(async (req, res) => {
|
||||
this.requestCount++;
|
||||
|
||||
// Simulate slow network
|
||||
if (this.requestDelay > 0) {
|
||||
await new Promise(r => setTimeout(r, this.requestDelay));
|
||||
}
|
||||
|
||||
if (this.shouldFail) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Server error' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read body
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString()) : {};
|
||||
|
||||
// Handle MCP requests
|
||||
if (body.method === 'initialize') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'mcp-session-id': randomUUID(),
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
serverInfo: { name: 'mock-server', version: '1.0' },
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.method === 'tools/list') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {
|
||||
tools: [
|
||||
{ name: 'echo', description: 'Echo tool', inputSchema: {} },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {},
|
||||
}));
|
||||
});
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('ResilientTransportWrapper - Integration Tests', () => {
|
||||
let mockServer: MockHttpMcpServer;
|
||||
const TEST_PORT = 19080;
|
||||
const TEST_URL = `http://127.0.0.1:${TEST_PORT}/mcp`;
|
||||
|
||||
beforeEach(() => {
|
||||
mockServer = new MockHttpMcpServer(TEST_PORT);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await mockServer.stop();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Heartbeat Concurrency Protection', () => {
|
||||
it('should prevent multiple concurrent health checks with slow network', async () => {
|
||||
// Start mock server with slow response
|
||||
mockServer.setRequestDelay(500); // 500ms delay per request
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCalls = 0;
|
||||
let concurrentCalls = 0;
|
||||
let maxConcurrentCalls = 0;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'concurrency-test',
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 10000, // Long timeout to not interfere
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
concurrentCalls++;
|
||||
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
||||
|
||||
// Simulate slow health check
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
concurrentCalls--;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first health check
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// At this point, first health check is in progress (started but not completed)
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger more timer events while health check is in progress
|
||||
// These should be blocked by healthCheckInProgress guard
|
||||
await vi.advanceTimersByTimeAsync(1000); // Would trigger second check if not guarded
|
||||
await vi.advanceTimersByTimeAsync(1000); // Would trigger third check
|
||||
|
||||
// Complete the first health check
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Only 1 call should have started because of the guard
|
||||
expect(maxConcurrentCalls).toBe(1);
|
||||
|
||||
// After first check completes, next one can be scheduled
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle rapid heartbeat timer triggers without stacking', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
const healthCheckTimes: number[] = [];
|
||||
let startTime = Date.now();
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'stacking-test',
|
||||
pingIntervalMs: 500,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckTimes.push(Date.now() - startTime);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 10 "heartbeat cycles"
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(10); // Allow async to complete
|
||||
}
|
||||
|
||||
expect(healthCheckTimes.length).toBe(10);
|
||||
|
||||
// Verify each check is ~500ms apart (response-driven scheduling)
|
||||
for (let i = 1; i < healthCheckTimes.length; i++) {
|
||||
const diff = healthCheckTimes[i] - healthCheckTimes[i - 1];
|
||||
// Should be at least 500ms (pingIntervalMs), not stacked
|
||||
expect(diff).toBeGreaterThanOrEqual(500);
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response-Driven Scheduling', () => {
|
||||
it('should schedule next check only after current one completes', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let checkCompleted = false;
|
||||
let nextCheckScheduledBeforeComplete = false;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'response-driven-test',
|
||||
pingIntervalMs: 1000,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
// Check if next check was already scheduled (it shouldn't be)
|
||||
// This is a bit indirect, but we can verify by timing
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
checkCompleted = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first check
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// Check is in progress, not completed yet
|
||||
expect(checkCompleted).toBe(false);
|
||||
|
||||
// Complete the check
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(checkCompleted).toBe(true);
|
||||
|
||||
// Now next check should be scheduled 1000ms after completion
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
// Should not have triggered yet
|
||||
// (we'd need another counter to verify this properly)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
// Now it should trigger
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should not schedule next check when state changes during health check', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCount = 0;
|
||||
let shouldFailHealthCheck = false;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'state-change-test',
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCount++;
|
||||
if (shouldFailHealthCheck) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// First check passes
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(1);
|
||||
|
||||
// Make health checks fail
|
||||
shouldFailHealthCheck = true;
|
||||
|
||||
// Second check fails
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(2);
|
||||
|
||||
// Third check fails, triggers reconnect
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(3);
|
||||
|
||||
// After max failures, state should be reconnecting
|
||||
// No more health checks should run during reconnection
|
||||
const countBeforeReconnect = healthCheckCount;
|
||||
await vi.advanceTimersByTimeAsync(50); // During reconnection delay
|
||||
|
||||
// Should not have increased because state is reconnecting
|
||||
expect(healthCheckCount).toBe(countBeforeReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-World Scenarios', () => {
|
||||
it('should handle server restart with pending health checks', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCount = 0;
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'restart-test',
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 3,
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCount++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
// Simulate reconnection handling
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run a few successful heartbeats
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(healthCheckCount).toBe(2);
|
||||
|
||||
// Simulate server going down
|
||||
mockServer.setShouldFail(true);
|
||||
|
||||
// Wait for health checks to fail
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Bring server back up
|
||||
mockServer.setShouldFail(false);
|
||||
|
||||
// Wait for reconnection
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should maintain heartbeat rhythm despite slow health checks', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
const checkTimes: number[] = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'rhythm-test',
|
||||
pingIntervalMs: 1000,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
checkTimes.push(Date.now() - startTime);
|
||||
// Simulate variable health check duration
|
||||
await new Promise(r => setTimeout(r, Math.random() * 200));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 5 cycles
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(250); // Account for health check duration
|
||||
}
|
||||
|
||||
expect(checkTimes.length).toBe(5);
|
||||
|
||||
// Verify that checks don't stack up despite variable duration
|
||||
// Each check should start approximately 1000ms after the previous one completed
|
||||
for (let i = 1; i < checkTimes.length; i++) {
|
||||
const diff = checkTimes[i] - checkTimes[i - 1];
|
||||
// Should be around 1000ms + health check duration
|
||||
expect(diff).toBeGreaterThanOrEqual(1000);
|
||||
expect(diff).toBeLessThan(1500); // Not stacked
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user