@effectstream/db 0.104.0 → 0.104.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -1
- package/package.json +6 -6
- package/scripts/pgtyped-update.ts +137 -40
- package/scripts/start-pglite.ts +219 -21
- package/scripts/wait-for-db.ts +28 -22
- package/test/fixtures/pglite-close/01-sentinel.test.ts +10 -0
- package/test/fixtures/pglite-close/02-live-client.test.ts +27 -0
- package/test/fixtures/pglite-close/process-exit.ts +10 -0
- package/test/fixtures/pgtyped/failure.mjs +17 -0
- package/test/fixtures/pgtyped/hang.mjs +12 -0
- package/test/fixtures/pgtyped/success.mjs +30 -0
- package/test/pgtyped-update.test.ts +132 -0
- package/test/start-pglite-multifile.test.ts +33 -0
- package/test/start-pglite.test.ts +670 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { Client } from "pg";
|
|
5
|
+
import {
|
|
6
|
+
startPglite,
|
|
7
|
+
type PgliteHandle,
|
|
8
|
+
} from "../scripts/start-pglite.ts";
|
|
9
|
+
|
|
10
|
+
const connection = (handle: PgliteHandle) => ({
|
|
11
|
+
host: "127.0.0.1",
|
|
12
|
+
port: handle.port,
|
|
13
|
+
user: "postgres",
|
|
14
|
+
database: "postgres",
|
|
15
|
+
connectionTimeoutMillis: 500,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type PositiveClientFactory = (config: ReturnType<typeof connection>) => Client;
|
|
19
|
+
|
|
20
|
+
const createPositiveClient: PositiveClientFactory = (config) => new Client(config);
|
|
21
|
+
|
|
22
|
+
async function connect(
|
|
23
|
+
handle: PgliteHandle,
|
|
24
|
+
clientFactory: PositiveClientFactory = createPositiveClient,
|
|
25
|
+
): Promise<Client> {
|
|
26
|
+
await handle.db.waitReady;
|
|
27
|
+
const client = clientFactory(connection(handle));
|
|
28
|
+
await client.connect();
|
|
29
|
+
return client;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const controlledHandle = (waitReady: Promise<unknown>, port = 5432): PgliteHandle => ({
|
|
33
|
+
port,
|
|
34
|
+
db: { waitReady },
|
|
35
|
+
}) as unknown as PgliteHandle;
|
|
36
|
+
|
|
37
|
+
async function expectRefused(handle: PgliteHandle): Promise<void> {
|
|
38
|
+
const probe = new Client(connection(handle));
|
|
39
|
+
await expect(probe.connect()).rejects.toMatchObject({ code: "ECONNREFUSED" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const clientSocket = (client: Client): net.Socket =>
|
|
43
|
+
(client as unknown as { connection: { stream: net.Socket } }).connection.stream;
|
|
44
|
+
|
|
45
|
+
async function destroyHandled(client: Client): Promise<Error[]> {
|
|
46
|
+
const errors: Error[] = [];
|
|
47
|
+
client.on("error", (error) => errors.push(error));
|
|
48
|
+
clientSocket(client).destroy();
|
|
49
|
+
await Bun.sleep(20);
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitFor(
|
|
54
|
+
predicate: () => boolean,
|
|
55
|
+
description: string,
|
|
56
|
+
timeoutMs = 2_000,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const deadline = Date.now() + timeoutMs;
|
|
59
|
+
while (!predicate()) {
|
|
60
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`);
|
|
61
|
+
await Bun.sleep(10);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("positive client waits for readiness before construction", async () => {
|
|
66
|
+
const order: string[] = [];
|
|
67
|
+
let releaseReadiness!: () => void;
|
|
68
|
+
const readinessGate = new Promise<void>((resolve) => {
|
|
69
|
+
releaseReadiness = resolve;
|
|
70
|
+
});
|
|
71
|
+
const waitReady = readinessGate.then(() => {
|
|
72
|
+
order.push("readiness");
|
|
73
|
+
});
|
|
74
|
+
const fakeClient = {
|
|
75
|
+
connect: async () => {
|
|
76
|
+
order.push("connect");
|
|
77
|
+
},
|
|
78
|
+
} as unknown as Client;
|
|
79
|
+
let observedConfig: ReturnType<typeof connection> | undefined;
|
|
80
|
+
const clientFactory: PositiveClientFactory = (config) => {
|
|
81
|
+
order.push("construction");
|
|
82
|
+
observedConfig = config;
|
|
83
|
+
return fakeClient;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let connectionAttempt: Promise<Client> | undefined;
|
|
87
|
+
try {
|
|
88
|
+
const startedAt = Date.now();
|
|
89
|
+
connectionAttempt = connect(controlledHandle(waitReady), clientFactory);
|
|
90
|
+
await Bun.sleep(600);
|
|
91
|
+
|
|
92
|
+
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(600);
|
|
93
|
+
expect(order).toEqual([]);
|
|
94
|
+
expect(observedConfig).toBeUndefined();
|
|
95
|
+
|
|
96
|
+
releaseReadiness();
|
|
97
|
+
expect(await connectionAttempt).toBe(fakeClient);
|
|
98
|
+
expect(order).toEqual(["readiness", "construction", "connect"]);
|
|
99
|
+
expect(observedConfig?.connectionTimeoutMillis).toBe(500);
|
|
100
|
+
} finally {
|
|
101
|
+
releaseReadiness();
|
|
102
|
+
await connectionAttempt?.catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("positive client propagates readiness rejection before construction", async () => {
|
|
107
|
+
const readinessError = new Error("controlled readiness rejection");
|
|
108
|
+
const waitReady = Promise.reject(readinessError);
|
|
109
|
+
void waitReady.catch(() => {});
|
|
110
|
+
let constructionCount = 0;
|
|
111
|
+
let connectCount = 0;
|
|
112
|
+
const clientFactory: PositiveClientFactory = () => {
|
|
113
|
+
constructionCount += 1;
|
|
114
|
+
return {
|
|
115
|
+
connect: async () => {
|
|
116
|
+
connectCount += 1;
|
|
117
|
+
},
|
|
118
|
+
} as unknown as Client;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await expect(connect(controlledHandle(waitReady), clientFactory)).rejects.toBe(readinessError);
|
|
122
|
+
expect(constructionCount).toBe(0);
|
|
123
|
+
expect(connectCount).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("positive client preserves the 500 ms post-readiness handshake timeout", async () => {
|
|
127
|
+
const acceptedSockets: net.Socket[] = [];
|
|
128
|
+
const stalledServer = net.createServer((socket) => {
|
|
129
|
+
acceptedSockets.push(socket);
|
|
130
|
+
});
|
|
131
|
+
await new Promise<void>((resolve) => stalledServer.listen(0, "127.0.0.1", resolve));
|
|
132
|
+
const address = stalledServer.address();
|
|
133
|
+
if (!address || typeof address === "string") {
|
|
134
|
+
throw new Error("Unable to allocate a controlled loopback port.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await expect(connect(controlledHandle(Promise.resolve(), address.port))).rejects.toMatchObject({
|
|
139
|
+
message: "timeout expired",
|
|
140
|
+
});
|
|
141
|
+
expect(acceptedSockets).toHaveLength(1);
|
|
142
|
+
} finally {
|
|
143
|
+
for (const socket of acceptedSockets) socket.destroy();
|
|
144
|
+
await new Promise<void>((resolve, reject) => {
|
|
145
|
+
stalledServer.close((error) => error ? reject(error) : resolve());
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("default and forced close are bounded without clients", async () => {
|
|
151
|
+
const graceful = await startPglite(0);
|
|
152
|
+
const forced = await startPglite(0);
|
|
153
|
+
|
|
154
|
+
await Promise.race([
|
|
155
|
+
graceful.close(),
|
|
156
|
+
Bun.sleep(2_000).then(() => Promise.reject(new Error("default close timed out"))),
|
|
157
|
+
]);
|
|
158
|
+
await Promise.race([
|
|
159
|
+
forced.close({ force: true }),
|
|
160
|
+
Bun.sleep(2_000).then(() => Promise.reject(new Error("forced close timed out"))),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
expect(graceful.server.listening).toBe(false);
|
|
164
|
+
expect(forced.server.listening).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a client that drains first closes without a client error", async () => {
|
|
168
|
+
const handle = await startPglite(0);
|
|
169
|
+
const client = await connect(handle);
|
|
170
|
+
const errors: Error[] = [];
|
|
171
|
+
client.on("error", (error) => errors.push(error));
|
|
172
|
+
|
|
173
|
+
await client.end();
|
|
174
|
+
await handle.close();
|
|
175
|
+
|
|
176
|
+
expect(errors).toEqual([]);
|
|
177
|
+
expect(handle.server.listening).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("default close preserves multiple retained sockets and rejects new clients", async () => {
|
|
181
|
+
const handle = await startPglite(0);
|
|
182
|
+
const clients = await Promise.all([connect(handle), connect(handle)]);
|
|
183
|
+
const errors: Error[] = [];
|
|
184
|
+
for (const client of clients) client.on("error", (error) => errors.push(error));
|
|
185
|
+
|
|
186
|
+
await Promise.race([
|
|
187
|
+
handle.close(),
|
|
188
|
+
Bun.sleep(2_000).then(() => Promise.reject(new Error("default close timed out"))),
|
|
189
|
+
]);
|
|
190
|
+
await expectRefused(handle);
|
|
191
|
+
await Bun.sleep(20);
|
|
192
|
+
|
|
193
|
+
expect(errors).toEqual([]);
|
|
194
|
+
expect(clients.every((client) => !clientSocket(client).destroyed)).toBe(true);
|
|
195
|
+
for (const client of clients) clientSocket(client).destroy();
|
|
196
|
+
await Bun.sleep(20);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("default close unrefs retained sockets and defers cleanup until the last drains", async () => {
|
|
200
|
+
const handle = await startPglite(0);
|
|
201
|
+
const acceptedSockets: net.Socket[] = [];
|
|
202
|
+
handle.server.on("connection", (socket) => acceptedSockets.push(socket));
|
|
203
|
+
const clients = await Promise.all([connect(handle), connect(handle)]);
|
|
204
|
+
const errors: Error[] = [];
|
|
205
|
+
for (const client of clients) client.on("error", (error) => errors.push(error));
|
|
206
|
+
const unrefCounts = acceptedSockets.map(() => 0);
|
|
207
|
+
acceptedSockets.forEach((socket, index) => {
|
|
208
|
+
const originalUnref = socket.unref.bind(socket);
|
|
209
|
+
socket.unref = () => {
|
|
210
|
+
unrefCounts[index] += 1;
|
|
211
|
+
return originalUnref();
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const originalDbClose = handle.db.close.bind(handle.db);
|
|
216
|
+
let dbCloseCount = 0;
|
|
217
|
+
let dbCloseComplete = false;
|
|
218
|
+
handle.db.close = async () => {
|
|
219
|
+
dbCloseCount += 1;
|
|
220
|
+
await originalDbClose();
|
|
221
|
+
dbCloseComplete = true;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
await handle.close();
|
|
225
|
+
expect(acceptedSockets).toHaveLength(2);
|
|
226
|
+
expect(unrefCounts).toEqual([1, 1]);
|
|
227
|
+
expect(dbCloseCount).toBe(0);
|
|
228
|
+
expect((await clients[0].query("SELECT 1 AS value")).rows).toEqual([{ value: 1 }]);
|
|
229
|
+
expect(errors).toEqual([]);
|
|
230
|
+
|
|
231
|
+
clientSocket(clients[0]).destroy();
|
|
232
|
+
await waitFor(() => acceptedSockets[0].destroyed, "the first accepted socket to drain");
|
|
233
|
+
expect(dbCloseCount).toBe(0);
|
|
234
|
+
|
|
235
|
+
clientSocket(clients[1]).destroy();
|
|
236
|
+
await waitFor(() => dbCloseComplete, "deferred PGlite cleanup");
|
|
237
|
+
expect(dbCloseCount).toBe(1);
|
|
238
|
+
expect(errors).toHaveLength(2);
|
|
239
|
+
expect(errors.every((error) => error.message === "Connection terminated unexpectedly")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("an unrefed retained server socket does not keep its owner process alive", async () => {
|
|
243
|
+
const child = Bun.spawn([
|
|
244
|
+
process.execPath,
|
|
245
|
+
new URL("./fixtures/pglite-close/process-exit.ts", import.meta.url).pathname,
|
|
246
|
+
], {
|
|
247
|
+
cwd: new URL("../../../..", import.meta.url).pathname,
|
|
248
|
+
stdout: "pipe",
|
|
249
|
+
stderr: "pipe",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const decoder = new TextDecoder();
|
|
253
|
+
let stdout = "";
|
|
254
|
+
let reportPort: ((port: number) => void) | undefined;
|
|
255
|
+
const portReported = new Promise<number>((resolve) => {
|
|
256
|
+
reportPort = resolve;
|
|
257
|
+
});
|
|
258
|
+
const stdoutComplete = (async () => {
|
|
259
|
+
for await (const chunk of child.stdout) {
|
|
260
|
+
stdout += decoder.decode(chunk, { stream: true });
|
|
261
|
+
const match = stdout.match(/PGLITE_PROCESS_EXIT_PORT:(\d+)/);
|
|
262
|
+
if (match && reportPort) {
|
|
263
|
+
reportPort(Number(match[1]));
|
|
264
|
+
reportPort = undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
stdout += decoder.decode();
|
|
268
|
+
return stdout;
|
|
269
|
+
})();
|
|
270
|
+
const stderrComplete = new Response(child.stderr).text();
|
|
271
|
+
|
|
272
|
+
let socket: net.Socket | undefined;
|
|
273
|
+
try {
|
|
274
|
+
const port = await Promise.race([
|
|
275
|
+
portReported,
|
|
276
|
+
Bun.sleep(5_000).then(() => Promise.reject(new Error("child did not report its port"))),
|
|
277
|
+
]);
|
|
278
|
+
socket = net.createConnection({ host: "127.0.0.1", port });
|
|
279
|
+
socket.on("error", () => {});
|
|
280
|
+
await new Promise<void>((resolve) => socket?.once("connect", resolve));
|
|
281
|
+
|
|
282
|
+
const exitCode = await Promise.race([
|
|
283
|
+
child.exited,
|
|
284
|
+
Bun.sleep(5_000).then(() => Promise.reject(
|
|
285
|
+
new Error(`retained socket kept child alive; stdout so far: ${stdout}`),
|
|
286
|
+
)),
|
|
287
|
+
]);
|
|
288
|
+
const [childStdout, childStderr] = await Promise.all([stdoutComplete, stderrComplete]);
|
|
289
|
+
const output = `${childStdout}\n${childStderr}`;
|
|
290
|
+
expect(exitCode, output).toBe(0);
|
|
291
|
+
expect(output).toContain("PGLITE_PROCESS_EXIT_DEFAULT_CLOSED");
|
|
292
|
+
} finally {
|
|
293
|
+
socket?.destroy();
|
|
294
|
+
if (child.exitCode === null) child.kill();
|
|
295
|
+
await child.exited;
|
|
296
|
+
}
|
|
297
|
+
}, 15_000);
|
|
298
|
+
|
|
299
|
+
test("forced close destroys every retained socket before settling", async () => {
|
|
300
|
+
const handle = await startPglite(0);
|
|
301
|
+
const clients = await Promise.all([connect(handle), connect(handle)]);
|
|
302
|
+
const errors: Error[] = [];
|
|
303
|
+
for (const client of clients) client.on("error", (error) => errors.push(error));
|
|
304
|
+
|
|
305
|
+
await handle.close({ force: true });
|
|
306
|
+
await Bun.sleep(20);
|
|
307
|
+
|
|
308
|
+
expect(clients.every((client) => clientSocket(client).destroyed)).toBe(true);
|
|
309
|
+
expect(errors).toHaveLength(2);
|
|
310
|
+
expect(errors.every((error) => error.message === "Connection terminated unexpectedly")).toBe(true);
|
|
311
|
+
expect(handle.server.listening).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("repeated and concurrent close calls share the first default operation", async () => {
|
|
315
|
+
const handle = await startPglite(0);
|
|
316
|
+
const client = await connect(handle);
|
|
317
|
+
const errors: Error[] = [];
|
|
318
|
+
client.on("error", (error) => errors.push(error));
|
|
319
|
+
|
|
320
|
+
const first = handle.close();
|
|
321
|
+
const sameMode = handle.close();
|
|
322
|
+
const attemptedEscalation = handle.close({ force: true });
|
|
323
|
+
|
|
324
|
+
expect(sameMode).toBe(first);
|
|
325
|
+
expect(attemptedEscalation).toBe(first);
|
|
326
|
+
await Promise.all([first, sameMode, attemptedEscalation]);
|
|
327
|
+
await Bun.sleep(20);
|
|
328
|
+
expect(errors).toEqual([]);
|
|
329
|
+
expect(clientSocket(client).destroyed).toBe(false);
|
|
330
|
+
expect(handle.close({ force: true })).toBe(first);
|
|
331
|
+
|
|
332
|
+
clientSocket(client).destroy();
|
|
333
|
+
await Bun.sleep(20);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("repeated and concurrent close calls share the first forced operation", async () => {
|
|
337
|
+
const handle = await startPglite(0);
|
|
338
|
+
const client = await connect(handle);
|
|
339
|
+
client.on("error", () => {});
|
|
340
|
+
|
|
341
|
+
const first = handle.close({ force: true });
|
|
342
|
+
const sameMode = handle.close({ force: true });
|
|
343
|
+
const attemptedDowngrade = handle.close();
|
|
344
|
+
|
|
345
|
+
expect(sameMode).toBe(first);
|
|
346
|
+
expect(attemptedDowngrade).toBe(first);
|
|
347
|
+
await Promise.all([first, sameMode, attemptedDowngrade]);
|
|
348
|
+
await Bun.sleep(20);
|
|
349
|
+
expect(clientSocket(client).destroyed).toBe(true);
|
|
350
|
+
expect(handle.close()).toBe(first);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("port zero reports the actual IPv4 loopback listener", async () => {
|
|
354
|
+
const handle = await startPglite(0);
|
|
355
|
+
try {
|
|
356
|
+
const address = handle.server.address();
|
|
357
|
+
expect(address).not.toBeNull();
|
|
358
|
+
expect(typeof address).not.toBe("string");
|
|
359
|
+
if (!address || typeof address === "string") return;
|
|
360
|
+
expect(handle.port).toBe(address.port);
|
|
361
|
+
expect(handle.port).toBeGreaterThan(0);
|
|
362
|
+
expect(address.address).toBe("127.0.0.1");
|
|
363
|
+
expect(address.family).toBe("IPv4");
|
|
364
|
+
} finally {
|
|
365
|
+
await handle.close({ force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("occupied-port startup rejects and closes the created PGlite instance", async () => {
|
|
370
|
+
const blocker = net.createServer();
|
|
371
|
+
await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve));
|
|
372
|
+
const address = blocker.address();
|
|
373
|
+
if (!address || typeof address === "string") throw new Error("Unable to reserve a port.");
|
|
374
|
+
|
|
375
|
+
const originalClose = PGlite.prototype.close;
|
|
376
|
+
let dbCloseCount = 0;
|
|
377
|
+
PGlite.prototype.close = async function () {
|
|
378
|
+
dbCloseCount += 1;
|
|
379
|
+
return await originalClose.call(this);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
await expect(startPglite(address.port)).rejects.toMatchObject({ code: "EADDRINUSE" });
|
|
384
|
+
expect(dbCloseCount).toBe(1);
|
|
385
|
+
} finally {
|
|
386
|
+
PGlite.prototype.close = originalClose;
|
|
387
|
+
await new Promise<void>((resolve, reject) => {
|
|
388
|
+
blocker.close((error) => error ? reject(error) : resolve());
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("startup reports both listen and database-cleanup failures deterministically", async () => {
|
|
394
|
+
const blocker = net.createServer();
|
|
395
|
+
await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve));
|
|
396
|
+
const address = blocker.address();
|
|
397
|
+
if (!address || typeof address === "string") throw new Error("Unable to reserve a port.");
|
|
398
|
+
|
|
399
|
+
const originalClose = PGlite.prototype.close;
|
|
400
|
+
const databaseError = new Error("injected startup database close failure");
|
|
401
|
+
PGlite.prototype.close = async function () {
|
|
402
|
+
await originalClose.call(this);
|
|
403
|
+
throw databaseError;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
try {
|
|
408
|
+
await startPglite(address.port);
|
|
409
|
+
throw new Error("startup unexpectedly succeeded");
|
|
410
|
+
} catch (error) {
|
|
411
|
+
expect(error).toBeInstanceOf(AggregateError);
|
|
412
|
+
const errors = (error as AggregateError).errors;
|
|
413
|
+
expect(errors).toHaveLength(2);
|
|
414
|
+
expect(errors[0]).toMatchObject({ code: "EADDRINUSE" });
|
|
415
|
+
expect(errors[1]).toBe(databaseError);
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
PGlite.prototype.close = originalClose;
|
|
419
|
+
await new Promise<void>((resolve, reject) => {
|
|
420
|
+
blocker.close((error) => error ? reject(error) : resolve());
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("listener close failure still closes the database and remains shared", async () => {
|
|
426
|
+
const handle = await startPglite(0);
|
|
427
|
+
const originalServerClose = handle.server.close.bind(handle.server);
|
|
428
|
+
const originalDbClose = handle.db.close.bind(handle.db);
|
|
429
|
+
const listenerError = new Error("injected listener close failure");
|
|
430
|
+
let dbCloseCount = 0;
|
|
431
|
+
|
|
432
|
+
handle.server.close = ((callback?: (error?: Error) => void) => {
|
|
433
|
+
callback?.(listenerError);
|
|
434
|
+
return handle.server;
|
|
435
|
+
}) as typeof handle.server.close;
|
|
436
|
+
handle.db.close = async () => {
|
|
437
|
+
dbCloseCount += 1;
|
|
438
|
+
await originalDbClose();
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const first = handle.close();
|
|
442
|
+
const repeated = handle.close({ force: true });
|
|
443
|
+
expect(repeated).toBe(first);
|
|
444
|
+
await expect(first).rejects.toBe(listenerError);
|
|
445
|
+
await expect(repeated).rejects.toBe(listenerError);
|
|
446
|
+
expect(dbCloseCount).toBe(1);
|
|
447
|
+
|
|
448
|
+
handle.server.close = originalServerClose as typeof handle.server.close;
|
|
449
|
+
await new Promise<void>((resolve, reject) => {
|
|
450
|
+
originalServerClose((error) => error ? reject(error) : resolve());
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("multiple cleanup failures are deterministic and database cleanup is attempted once", async () => {
|
|
455
|
+
const handle = await startPglite(0);
|
|
456
|
+
const originalServerClose = handle.server.close.bind(handle.server);
|
|
457
|
+
const originalDbClose = handle.db.close.bind(handle.db);
|
|
458
|
+
const listenerError = new Error("injected listener close failure");
|
|
459
|
+
const databaseError = new Error("injected database close failure");
|
|
460
|
+
let dbCloseCount = 0;
|
|
461
|
+
|
|
462
|
+
handle.server.close = ((callback?: (error?: Error) => void) => {
|
|
463
|
+
callback?.(listenerError);
|
|
464
|
+
return handle.server;
|
|
465
|
+
}) as typeof handle.server.close;
|
|
466
|
+
handle.db.close = async () => {
|
|
467
|
+
dbCloseCount += 1;
|
|
468
|
+
throw databaseError;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const cleanup = handle.close({ force: true });
|
|
472
|
+
try {
|
|
473
|
+
await cleanup;
|
|
474
|
+
throw new Error("cleanup unexpectedly succeeded");
|
|
475
|
+
} catch (error) {
|
|
476
|
+
expect(error).toBeInstanceOf(AggregateError);
|
|
477
|
+
expect((error as AggregateError).errors).toEqual([listenerError, databaseError]);
|
|
478
|
+
}
|
|
479
|
+
expect(handle.close()).toBe(cleanup);
|
|
480
|
+
await expect(handle.close()).rejects.toBeInstanceOf(AggregateError);
|
|
481
|
+
expect(dbCloseCount).toBe(1);
|
|
482
|
+
|
|
483
|
+
handle.server.close = originalServerClose as typeof handle.server.close;
|
|
484
|
+
handle.db.close = originalDbClose;
|
|
485
|
+
await new Promise<void>((resolve, reject) => {
|
|
486
|
+
originalServerClose((error) => error ? reject(error) : resolve());
|
|
487
|
+
});
|
|
488
|
+
await originalDbClose();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("an externally stopped listener still permits database cleanup", async () => {
|
|
492
|
+
const handle = await startPglite(0);
|
|
493
|
+
await new Promise<void>((resolve, reject) => {
|
|
494
|
+
handle.server.close((error) => error ? reject(error) : resolve());
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await handle.close({ force: true });
|
|
498
|
+
expect(handle.server.listening).toBe(false);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("forced close awaits a live socket and an externally closing listener", async () => {
|
|
502
|
+
const handle = await startPglite(0);
|
|
503
|
+
let acceptedSocket: net.Socket | undefined;
|
|
504
|
+
handle.server.once("connection", (socket) => {
|
|
505
|
+
acceptedSocket = socket;
|
|
506
|
+
});
|
|
507
|
+
const client = await connect(handle);
|
|
508
|
+
client.on("error", () => {});
|
|
509
|
+
if (!acceptedSocket) throw new Error("The gateway did not track the accepted socket.");
|
|
510
|
+
|
|
511
|
+
const order: string[] = [];
|
|
512
|
+
let acceptedSocketClosed = false;
|
|
513
|
+
let listenerCloseComplete = false;
|
|
514
|
+
acceptedSocket.once("close", () => {
|
|
515
|
+
acceptedSocketClosed = true;
|
|
516
|
+
order.push("socket-close");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const originalDbClose = handle.db.close.bind(handle.db);
|
|
520
|
+
handle.db.close = async () => {
|
|
521
|
+
order.push("db-close");
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const externalListenerClose = new Promise<void>((resolve, reject) => {
|
|
525
|
+
handle.server.close((error) => {
|
|
526
|
+
if (error) {
|
|
527
|
+
reject(error);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
listenerCloseComplete = true;
|
|
531
|
+
order.push("listener-close");
|
|
532
|
+
resolve();
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
await handle.close({ force: true });
|
|
538
|
+
order.push("handle-close-resolved");
|
|
539
|
+
|
|
540
|
+
expect(acceptedSocketClosed).toBe(true);
|
|
541
|
+
expect(listenerCloseComplete).toBe(true);
|
|
542
|
+
expect(order.indexOf("socket-close")).toBeLessThan(order.indexOf("db-close"));
|
|
543
|
+
expect(order.indexOf("listener-close")).toBeLessThan(order.indexOf("db-close"));
|
|
544
|
+
expect(order.at(-1)).toBe("handle-close-resolved");
|
|
545
|
+
} finally {
|
|
546
|
+
await externalListenerClose;
|
|
547
|
+
handle.db.close = originalDbClose;
|
|
548
|
+
await originalDbClose();
|
|
549
|
+
clientSocket(client).destroy();
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("a genuine PostgreSQL query error remains visible", async () => {
|
|
554
|
+
const handle = await startPglite(0);
|
|
555
|
+
const client = await connect(handle);
|
|
556
|
+
client.on("error", () => {});
|
|
557
|
+
try {
|
|
558
|
+
await expect(client.query("SELECT * FROM table_that_does_not_exist")).rejects.toMatchObject({
|
|
559
|
+
code: "42P01",
|
|
560
|
+
});
|
|
561
|
+
} finally {
|
|
562
|
+
await handle.close({ force: true });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("default close settles while an in-flight query completes on its retained socket", async () => {
|
|
567
|
+
const handle = await startPglite(0);
|
|
568
|
+
const client = await connect(handle);
|
|
569
|
+
const errors: Error[] = [];
|
|
570
|
+
client.on("error", (error) => errors.push(error));
|
|
571
|
+
|
|
572
|
+
const query = client.query("SELECT pg_sleep(0.05), 1 AS value");
|
|
573
|
+
await Bun.sleep(10);
|
|
574
|
+
const cleanup = handle.close();
|
|
575
|
+
await cleanup;
|
|
576
|
+
const result = await query;
|
|
577
|
+
|
|
578
|
+
expect(result.rows).toHaveLength(1);
|
|
579
|
+
expect(errors).toEqual([]);
|
|
580
|
+
await destroyHandled(client);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("default close drains a query submitted by an accepted client as close begins", async () => {
|
|
584
|
+
const handle = await startPglite(0);
|
|
585
|
+
const client = await connect(handle);
|
|
586
|
+
const errors: Error[] = [];
|
|
587
|
+
client.on("error", (error) => errors.push(error));
|
|
588
|
+
|
|
589
|
+
const cleanup = handle.close();
|
|
590
|
+
const result = await client.query("SELECT 1 AS value");
|
|
591
|
+
await cleanup;
|
|
592
|
+
|
|
593
|
+
expect(result.rows).toEqual([{ value: 1 }]);
|
|
594
|
+
expect(errors).toEqual([]);
|
|
595
|
+
await destroyHandled(client);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("last socket close waits for final serialized work before deferred database cleanup", async () => {
|
|
599
|
+
const handle = await startPglite(0);
|
|
600
|
+
let acceptedSocket: net.Socket | undefined;
|
|
601
|
+
handle.server.once("connection", (socket) => {
|
|
602
|
+
acceptedSocket = socket;
|
|
603
|
+
});
|
|
604
|
+
const client = await connect(handle);
|
|
605
|
+
client.on("error", () => {});
|
|
606
|
+
|
|
607
|
+
const originalExec = handle.db.execProtocolRaw.bind(handle.db);
|
|
608
|
+
const originalDbClose = handle.db.close.bind(handle.db);
|
|
609
|
+
let releaseQuery!: () => void;
|
|
610
|
+
let queryEntered!: () => void;
|
|
611
|
+
const queryGate = new Promise<void>((resolve) => {
|
|
612
|
+
releaseQuery = resolve;
|
|
613
|
+
});
|
|
614
|
+
const queryStarted = new Promise<void>((resolve) => {
|
|
615
|
+
queryEntered = resolve;
|
|
616
|
+
});
|
|
617
|
+
let dbCloseCount = 0;
|
|
618
|
+
let dbCloseComplete = false;
|
|
619
|
+
handle.db.execProtocolRaw = async (...args) => {
|
|
620
|
+
queryEntered();
|
|
621
|
+
await queryGate;
|
|
622
|
+
return await originalExec(...args);
|
|
623
|
+
};
|
|
624
|
+
handle.db.close = async () => {
|
|
625
|
+
dbCloseCount += 1;
|
|
626
|
+
await originalDbClose();
|
|
627
|
+
dbCloseComplete = true;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const query = client.query("SELECT 1 AS value").catch(() => undefined);
|
|
631
|
+
await queryStarted;
|
|
632
|
+
clientSocket(client).destroy();
|
|
633
|
+
await waitFor(() => acceptedSocket?.destroyed === true, "the final accepted socket to close");
|
|
634
|
+
const cleanup = handle.close();
|
|
635
|
+
await Bun.sleep(20);
|
|
636
|
+
expect(dbCloseCount).toBe(0);
|
|
637
|
+
|
|
638
|
+
releaseQuery();
|
|
639
|
+
await cleanup;
|
|
640
|
+
await query;
|
|
641
|
+
await waitFor(() => dbCloseComplete, "database cleanup after final serialized work");
|
|
642
|
+
expect(dbCloseCount).toBe(1);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("a late deferred database-cleanup failure is observed without rejecting settled close", async () => {
|
|
646
|
+
const handle = await startPglite(0);
|
|
647
|
+
const client = await connect(handle);
|
|
648
|
+
client.on("error", () => {});
|
|
649
|
+
const originalDbClose = handle.db.close.bind(handle.db);
|
|
650
|
+
const originalConsoleError = console.error;
|
|
651
|
+
const databaseError = new Error("injected deferred database close failure");
|
|
652
|
+
const observed: unknown[][] = [];
|
|
653
|
+
handle.db.close = async () => {
|
|
654
|
+
throw databaseError;
|
|
655
|
+
};
|
|
656
|
+
console.error = (...args: unknown[]) => observed.push(args);
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
await handle.close();
|
|
660
|
+
clientSocket(client).destroy();
|
|
661
|
+
await waitFor(() => observed.length === 1, "the deferred cleanup failure to be observed");
|
|
662
|
+
expect(observed[0][0]).toBe("database: deferred PGlite cleanup failed");
|
|
663
|
+
expect(observed[0][1]).toBe(databaseError);
|
|
664
|
+
expect(handle.close({ force: true })).toBe(handle.close());
|
|
665
|
+
} finally {
|
|
666
|
+
console.error = originalConsoleError;
|
|
667
|
+
handle.db.close = originalDbClose;
|
|
668
|
+
await originalDbClose();
|
|
669
|
+
}
|
|
670
|
+
});
|