@@ -3,6 +3,13 @@ import pg from 'pg'
33import assert from 'node:assert'
44import type * as types from './types.ts'
55
6+ // Keep silent network failures below the default 30-second notify polling backstop: in the
7+ // worst case a failure happens immediately after a successful check, then takes one interval,
8+ // one query timeout, and the existing first reconnect backoff (1s) to restore LISTEN.
9+ const LISTEN_HEARTBEAT_INTERVAL_MS = 10000
10+ const LISTEN_HEARTBEAT_TIMEOUT_MS = 5000
11+ const LISTEN_KEEP_ALIVE_INITIAL_DELAY_MS = 10000
12+
613class Db extends EventEmitter implements types . IDatabase , types . EventsMixin {
714 private pool ! : pg . Pool
815 private config : types . DatabaseOptions
@@ -57,8 +64,9 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
5764
5865 // Opens a dedicated, session-pinned connection for LISTEN/NOTIFY. A separate pg.Client
5966 // (not a pooled connection) is used so the listener never depletes the query pool and so
60- // reconnection is self-contained. On any drop the client reconnects with capped backoff
61- // and re-runs LISTEN, then calls onReconnect so the caller can recover missed messages.
67+ // reconnection is self-contained. TCP keepalive plus a same-session heartbeat detect silent
68+ // drops and lost subscriptions. The client reconnects with capped backoff, re-runs LISTEN,
69+ // then calls onReconnect so the caller can recover missed messages.
6270 async listen (
6371 channel : string ,
6472 onNotification : ( payload : string ) => void ,
@@ -69,14 +77,23 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
6977 let closed = false
7078 let client : pg . Client | null = null
7179 let reconnectTimer : ReturnType < typeof setTimeout > | null = null
80+ let heartbeatTimer : ReturnType < typeof setTimeout > | null = null
7281 let attempt = 0
82+ const heartbeatInterval = this . config . __test__listenHeartbeatIntervalMs ?? LISTEN_HEARTBEAT_INTERVAL_MS
83+ const heartbeatTimeout = this . config . __test__listenHeartbeatTimeoutMs ?? LISTEN_HEARTBEAT_TIMEOUT_MS
7384 // Only self-heal once the listener has been established at least once. If the INITIAL connect
7485 // fails, the rejection propagates to the caller (Notifier.start), which falls back to
7586 // polling-only and discards this subscription's close handle — so a reconnect scheduled from
7687 // the client 'error' handler would be an untracked connection nothing can close, keeping the
7788 // event loop alive and delivering notifications into a stopped manager.
7889 let established = false
7990
91+ const clearHeartbeat = ( ) => {
92+ if ( ! heartbeatTimer ) return
93+ clearTimeout ( heartbeatTimer )
94+ heartbeatTimer = null
95+ }
96+
8097 const scheduleReconnect = ( ) => {
8198 if ( closed || reconnectTimer ) return
8299 const backoff = Math . min ( 30000 , 1000 * 2 ** Math . min ( attempt , 5 ) )
@@ -87,21 +104,72 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
87104 } , backoff )
88105 }
89106
107+ const disconnect = ( target : pg . Client , error : Error ) => {
108+ if ( closed || client !== target ) return
109+
110+ clearHeartbeat ( )
111+ client = null
112+ target . removeAllListeners ( )
113+ target . end ( ) . catch ( ( ) => { } )
114+ this . emit ( 'error' , error )
115+ if ( established ) scheduleReconnect ( )
116+ }
117+
118+ const scheduleHeartbeat = ( target : pg . Client ) => {
119+ if ( closed || client !== target ) return
120+ heartbeatTimer = setTimeout ( ( ) => {
121+ heartbeatTimer = null
122+ heartbeat ( target ) . catch ( error => disconnect ( target , error ) )
123+ } , heartbeatInterval )
124+ }
125+
126+ const heartbeat = async ( target : pg . Client ) => {
127+ if ( closed || client !== target ) return
128+
129+ let timeout : ReturnType < typeof setTimeout > | null = null
130+ const query = target . query (
131+ `SELECT EXISTS (
132+ SELECT 1
133+ FROM pg_listening_channels() AS active(channel)
134+ WHERE channel = $1
135+ ) AS listening` ,
136+ [ channel ]
137+ )
138+ query . catch ( ( ) => { } )
139+
140+ try {
141+ const result = await Promise . race ( [
142+ query ,
143+ new Promise < never > ( ( resolve , reject ) => {
144+ timeout = setTimeout ( ( ) => reject ( new Error ( 'LISTEN/NOTIFY heartbeat timed out' ) ) , heartbeatTimeout )
145+ } )
146+ ] )
147+
148+ if ( ! result . rows [ 0 ] ?. listening ) {
149+ throw new Error ( 'LISTEN/NOTIFY channel registration was lost' )
150+ }
151+ } finally {
152+ if ( timeout ) clearTimeout ( timeout )
153+ }
154+
155+ scheduleHeartbeat ( target )
156+ }
157+
90158 const connect = async ( ) => {
91159 if ( closed ) return
92160
93- const next = new pg . Client ( this . config )
161+ const next = new pg . Client ( {
162+ ...this . config ,
163+ keepAlive : true ,
164+ keepAliveInitialDelayMillis : LISTEN_KEEP_ALIVE_INITIAL_DELAY_MS
165+ } )
94166
95167 next . on ( 'error' , error => {
96- this . emit ( 'error' , error )
97- if ( ! closed ) {
98- next . removeAllListeners ( )
99- next . end ( ) . catch ( ( ) => { } )
100- if ( client === next ) client = null
101- if ( established ) scheduleReconnect ( )
102- }
168+ disconnect ( next , error )
103169 } )
104170
171+ next . on ( 'end' , ( ) => disconnect ( next , new Error ( 'LISTEN/NOTIFY connection ended' ) ) )
172+
105173 next . on ( 'notification' , msg => {
106174 if ( msg . payload !== undefined ) onNotification ( msg . payload )
107175 } )
@@ -125,6 +193,7 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
125193
126194 attempt = 0
127195 established = true
196+ scheduleHeartbeat ( next )
128197 onReconnect ( )
129198 }
130199
@@ -137,6 +206,7 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
137206 clearTimeout ( reconnectTimer )
138207 reconnectTimer = null
139208 }
209+ clearHeartbeat ( )
140210 if ( client ) {
141211 client . removeAllListeners ( )
142212 await client . end ( ) . catch ( ( ) => { } )
0 commit comments