|
6 | 6 | // wait is 30s per query on the request path. SqlDriver now bounds it by default, |
7 | 7 | // while leaving a host's own choice alone. |
8 | 8 |
|
9 | | -import { describe, it, expect, afterEach } from 'vitest'; |
| 9 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
10 | 10 | import net from 'node:net'; |
11 | 11 | import { SqlDriver } from './sql-driver.js'; |
12 | 12 |
|
13 | | -/** A listener that accepts sockets and then says nothing, ever. */ |
| 13 | +/** |
| 14 | + * A listener that accepts sockets and then says nothing, ever. `accepted` |
| 15 | + * settles once the first socket is held — the moment the connection attempt |
| 16 | + * is a TCP connection that will never see a handshake. |
| 17 | + */ |
14 | 18 | function blackHole() { |
15 | 19 | const held: net.Socket[] = []; |
16 | | - const server = net.createServer((sock) => { sock.on('error', () => {}); held.push(sock); }); |
| 20 | + let firstAccepted!: () => void; |
| 21 | + const accepted = new Promise<void>((resolve) => { firstAccepted = resolve; }); |
| 22 | + const server = net.createServer((sock) => { |
| 23 | + sock.on('error', () => {}); |
| 24 | + held.push(sock); |
| 25 | + firstAccepted(); |
| 26 | + }); |
17 | 27 | return { |
| 28 | + accepted, |
18 | 29 | listen: () => new Promise<number>((resolve) => { |
19 | 30 | server.listen(0, '127.0.0.1', () => resolve((server.address() as net.AddressInfo).port)); |
20 | 31 | }), |
@@ -199,32 +210,61 @@ describe('SqlDriver — connection-attempt bound (framework#3769)', () => { |
199 | 210 | it('actually bounds a query against a black-holing endpoint', async () => { |
200 | 211 | const bh = blackHole(); |
201 | 212 | const port = await bh.listen(); |
| 213 | + // The endpoint is real: a loopback listener that holds the accepted socket |
| 214 | + // and never answers, so only the driver's own bound can end the attempt. |
| 215 | + // The CLOCK is faked (setTimeout/clearTimeout only — `net` I/O and the |
| 216 | + // microtask queue stay real), because the default bound is a 10s wall-clock |
| 217 | + // timer and waiting it out was the whole 10s this file used to cost. Fake |
| 218 | + // time still proves the bound: pg arms `connectionTimeoutMillis` and tarn |
| 219 | + // arms `createTimeoutMillis` on the faked `setTimeout`, so whichever |
| 220 | + // fires — or none, for an unbounded connect — shows in how far the fake |
| 221 | + // clock has to move before the query settles. |
| 222 | + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); |
202 | 223 | try { |
203 | 224 | // The default is 10s; unbounded would be knex/tarn's 30s. |
204 | 225 | const d = make({ |
205 | 226 | client: 'pg', |
206 | 227 | connection: `postgres://u:p@127.0.0.1:${port}/d`, |
207 | 228 | pool: { min: 0, max: 2 }, |
208 | 229 | }); |
209 | | - const started = Date.now(); |
210 | | - const err = await (d as any).knex.raw('SELECT 1').then( |
| 230 | + let err: Error | undefined; |
| 231 | + const outcome = (d as any).knex.raw('SELECT 1').then( |
211 | 232 | () => { throw new Error('query resolved but should have failed'); }, |
212 | | - (e: Error) => e, |
| 233 | + (e: Error) => { err = e; }, |
213 | 234 | ); |
214 | | - const elapsed = Date.now() - started; |
| 235 | + |
| 236 | + // Real I/O: do not move the clock until the server holds the socket, so |
| 237 | + // the attempt being timed is a connection that was accepted and then |
| 238 | + // went silent — not one that never started. |
| 239 | + await bh.accepted; |
| 240 | + |
| 241 | + // Move the fake clock in steps until the query settles, never past 20s. |
| 242 | + // Each step also lets a real macrotask run, so the socket teardown and |
| 243 | + // the rejection it causes land before the next step is judged. |
| 244 | + let elapsed = 0; |
| 245 | + while (err === undefined && elapsed < 20_000) { |
| 246 | + await vi.advanceTimersByTimeAsync(500); |
| 247 | + await new Promise<void>((resolve) => setImmediate(resolve)); |
| 248 | + elapsed += 500; |
| 249 | + } |
| 250 | + expect(err, 'still pending after 20s of fake time — the connect is unbounded').toBeDefined(); |
| 251 | + await outcome; // surfaces the "resolved but should have failed" throw, if any |
215 | 252 |
|
216 | 253 | // The pg-level timeout fires first (10s), so the message names the |
217 | 254 | // connection attempt rather than blaming pool sizing. Guarding against |
218 | 255 | // the regression is the point: knex's own wording would send an operator |
219 | 256 | // to tune `pool.max` while the network is what is broken. |
220 | | - expect(err.message).toContain('timeout expired'); |
221 | | - expect(err.message).not.toContain('pool is probably full'); |
| 257 | + expect(err!.message).toContain('timeout expired'); |
| 258 | + expect(err!.message).not.toContain('pool is probably full'); |
222 | 259 | expect(elapsed).toBeGreaterThan(8_000); |
223 | 260 | expect(elapsed).toBeLessThan(20_000); |
224 | 261 | } finally { |
| 262 | + // Real timers back BEFORE afterEach disconnects the driver and the |
| 263 | + // listener closes: teardown must not depend on a clock nobody advances. |
| 264 | + vi.useRealTimers(); |
225 | 265 | bh.close(); |
226 | 266 | } |
227 | | - }, 40_000); |
| 267 | + }, 15_000); |
228 | 268 |
|
229 | 269 | it('does not disturb a working sqlite connection', async () => { |
230 | 270 | const d = make({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); |
|
0 commit comments