@effectstream/runtime 0.100.18 → 0.100.19
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/conciseinputhash-determinism.md +114 -0
- package/package.json +12 -12
- package/src/api/apply-status.ts +30 -0
- package/src/api/http-server.ts +74 -7
- package/src/config-snapshot.ts +12 -4
- package/src/main.ts +35 -32
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +14 -0
- package/test/reproduction/buffering.test.ts +389 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +613 -0
- package/test/reproduction/node-runner.ts +152 -0
- package/test/reproduction/poll.ts +160 -0
- package/test/reproduction/resume.test.ts +77 -0
- package/test/reproduction/sanity.test.ts +74 -0
- package/test/reproduction/scenario.ts +126 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic harness for reproducing sync-service issues. Two backends behind
|
|
3
|
+
* one {@link setupHarness} API:
|
|
4
|
+
*
|
|
5
|
+
* - **PGLite** (default) — in-memory WASM Postgres behind a `startPglite`
|
|
6
|
+
* gateway, the production PGLite wire path (`PGLITE=true`). Dependency-free.
|
|
7
|
+
* - **Postgres** (opt-in via `PGLITE=false`) — a throwaway Homebrew
|
|
8
|
+
* `postgresql@18` cluster per test process (it bundles `pg_ivm`), each
|
|
9
|
+
* {@link setupHarness} getting a fresh database. Higher fidelity; needs
|
|
10
|
+
* `postgresql@18` on PATH (override the bin dir with `REPRO_PG_BINDIR`, probe
|
|
11
|
+
* with {@link postgresAvailable}).
|
|
12
|
+
*
|
|
13
|
+
* Each node runs in its own subprocess ({@link ./node-runner.ts}) booting the
|
|
14
|
+
* real runtime over a synthetic `test` chain, so a "restart" is a genuine new OS
|
|
15
|
+
* process: the in-memory Deque is gone while the database keeps every committed
|
|
16
|
+
* row. (Booting `start()` twice in one process stalls the sync loops under
|
|
17
|
+
* `bun test`, so we never do it.)
|
|
18
|
+
*
|
|
19
|
+
* Lives in the runtime package because the subprocess imports `start()`.
|
|
20
|
+
*/
|
|
21
|
+
import net from "node:net";
|
|
22
|
+
import pg from "pg";
|
|
23
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
24
|
+
import { appendFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { platform, tmpdir } from "node:os";
|
|
26
|
+
import { join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import type { EventSpec } from "./scenario.ts";
|
|
29
|
+
import { TestChainControl } from "@effectstream/sync";
|
|
30
|
+
import { toSyncProtocolWithNetwork } from "@effectstream/config";
|
|
31
|
+
|
|
32
|
+
export type RunNodeOpts = {
|
|
33
|
+
config: any;
|
|
34
|
+
apiPort: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RunningNode = {
|
|
38
|
+
child: any;
|
|
39
|
+
apiPort: number;
|
|
40
|
+
bufferSizes: Record<string, number>;
|
|
41
|
+
pauses: Record<string, number>;
|
|
42
|
+
ownBlocks: Record<string, number | null>;
|
|
43
|
+
stop: () => Promise<void>;
|
|
44
|
+
syncProtocols: () => any[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Re-export the bits the test files assert with, so they import from one place.
|
|
48
|
+
// `sleep` is imported (not just re-exported) so the helpers below can use it too.
|
|
49
|
+
export { TEST_PRIMITIVE_TYPE } from "./scenario.ts";
|
|
50
|
+
export type { EventSpec } from "./scenario.ts";
|
|
51
|
+
import {
|
|
52
|
+
countEventRows,
|
|
53
|
+
latestFinalizedHeight,
|
|
54
|
+
maxPage,
|
|
55
|
+
pageRows,
|
|
56
|
+
sleep,
|
|
57
|
+
} from "./poll.ts";
|
|
58
|
+
export { countEventRows, latestFinalizedHeight, maxPage, pageRows, sleep };
|
|
59
|
+
|
|
60
|
+
// ── Throwaway PostgreSQL cluster (one per test process) ──────────────────────
|
|
61
|
+
|
|
62
|
+
type Cluster = { port: number; dataDir: string };
|
|
63
|
+
|
|
64
|
+
let clusterPromise: Promise<Cluster> | undefined;
|
|
65
|
+
let clusterBin: string | undefined;
|
|
66
|
+
let dbCounter = 0;
|
|
67
|
+
let activeHarnessCount = 0;
|
|
68
|
+
let clusterStopped = false;
|
|
69
|
+
|
|
70
|
+
/** Resolve a Postgres bin dir. Tries, in order: env var, Homebrew, pg_config, PATH, Linux conventions. */
|
|
71
|
+
function resolvePgBinDir(): string | undefined {
|
|
72
|
+
const override = process.env.REPRO_PG_BINDIR;
|
|
73
|
+
if (override) return override;
|
|
74
|
+
|
|
75
|
+
// macOS + Homebrew
|
|
76
|
+
if (platform() === "darwin") {
|
|
77
|
+
try {
|
|
78
|
+
const prefix = execFileSync("brew", ["--prefix", "postgresql@18"], {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
}).trim();
|
|
81
|
+
return join(prefix, "bin");
|
|
82
|
+
} catch { /* not installed via Homebrew */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// pg_config — standard on most systems with PostgreSQL dev packages
|
|
86
|
+
try {
|
|
87
|
+
const dir = execFileSync("pg_config", ["--bindir"], { encoding: "utf8" }).trim();
|
|
88
|
+
if (dir) return dir;
|
|
89
|
+
} catch { /* not available */ }
|
|
90
|
+
|
|
91
|
+
// PATH probe — initdb directly accessible
|
|
92
|
+
try {
|
|
93
|
+
const which = platform() === "win32" ? "where" : "which";
|
|
94
|
+
const p = execFileSync(which, ["initdb"], { encoding: "utf8" }).trim();
|
|
95
|
+
if (p) return resolve(p, "..");
|
|
96
|
+
} catch { /* not on PATH */ }
|
|
97
|
+
|
|
98
|
+
// Linux convention (Debian/Ubuntu layout)
|
|
99
|
+
if (platform() === "linux") {
|
|
100
|
+
for (const ver of [18, 17, 16]) {
|
|
101
|
+
const dir = `/usr/lib/postgresql/${ver}/bin`;
|
|
102
|
+
try {
|
|
103
|
+
readdirSync(dir);
|
|
104
|
+
return dir;
|
|
105
|
+
} catch { /* not installed */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True if a Postgres binary is available on this system. */
|
|
113
|
+
export function postgresAvailable(): boolean {
|
|
114
|
+
return resolvePgBinDir() !== undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Grab a free TCP port by binding to :0 and reading the assigned port. */
|
|
118
|
+
function freePort(): Promise<number> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const srv = net.createServer();
|
|
121
|
+
srv.once("error", reject);
|
|
122
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
123
|
+
const port = (srv.address() as net.AddressInfo).port;
|
|
124
|
+
srv.close(() => resolve(port));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The standalone PGLite gateway script (`--port` mode). */
|
|
130
|
+
const PGLITE_GATEWAY = fileURLToPath(import.meta.resolve("@effectstream/db/start-pglite"));
|
|
131
|
+
|
|
132
|
+
/** Resolve once a child process has exited (immediately if it already has). */
|
|
133
|
+
function waitChildExit(child: ReturnType<typeof spawn>): Promise<void> {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
if (child.exitCode != null || child.signalCode != null) return resolve();
|
|
136
|
+
child.once("exit", () => resolve());
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Poll `fn` every `intervalMs` until it resolves truthy or `timeoutMs` elapses.
|
|
142
|
+
* A throwing/false `fn` counts as "not ready yet". Returns whether it succeeded.
|
|
143
|
+
*/
|
|
144
|
+
async function pollUntil(
|
|
145
|
+
fn: () => Promise<boolean>,
|
|
146
|
+
timeoutMs: number,
|
|
147
|
+
intervalMs = 50,
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
151
|
+
try {
|
|
152
|
+
if (await fn()) return true;
|
|
153
|
+
} catch {
|
|
154
|
+
// not ready yet
|
|
155
|
+
}
|
|
156
|
+
await sleep(intervalMs);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True once a TCP connection to `port` succeeds; never rejects. */
|
|
162
|
+
function tcpConnects(port: number): Promise<boolean> {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const sock = net.connect(port, "127.0.0.1");
|
|
165
|
+
sock.once("connect", () => { sock.destroy(); resolve(true); });
|
|
166
|
+
sock.once("error", () => { sock.destroy(); resolve(false); });
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Wait until something accepts a connection on `port` (the gateway is up). */
|
|
171
|
+
async function waitPortOpen(port: number, timeoutMs = 15_000): Promise<void> {
|
|
172
|
+
if (!(await pollUntil(() => tcpConnects(port), timeoutMs))) {
|
|
173
|
+
throw new Error(`PGLite gateway never came up on port ${port}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Kill orphaned `repro-pg-*` clusters left behind by test processes that
|
|
179
|
+
* exited without hitting their `exit` handler (SIGKILL, IDE stop, etc.).
|
|
180
|
+
*
|
|
181
|
+
* PostgreSQL writes the postmaster PID into `<dataDir>/postmaster.pid`. We
|
|
182
|
+
* also write our own owner PID into `<dataDir>/repro-owner.pid` so we can
|
|
183
|
+
* distinguish our clusters from unrelated `repro-pg-*` dirs. A directory is
|
|
184
|
+
* considered orphaned when the owner process is dead; we then `pg_ctl stop`
|
|
185
|
+
* and `rm -rf` it.
|
|
186
|
+
*/
|
|
187
|
+
function cleanupOrphanedClusters(bin: string, env: Record<string, string | undefined>): void {
|
|
188
|
+
const tmp = tmpdir();
|
|
189
|
+
let entries: string[];
|
|
190
|
+
try { entries = readdirSync(tmp); } catch { return; }
|
|
191
|
+
|
|
192
|
+
for (const name of entries) {
|
|
193
|
+
if (!name.startsWith("repro-pg-")) continue;
|
|
194
|
+
const dataDir = join(tmp, name);
|
|
195
|
+
|
|
196
|
+
const ownerFile = join(dataDir, "repro-owner.pid");
|
|
197
|
+
let ownerPid: number | undefined;
|
|
198
|
+
try { ownerPid = parseInt(readFileSync(ownerFile, "utf8").trim(), 10); } catch { /* no owner file */ }
|
|
199
|
+
|
|
200
|
+
if (ownerPid == null || Number.isNaN(ownerPid)) continue;
|
|
201
|
+
|
|
202
|
+
try { process.kill(ownerPid, 0); } catch {
|
|
203
|
+
// Owner is dead — orphaned cluster.
|
|
204
|
+
spawnSync(join(bin, "pg_ctl"), ["-D", dataDir, "stop", "-m", "immediate"], {
|
|
205
|
+
env, stdio: "ignore",
|
|
206
|
+
});
|
|
207
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Boot a fresh cluster the first time it's needed and reuse it for the rest of
|
|
214
|
+
* the process. initdb with C locale + trust auth (mirrors e2e_postgres.sh; the
|
|
215
|
+
* C locale dodges the macOS "postmaster became multithreaded during startup"
|
|
216
|
+
* fatal). Stopped synchronously on process exit.
|
|
217
|
+
*
|
|
218
|
+
* Before booting, any orphaned clusters from prior crashed runs are cleaned up
|
|
219
|
+
* so they don't accumulate and exhaust system resources.
|
|
220
|
+
*/
|
|
221
|
+
function ensureCluster(): Promise<Cluster> {
|
|
222
|
+
if (clusterPromise) return clusterPromise;
|
|
223
|
+
clusterPromise = (async () => {
|
|
224
|
+
const bin = resolvePgBinDir()!;
|
|
225
|
+
clusterBin = bin;
|
|
226
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
227
|
+
|
|
228
|
+
cleanupOrphanedClusters(bin, env);
|
|
229
|
+
|
|
230
|
+
const dataDir = mkdtempSync(join(tmpdir(), "repro-pg-"));
|
|
231
|
+
const port = await freePort();
|
|
232
|
+
|
|
233
|
+
writeFileSync(join(dataDir, "repro-owner.pid"), String(process.pid));
|
|
234
|
+
|
|
235
|
+
execFileSync(join(bin, "initdb"), [
|
|
236
|
+
"-D", dataDir,
|
|
237
|
+
"-U", "postgres",
|
|
238
|
+
"--auth=trust",
|
|
239
|
+
"--locale=C",
|
|
240
|
+
"--encoding=UTF8",
|
|
241
|
+
"--no-instructions",
|
|
242
|
+
], { env, stdio: "ignore" });
|
|
243
|
+
appendFileSync(join(dataDir, "postgresql.conf"), `\nport = ${port}\n`);
|
|
244
|
+
execFileSync(join(bin, "pg_ctl"), [
|
|
245
|
+
"-D", dataDir,
|
|
246
|
+
"-l", join(dataDir, "server.log"),
|
|
247
|
+
"-w",
|
|
248
|
+
"start",
|
|
249
|
+
], { env, stdio: "ignore" });
|
|
250
|
+
|
|
251
|
+
clusterStopped = false;
|
|
252
|
+
|
|
253
|
+
const stop = () => {
|
|
254
|
+
spawnSync(join(bin, "pg_ctl"), ["-D", dataDir, "stop", "-m", "immediate"], {
|
|
255
|
+
env,
|
|
256
|
+
stdio: "ignore",
|
|
257
|
+
});
|
|
258
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
259
|
+
clusterStopped = true;
|
|
260
|
+
};
|
|
261
|
+
process.once("exit", stop);
|
|
262
|
+
return { port, dataDir };
|
|
263
|
+
})();
|
|
264
|
+
return clusterPromise;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Stop the shared cluster if it's running (called when last harness tears down). */
|
|
268
|
+
async function stopCluster(): Promise<void> {
|
|
269
|
+
if (!clusterPromise || clusterStopped) return;
|
|
270
|
+
const cluster = await clusterPromise;
|
|
271
|
+
if (clusterStopped || !clusterBin) return;
|
|
272
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
273
|
+
spawnSync(join(clusterBin, "pg_ctl"), ["-D", cluster.dataDir, "stop", "-m", "immediate"], {
|
|
274
|
+
env,
|
|
275
|
+
stdio: "ignore",
|
|
276
|
+
});
|
|
277
|
+
rmSync(cluster.dataDir, { recursive: true, force: true });
|
|
278
|
+
clusterStopped = true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Harness ──────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export type Harness = {
|
|
284
|
+
/** Query pool against this harness's database (for assertions). */
|
|
285
|
+
pool: pg.Pool;
|
|
286
|
+
/**
|
|
287
|
+
* Boot one node in a subprocess, sync it to `target` height (+ optional page
|
|
288
|
+
* watermarks), then let it exit. Resolves once the child exits cleanly; the
|
|
289
|
+
* database keeps everything it committed. Rejects if the child fails.
|
|
290
|
+
*/
|
|
291
|
+
runToHeight: (opts: RunToHeightOpts) => Promise<void>;
|
|
292
|
+
runNode: (opts: RunNodeOpts) => Promise<RunningNode>;
|
|
293
|
+
teardown: () => Promise<void>;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export type RunToHeightOpts = {
|
|
297
|
+
events: EventSpec[];
|
|
298
|
+
parallelStepSize?: number;
|
|
299
|
+
/** Manual chain tips, e.g. { mainClock: 100, parallelP: 200 }. */
|
|
300
|
+
tips: Record<string, number>;
|
|
301
|
+
/** Finalized height the node must reach before exiting. */
|
|
302
|
+
target: number;
|
|
303
|
+
/** Unique per boot so the (test-only) HTTP server never collides. */
|
|
304
|
+
apiPort: number;
|
|
305
|
+
/** Optional per-protocol page watermarks to also wait for before exiting. */
|
|
306
|
+
waitPages?: Record<string, number>;
|
|
307
|
+
securityNamespace?: string;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const RUNNER = join(import.meta.dir, "node-runner.ts");
|
|
311
|
+
|
|
312
|
+
export type Backend = "pglite" | "postgres";
|
|
313
|
+
|
|
314
|
+
/** Backend keyed off the engine's `PGLITE` flag: `PGLITE=false` → real Postgres. */
|
|
315
|
+
function defaultBackend(): Backend {
|
|
316
|
+
return process.env.PGLITE === "false" ? "postgres" : "pglite";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** `runToHeight` closure shared by both backends; only needs the child's dbEnv. */
|
|
320
|
+
function makeRunToHeight(
|
|
321
|
+
dbEnv: Record<string, string>,
|
|
322
|
+
): (opts: RunToHeightOpts) => Promise<void> {
|
|
323
|
+
return (opts: RunToHeightOpts): Promise<void> => {
|
|
324
|
+
const spec = {
|
|
325
|
+
securityNamespace: opts.securityNamespace ?? "sync-repro",
|
|
326
|
+
events: opts.events,
|
|
327
|
+
parallelStepSize: opts.parallelStepSize,
|
|
328
|
+
tips: opts.tips,
|
|
329
|
+
target: opts.target,
|
|
330
|
+
apiPort: opts.apiPort,
|
|
331
|
+
waitPages: opts.waitPages,
|
|
332
|
+
};
|
|
333
|
+
return new Promise<void>((resolve, reject) => {
|
|
334
|
+
const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
|
|
335
|
+
env: { ...process.env, ...dbEnv },
|
|
336
|
+
stdio: process.env.REPRO_DEBUG
|
|
337
|
+
? ["ignore", "inherit", "inherit"]
|
|
338
|
+
: ["ignore", "ignore", "pipe"],
|
|
339
|
+
});
|
|
340
|
+
// Keep only a tail of stderr so the MQTT-publish flood doesn't pile up;
|
|
341
|
+
// surface it only if the child fails.
|
|
342
|
+
const tail: string[] = [];
|
|
343
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
344
|
+
for (const line of b.toString().split("\n")) {
|
|
345
|
+
if (line && !line.includes("publish")) tail.push(line);
|
|
346
|
+
}
|
|
347
|
+
if (tail.length > 40) tail.splice(0, tail.length - 40);
|
|
348
|
+
});
|
|
349
|
+
child.on("error", reject);
|
|
350
|
+
child.on("exit", (code) => {
|
|
351
|
+
if (code === 0) resolve();
|
|
352
|
+
else {
|
|
353
|
+
reject(
|
|
354
|
+
new Error(
|
|
355
|
+
`node subprocess exited ${code}\n${tail.join("\n")}`,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** `runNode` closure shared by both backends; spawns a subprocess and queries metrics. */
|
|
365
|
+
function makeRunNode(
|
|
366
|
+
dbEnv: Record<string, string>,
|
|
367
|
+
): (opts: RunNodeOpts) => Promise<RunningNode> {
|
|
368
|
+
return async (opts: RunNodeOpts): Promise<RunningNode> => {
|
|
369
|
+
const tips: Record<string, number> = {};
|
|
370
|
+
for (const sp of toSyncProtocolWithNetwork(opts.config)) {
|
|
371
|
+
const tip = TestChainControl.getTip(sp.syncProtocol.name);
|
|
372
|
+
if (tip !== undefined) {
|
|
373
|
+
tips[sp.syncProtocol.name] = tip;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const spec = {
|
|
378
|
+
securityNamespace: opts.config.securityNamespace ?? "sync-repro",
|
|
379
|
+
events: [],
|
|
380
|
+
tips,
|
|
381
|
+
target: 999999,
|
|
382
|
+
apiPort: opts.apiPort,
|
|
383
|
+
config: opts.config,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
|
|
387
|
+
env: { ...process.env, ...dbEnv, ENABLE_DEV_AND_DEBUG_ENDPOINTS: "true" },
|
|
388
|
+
stdio: process.env.REPRO_DEBUG
|
|
389
|
+
? ["ignore", "inherit", "inherit"]
|
|
390
|
+
: ["ignore", "ignore", "pipe"],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
let alive = true;
|
|
394
|
+
|
|
395
|
+
const node: RunningNode = {
|
|
396
|
+
child,
|
|
397
|
+
apiPort: opts.apiPort,
|
|
398
|
+
bufferSizes: {},
|
|
399
|
+
pauses: {},
|
|
400
|
+
ownBlocks: {},
|
|
401
|
+
syncProtocols() {
|
|
402
|
+
return Object.entries(this.bufferSizes).map(([name, buf]) => ({
|
|
403
|
+
name,
|
|
404
|
+
bufferedData: {
|
|
405
|
+
size: () => buf,
|
|
406
|
+
},
|
|
407
|
+
backpressurePauses: this.pauses[name] ?? 0,
|
|
408
|
+
lastPage: this.ownBlocks[name] != null ? { ownBlockNumber: this.ownBlocks[name] } : null,
|
|
409
|
+
}));
|
|
410
|
+
},
|
|
411
|
+
stop: async () => {
|
|
412
|
+
alive = false;
|
|
413
|
+
await poll.catch(() => {});
|
|
414
|
+
child.kill("SIGKILL");
|
|
415
|
+
await waitChildExit(child);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Background loop scraping the node's debug metrics until stop() flips `alive`.
|
|
420
|
+
const poll = (async () => {
|
|
421
|
+
while (alive) {
|
|
422
|
+
try {
|
|
423
|
+
const res = await fetch(`http://127.0.0.1:${opts.apiPort}/debug/metrics`);
|
|
424
|
+
if (res.ok) {
|
|
425
|
+
const data = await res.json() as any;
|
|
426
|
+
if (data && Array.isArray(data.protocols)) {
|
|
427
|
+
for (const p of data.protocols) {
|
|
428
|
+
node.bufferSizes[p.name] = p.buf;
|
|
429
|
+
node.pauses[p.name] = p.pauses;
|
|
430
|
+
node.ownBlocks[p.name] = p.ownBlockNumber;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
// ignore
|
|
436
|
+
}
|
|
437
|
+
await sleep(25);
|
|
438
|
+
}
|
|
439
|
+
})();
|
|
440
|
+
|
|
441
|
+
const up = await pollUntil(
|
|
442
|
+
() => fetch(`http://127.0.0.1:${opts.apiPort}/health`).then((r) => r.ok),
|
|
443
|
+
10_000,
|
|
444
|
+
);
|
|
445
|
+
if (!up) {
|
|
446
|
+
await node.stop();
|
|
447
|
+
throw new Error(`node subprocess failed to boot on port ${opts.apiPort}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return node;
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Fresh harness on the chosen backend (default {@link defaultBackend}, override
|
|
456
|
+
* with `{ backend }`). One Harness per node lineage: its database outlives a
|
|
457
|
+
* restart, so committed state persists exactly as in production.
|
|
458
|
+
*/
|
|
459
|
+
export async function setupHarness(
|
|
460
|
+
opts: { backend?: Backend } = {},
|
|
461
|
+
): Promise<Harness> {
|
|
462
|
+
const backend = opts.backend ?? defaultBackend();
|
|
463
|
+
return backend === "postgres"
|
|
464
|
+
? setupPostgresHarness()
|
|
465
|
+
: setupPgliteHarness();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Real Postgres backend — a fresh database on the shared throwaway cluster. */
|
|
469
|
+
async function setupPostgresHarness(): Promise<Harness> {
|
|
470
|
+
const cluster = await ensureCluster();
|
|
471
|
+
const dbName = `repro_${process.pid}_${dbCounter++}`;
|
|
472
|
+
activeHarnessCount++;
|
|
473
|
+
|
|
474
|
+
// CREATE DATABASE on the maintenance db, then point everything at it.
|
|
475
|
+
const admin = new pg.Pool({
|
|
476
|
+
host: "127.0.0.1",
|
|
477
|
+
port: cluster.port,
|
|
478
|
+
user: "postgres",
|
|
479
|
+
database: "postgres",
|
|
480
|
+
max: 1,
|
|
481
|
+
});
|
|
482
|
+
await admin.query(`CREATE DATABASE "${dbName}"`);
|
|
483
|
+
await admin.end();
|
|
484
|
+
|
|
485
|
+
// Parent assertions go through poll.ts's `q`; PGLITE=false → plain queries.
|
|
486
|
+
process.env.PGLITE = "false";
|
|
487
|
+
|
|
488
|
+
const dbEnv = {
|
|
489
|
+
PGLITE: "false",
|
|
490
|
+
ALLOW_NO_PG_IVM: "true",
|
|
491
|
+
DB_HOST: "127.0.0.1",
|
|
492
|
+
DB_PORT: String(cluster.port),
|
|
493
|
+
DB_NAME: dbName,
|
|
494
|
+
DB_USER: "postgres",
|
|
495
|
+
DB_PW: "",
|
|
496
|
+
MQTT_BROKER: "false",
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const pool = new pg.Pool({
|
|
500
|
+
host: "127.0.0.1",
|
|
501
|
+
port: cluster.port,
|
|
502
|
+
user: "postgres",
|
|
503
|
+
database: dbName,
|
|
504
|
+
max: 10,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const teardown = async (): Promise<void> => {
|
|
508
|
+
await pool.end().catch(() => {});
|
|
509
|
+
const admin = new pg.Pool({
|
|
510
|
+
host: "127.0.0.1",
|
|
511
|
+
port: cluster.port,
|
|
512
|
+
user: "postgres",
|
|
513
|
+
database: "postgres",
|
|
514
|
+
max: 1,
|
|
515
|
+
});
|
|
516
|
+
try {
|
|
517
|
+
// Drop lingering backends so DROP DATABASE isn't blocked by open conns.
|
|
518
|
+
await admin.query(
|
|
519
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
|
520
|
+
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
521
|
+
[dbName],
|
|
522
|
+
);
|
|
523
|
+
await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`);
|
|
524
|
+
} catch {
|
|
525
|
+
// Best-effort: the cluster is torn down wholesale below.
|
|
526
|
+
} finally {
|
|
527
|
+
await admin.end().catch(() => {});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
activeHarnessCount--;
|
|
531
|
+
if (activeHarnessCount <= 0) {
|
|
532
|
+
await stopCluster();
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
return { pool, runToHeight: makeRunToHeight(dbEnv), runNode: makeRunNode(dbEnv), teardown };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* PGLite backend — in-memory WASM Postgres behind a `startPglite` gateway.
|
|
541
|
+
*
|
|
542
|
+
* The gateway runs in its OWN subprocess rather than in this test process.
|
|
543
|
+
* PGLite is an Emscripten WASM build that loads the `pg_ivm` extension via
|
|
544
|
+
* dlopen into a shared function table; two instances alive in one process
|
|
545
|
+
* collide ("call_indirect signature mismatch"). bun runs every test file — and
|
|
546
|
+
* every harness within a file (e.g. buffering's 1a + 1b) — in one process, so an
|
|
547
|
+
* in-process gateway means multiple instances coexisting. One gateway = one
|
|
548
|
+
* subprocess = exactly one PGLite instance per process, which sidesteps it.
|
|
549
|
+
*
|
|
550
|
+
* The single WASM backend still tolerates no overlapping queries, so the node
|
|
551
|
+
* subprocess routes its polls through the global DB mutex (node-runner.ts /
|
|
552
|
+
* poll.ts), both pools cap at one connection, and the gateway serializes all
|
|
553
|
+
* clients (start-pglite.ts). The gateway reads its config from ENV.
|
|
554
|
+
*/
|
|
555
|
+
async function setupPgliteHarness(): Promise<Harness> {
|
|
556
|
+
const port = await freePort();
|
|
557
|
+
const dbName = "postgres";
|
|
558
|
+
|
|
559
|
+
// PGLITE=true makes parent assertions (poll.ts `q`) take the DB mutex. The
|
|
560
|
+
// parent never loads PGLite itself — the gateway subprocess does.
|
|
561
|
+
process.env.PGLITE = "true";
|
|
562
|
+
|
|
563
|
+
// A fresh `memory://` instance, isolated per harness, in its own process.
|
|
564
|
+
const gatewayEnv = {
|
|
565
|
+
...process.env,
|
|
566
|
+
PGLITE: "true",
|
|
567
|
+
PGLITE_DATA_DIR: "memory://",
|
|
568
|
+
DB_USER: "postgres",
|
|
569
|
+
DB_NAME: dbName,
|
|
570
|
+
};
|
|
571
|
+
const gateway = spawn("bun", [PGLITE_GATEWAY, "--port", String(port)], {
|
|
572
|
+
env: gatewayEnv,
|
|
573
|
+
stdio: process.env.REPRO_DEBUG ? "inherit" : ["ignore", "ignore", "ignore"],
|
|
574
|
+
});
|
|
575
|
+
await waitPortOpen(port);
|
|
576
|
+
|
|
577
|
+
const dbEnv = {
|
|
578
|
+
PGLITE: "true",
|
|
579
|
+
DB_HOST: "127.0.0.1",
|
|
580
|
+
DB_PORT: String(port),
|
|
581
|
+
DB_NAME: dbName,
|
|
582
|
+
DB_USER: "postgres",
|
|
583
|
+
DB_PW: "",
|
|
584
|
+
PGLITE_DATA_DIR: "memory://",
|
|
585
|
+
MQTT_BROKER: "false",
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// One connection: the gateway is a single WASM backend.
|
|
589
|
+
const pool = new pg.Pool({
|
|
590
|
+
host: "127.0.0.1",
|
|
591
|
+
port,
|
|
592
|
+
user: "postgres",
|
|
593
|
+
database: dbName,
|
|
594
|
+
max: 1,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const teardown = async (): Promise<void> => {
|
|
598
|
+
await pool.end().catch(() => {});
|
|
599
|
+
gateway.kill("SIGKILL");
|
|
600
|
+
await waitChildExit(gateway);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
return { pool, runToHeight: makeRunToHeight(dbEnv), runNode: makeRunNode(dbEnv), teardown };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Sample a protocol's in-memory buffered-data size now. */
|
|
607
|
+
export function bufferSize(
|
|
608
|
+
node: RunningNode,
|
|
609
|
+
protocolName: string,
|
|
610
|
+
): number {
|
|
611
|
+
return node.bufferSizes[protocolName] ?? 0;
|
|
612
|
+
}
|
|
613
|
+
|