fix(realtime): force-destroy pg socket on cleanup timeout (SSE leak) (#44)
Three SSE-routes (solo, backlog, notifications) each create a long- running pg.Client that LISTENs on scrum4me_changes. On abrupt close (Fast Refresh, browser refresh, Vercel function recycle) the pgClient.end()-await sometimes hangs silently, leaving the underlying socket connected to Postgres. The connection stays in 'idle' on Neon's side and after ~10-20 reconnects the connection-pool fills up — new SSE connects fail with ERR_INCOMPLETE_CHUNKED_ENCODING in the browser. Fix: shared `closePgClientSafely` helper that races client.end() against a 2 s timeout; on timeout it force-destroys the underlying socket so the OS releases the FD and Postgres notices the disconnect. Validated by direct DB inspection: 18 stale 'idle LISTEN'-connections were piled up before the fix; after manual pg_terminate_backend cleanup the SSE-stream stabilised. This change makes the pile-up impossible going forward. - new lib/realtime/pg-client-cleanup.ts - 3 routes use the helper instead of bare `await pgClient.end()` - 3 unit tests for the helper (timely-end, hang-falls-back-to-destroy, end-rejection-is-swallowed) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
070e1d9ea2
commit
6c6c8b96b7
5 changed files with 127 additions and 11 deletions
66
__tests__/lib/realtime/pg-client-cleanup.test.ts
Normal file
66
__tests__/lib/realtime/pg-client-cleanup.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Client } from 'pg'
|
||||
import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup'
|
||||
|
||||
function makeFakeClient(opts: {
|
||||
endResolves?: Promise<void>
|
||||
destroy?: ReturnType<typeof vi.fn>
|
||||
}): Client {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>()
|
||||
const fake = {
|
||||
end: vi.fn().mockReturnValue(opts.endResolves ?? Promise.resolve()),
|
||||
on: vi.fn((event: string, fn: (...args: unknown[]) => void) => {
|
||||
const list = handlers.get(event) ?? []
|
||||
list.push(fn)
|
||||
handlers.set(event, list)
|
||||
return fake
|
||||
}),
|
||||
removeAllListeners: vi.fn((event: string) => {
|
||||
handlers.delete(event)
|
||||
return fake
|
||||
}),
|
||||
connection: {
|
||||
stream: { destroy: opts.destroy ?? vi.fn() },
|
||||
},
|
||||
}
|
||||
return fake as unknown as Client
|
||||
}
|
||||
|
||||
describe('closePgClientSafely', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops listeners and awaits client.end() when it resolves quickly', async () => {
|
||||
const destroy = vi.fn()
|
||||
const client = makeFakeClient({ destroy })
|
||||
|
||||
await closePgClientSafely(client, 'test')
|
||||
|
||||
expect(client.removeAllListeners).toHaveBeenCalledWith('notification')
|
||||
expect(client.removeAllListeners).toHaveBeenCalledWith('error')
|
||||
expect(client.end).toHaveBeenCalledOnce()
|
||||
expect(destroy).not.toHaveBeenCalled() // ended in time
|
||||
})
|
||||
|
||||
it('falls back to socket-destroy when client.end() hangs past the timeout', async () => {
|
||||
const destroy = vi.fn()
|
||||
// .end() never resolves
|
||||
const client = makeFakeClient({ endResolves: new Promise(() => {}), destroy })
|
||||
|
||||
vi.useFakeTimers()
|
||||
const promise = closePgClientSafely(client, 'test-hang')
|
||||
await vi.advanceTimersByTimeAsync(2_001)
|
||||
await promise
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
const arg = destroy.mock.calls[0][0]
|
||||
expect(arg).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('does not throw when client.end() rejects', async () => {
|
||||
const client = makeFakeClient({ endResolves: Promise.reject(new Error('boom')) })
|
||||
|
||||
await expect(closePgClientSafely(client, 'test-reject')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue