diff --git a/packages/channels/src/adapters/discord/adapter.ts b/packages/channels/src/adapters/discord/adapter.ts index 6233487d..70370915 100644 --- a/packages/channels/src/adapters/discord/adapter.ts +++ b/packages/channels/src/adapters/discord/adapter.ts @@ -218,6 +218,36 @@ export class DiscordAdapter implements ChannelAdapter { return Promise.resolve(); } + /** + * Perform health check using Discord WebSocket ping + * + * @returns Promise that resolves to true if connection is healthy + */ + async healthCheck(): Promise { + if (!this.isStarted) { + return false; + } + + try { + // Check if client and WebSocket are available + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (!this.client.ws || this.client.ws.status !== 0) { + // Status 0 = READY + return false; + } + + // Discord.js WebSocket ping returns the latency in milliseconds + // Negative value or very high latency indicates unhealthy connection + const ping = this.client.ws.ping; + return ping >= 0 && ping < 10000; // Consider healthy if ping < 10s + } catch (error) { + this.logger.warn('Health check failed', { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + /** * Clean up session resources (threads, condensed display, tool tracking) * diff --git a/packages/channels/src/lifecycle.ts b/packages/channels/src/lifecycle.ts index 65af44cb..33496012 100644 --- a/packages/channels/src/lifecycle.ts +++ b/packages/channels/src/lifecycle.ts @@ -316,23 +316,33 @@ export class ChannelLifecycle { return; } - try { - // Simple health check: adapter should still be defined - // Platform-specific health checks could be added here - if (!this.adapter) { - throw new Error('Adapter not available'); - } + // Use async wrapper to handle platform-specific health checks + void (async () => { + try { + // Simple health check: adapter should still be defined + if (!this.adapter) { + throw new Error('Adapter not available'); + } - // Reset failure counter on success - if (this.consecutiveFailures > 0) { - this.consecutiveFailures = 0; - if (this.state === 'unhealthy') { - this.state = 'healthy'; + // Platform-specific health check if available + if (this.adapter.healthCheck) { + const isHealthy = await this.adapter.healthCheck(); + if (!isHealthy) { + throw new Error('Adapter health check failed'); + } } + + // Reset failure counter on success + if (this.consecutiveFailures > 0) { + this.consecutiveFailures = 0; + if (this.state === 'unhealthy') { + this.state = 'healthy'; + } + } + } catch { + this.handleHealthCheckFailure(); } - } catch { - this.handleHealthCheckFailure(); - } + })(); } /** diff --git a/packages/channels/test/adapters/discord/adapter.test.ts b/packages/channels/test/adapters/discord/adapter.test.ts index fee1a053..6fc6525c 100644 --- a/packages/channels/test/adapters/discord/adapter.test.ts +++ b/packages/channels/test/adapters/discord/adapter.test.ts @@ -1106,6 +1106,148 @@ describe('setupBotEventListeners()', () => { }); }); +describe('healthCheck()', () => { + let adapter: DiscordAdapter; + + beforeEach(() => { + (globalThis as Record).__mockClientRef = null; + adapter = new DiscordAdapter({ token: 'test-token' }); + }); + + afterEach(() => { + vi.clearAllMocks(); + (globalThis as Record).__mockClientRef = null; + }); + + it('should return false if adapter not started', async () => { + const result = await adapter.healthCheck(); + expect(result).toBe(false); + }); + + it('should return false if WebSocket not available', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate missing WebSocket + (mockClient as { ws: unknown }).ws = undefined; + + const result = await adapter.healthCheck(); + expect(result).toBe(false); + }); + + it('should return false if WebSocket status is not READY', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate non-READY status + (mockClient as { ws: { status: number } }).ws = { status: 1, ping: 50 }; // 1 = CONNECTING + + const result = await adapter.healthCheck(); + expect(result).toBe(false); + }); + + it('should return false if ping is negative', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate negative ping + (mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: -1 }; + + const result = await adapter.healthCheck(); + expect(result).toBe(false); + }); + + it('should return false if ping exceeds threshold', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate very high ping (> 10s) + (mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: 15000 }; + + const result = await adapter.healthCheck(); + expect(result).toBe(false); + }); + + it('should return true if connection is healthy', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate healthy connection + (mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: 50 }; + + const result = await adapter.healthCheck(); + expect(result).toBe(true); + }); + + it('should return true for ping at edge of threshold', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate ping just under 10s threshold + (mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: 9999 }; + + const result = await adapter.healthCheck(); + expect(result).toBe(true); + }); + + it('should return false and log warning on exception', async () => { + const startPromise = adapter.start(); + setImmediate(() => { + const client = getMockClient(); + client?.emit(Events.ClientReady, client); + }); + await startPromise; + + const mockClient = getMockClient(); + + // Simulate error accessing ws.ping + Object.defineProperty(mockClient, 'ws', { + get: () => { + throw new Error('WebSocket error'); + }, + }); + + const result = await adapter.healthCheck(); + expect(result).toBe(false); + }); +}); + describe('cleanupSession()', () => { let adapter: DiscordAdapter; diff --git a/packages/core/src/types/channel-adapter.ts b/packages/core/src/types/channel-adapter.ts index a4b520cf..0294e024 100644 --- a/packages/core/src/types/channel-adapter.ts +++ b/packages/core/src/types/channel-adapter.ts @@ -91,4 +91,15 @@ export interface ChannelAdapter { * @param channel - Channel identifier (platform-specific) */ sendTyping?(channel: string): Promise; + + /** + * Perform platform-specific health check (optional) + * + * Used by lifecycle managers to verify the adapter's connection is healthy. + * Platforms that support health checks (e.g., Discord's WebSocket ping) + * should implement this method. + * + * @returns Promise that resolves to true if healthy, false otherwise + */ + healthCheck?(): Promise; }