Skip to content

Commit 451ca66

Browse files
committed
fix(knex): handle errors and stalls of the LISTEN probe client
The probe client had no error listener: a connection failure while it waited for its notification emitted an unhandled error event and killed the process, instead of rejecting watch(). Its errors now reject the probe. The probe client also ended gracefully without a bound, so a connection stalled in its handshake kept its socket open and could hold the process at shutdown. It now ends like the LISTEN client: its socket is destroyed when end() does not settle in time. Both keep an error listener while they end.
1 parent b5b62b2 commit 451ca66

2 files changed

Lines changed: 80 additions & 16 deletions

File tree

‎src/drivers/knex_adapter.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,21 @@ function wakeChannelName(table: string): string {
9696
return `queue_jobs_${createHash('sha256').update(table).digest('hex').slice(0, 40)}`
9797
}
9898

99+
/**
100+
* End a LISTEN or probe client. A graceful end waits for the server, which a
101+
* stalled connection never answers: its socket is destroyed after a while.
102+
*/
103+
async function endListenClient(client: PgListenClient): Promise<void> {
104+
client.removeAllListeners()
105+
client.on('error', () => {})
106+
const timer = setTimeout(
107+
() => client.connection?.stream?.destroy(),
108+
subscriptionHeartbeat.timeout
109+
)
110+
await client.end().catch(() => {})
111+
clearTimeout(timer)
112+
}
113+
99114
/** The message of a job of `queue` ready now, or due at `at`. */
100115
function jobMessage(queue: string, at?: number): WakeMessage {
101116
return at === undefined ? { ready: [queue] } : { due: { [queue]: at } }
@@ -358,14 +373,7 @@ export class KnexAdapter implements Adapter {
358373
const current = client
359374
client = undefined
360375
if (!current) return
361-
current.removeAllListeners()
362-
// A graceful end waits for the server, which a stalled connection never answers.
363-
const timer = setTimeout(
364-
() => current.connection?.stream?.destroy(),
365-
subscriptionHeartbeat.timeout
366-
)
367-
await current.end().catch(() => {})
368-
clearTimeout(timer)
376+
await endListenClient(current)
369377
}
370378

371379
// A connection config provider is called again once its settings expire,
@@ -437,6 +445,10 @@ export class KnexAdapter implements Adapter {
437445
}
438446
const { received, payload } = probe
439447
const sender = await createClient()
448+
// An error event without a listener would crash the process.
449+
const failed = Promise.withResolvers<never>()
450+
failed.promise.catch(() => {})
451+
sender.on('error', (error) => failed.reject(error))
440452
let timer: NodeJS.Timeout | undefined
441453
const timeout = new Promise<never>((_resolve, reject) => {
442454
timer = setTimeout(
@@ -458,11 +470,11 @@ export class KnexAdapter implements Adapter {
458470
})()
459471
exchange.catch(() => {})
460472
try {
461-
await Promise.race([exchange, timeout, closing.promise])
473+
await Promise.race([exchange, failed.promise, timeout, closing.promise])
462474
} finally {
463475
clearTimeout(timer)
464476
probe = undefined
465-
void sender.end().catch(() => {})
477+
void endListenClient(sender)
466478
}
467479
}
468480

‎tests/adapter.spec.ts‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2611,11 +2611,15 @@ test.group('Adapter | Knex (PostgreSQL)', (group) => {
26112611
* client gets the behavior `behave` returns for its 1-based creation
26122612
* number: 'real' for a working connection, or a fake one.
26132613
*/
2614+
type FakeBehavior = {
2615+
connect?: () => Promise<void>
2616+
query?: () => Promise<void>
2617+
end?: () => Promise<void>
2618+
}
2619+
26142620
function fakeListenClients(
26152621
cleanup: (callback: () => void) => void,
2616-
behave: (
2617-
index: number
2618-
) => 'real' | { connect?: () => Promise<void>; query?: () => Promise<void> }
2622+
behave: (index: number) => 'real' | FakeBehavior
26192623
) {
26202624
type PgClient = new (settings: unknown) => {
26212625
end(): Promise<void>
@@ -2630,16 +2634,24 @@ test.group('Adapter | Knex (PostgreSQL)', (group) => {
26302634
client.driver = driver
26312635
})
26322636

2633-
const created: Array<{ ended: boolean; emit: (event: string, error?: Error) => void }> = []
2637+
const created: Array<{
2638+
ended: boolean
2639+
destroyed?: boolean
2640+
emit: (event: string, error?: Error) => void
2641+
}> = []
26342642
const FakeClient = class {
26352643
#listeners = new Map<string, (error?: Error) => void>()
2636-
#behavior: { connect?: () => Promise<void>; query?: () => Promise<void> }
2644+
#behavior: FakeBehavior
26372645
state = {
26382646
ended: false,
2647+
destroyed: false,
26392648
emit: (event: string, error?: Error) => this.#listeners.get(event)?.(error),
26402649
}
26412650

2642-
constructor(behavior: { connect?: () => Promise<void>; query?: () => Promise<void> }) {
2651+
// As node-postgres: the socket, destroyed when end() does not settle.
2652+
connection = { stream: { destroy: () => (this.state.destroyed = true) } }
2653+
2654+
constructor(behavior: FakeBehavior) {
26432655
this.#behavior = behavior
26442656
created.push(this.state)
26452657
}
@@ -2658,6 +2670,7 @@ test.group('Adapter | Knex (PostgreSQL)', (group) => {
26582670
}
26592671
async end() {
26602672
this.state.ended = true
2673+
await this.#behavior.end?.()
26612674
}
26622675
}
26632676
client.driver.Client = function (settings: unknown) {
@@ -2693,6 +2706,45 @@ test.group('Adapter | Knex (PostgreSQL)', (group) => {
26932706
await assert.rejects(() => watching.watch!(['default']), /do not enable notifications/)
26942707
}).timeout(5000)
26952708

2709+
test('rejects a LISTEN probe whose connection fails', async ({ assert, cleanup }) => {
2710+
// The probe sends its notification, then its connection fails.
2711+
const created = fakeListenClients(cleanup, (index) =>
2712+
index === 1
2713+
? 'real'
2714+
: {
2715+
async query() {
2716+
setTimeout(() => created[1].emit('error', new Error('Connection terminated')), 10)
2717+
},
2718+
}
2719+
)
2720+
const watching = new KnexAdapter({
2721+
connection,
2722+
tableName,
2723+
schedulesTableName,
2724+
notifications: true,
2725+
})
2726+
2727+
await assert.rejects(() => watching.watch!(['default']), 'Connection terminated')
2728+
})
2729+
2730+
test('destroys a LISTEN probe connection that does not end', async ({ assert, cleanup }) => {
2731+
shortenHeartbeat(cleanup)
2732+
const never = () => new Promise<void>(() => {})
2733+
const created = fakeListenClients(cleanup, (index) =>
2734+
index === 1 ? 'real' : { connect: never, end: never }
2735+
)
2736+
const watching = new KnexAdapter({
2737+
connection,
2738+
tableName,
2739+
schedulesTableName,
2740+
notifications: true,
2741+
})
2742+
2743+
await assert.rejects(() => watching.watch!(['default']), /do not enable notifications/)
2744+
await new Promise((resolve) => setTimeout(resolve, 400))
2745+
assert.isTrue(created[1].destroyed)
2746+
}).timeout(5000)
2747+
26962748
test('aborts a first LISTEN connection still in its handshake', async ({ assert, cleanup }) => {
26972749
const created = fakeListenClients(cleanup, () => ({ connect: () => new Promise(() => {}) }))
26982750
const watching = new KnexAdapter({

0 commit comments

Comments
 (0)