diff --git a/src/bin.ts b/src/bin.ts index c2dbb7b..2b4c537 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,4 +1,13 @@ import { CLI } from './cli.js' -const exitCode = await CLI.run(process.argv.slice(2)) -process.exit(exitCode) +// Top-level catch: without it, any error escaping CLI.run rejects the +// top-level await and Node kills the process with a raw stack trace — +// including errors thrown while a finally block runs during shutdown. +try { + const exitCode = await CLI.run(process.argv.slice(2)) + process.exit(exitCode) +} catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error(`[specbandit] Fatal: ${message}`) + process.exit(1) +} diff --git a/src/redisQueue.ts b/src/redisQueue.ts index 9cd548b..aaeb5b4 100644 --- a/src/redisQueue.ts +++ b/src/redisQueue.ts @@ -117,8 +117,19 @@ export class RedisQueue { }) } + /** + * Close the connection without ever throwing. QUIT is a regular command, + * so during an outage it hits commandTimeout and rejects — and close() + * runs in exit paths (finally blocks), where a throw masks the real error + * or crashes an otherwise-green run. Fall back to a hard disconnect, + * which drops the socket and pending commands without a round-trip. + */ async close(): Promise { - await this.redis.quit() + try { + await this.redis.quit() + } catch { + this.redis.disconnect() + } } private async withRetries(operation: string, fn: () => Promise): Promise { @@ -126,7 +137,10 @@ export class RedisQueue { try { return await fn() } catch (error) { - if (attempt === this.maxAttempts) throw error + if (attempt === this.maxAttempts) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Redis ${operation} failed after ${this.maxAttempts} attempts: ${message}`) + } const delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt - 1), MAX_BACKOFF_MS) console.warn(`[specbandit] Redis ${operation} failed (attempt ${attempt}/${this.maxAttempts}), retrying in ${delay}ms: ${error}`) await new Promise(resolve => setTimeout(resolve, delay)) diff --git a/test/redisQueue.test.ts b/test/redisQueue.test.ts index 390b9b9..f9e6016 100644 --- a/test/redisQueue.test.ts +++ b/test/redisQueue.test.ts @@ -12,6 +12,7 @@ describe('RedisQueue', () => { set: ReturnType exists: ReturnType quit: ReturnType + disconnect: ReturnType } beforeEach(() => { @@ -27,6 +28,7 @@ describe('RedisQueue', () => { set: vi.fn(), exists: vi.fn(), quit: vi.fn(), + disconnect: vi.fn(), } // Replace the redis instance with our mock @@ -224,7 +226,7 @@ describe('RedisQueue', () => { const result = await promise expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('persistent failure') + expect((result as Error).message).toBe('Redis length failed after 5 attempts: persistent failure') expect(mockRedis.llen).toHaveBeenCalledTimes(5) expect(warnSpy).toHaveBeenCalledTimes(4) @@ -305,6 +307,14 @@ describe('RedisQueue', () => { await queue.close() expect(mockRedis.quit).toHaveBeenCalled() + expect(mockRedis.disconnect).not.toHaveBeenCalled() + }) + + it('falls back to disconnect and does not throw when QUIT fails', async () => { + mockRedis.quit.mockRejectedValue(new Error('Command timed out')) + + await expect(queue.close()).resolves.toBeUndefined() + expect(mockRedis.disconnect).toHaveBeenCalled() }) }) })