@effectstream/runtime 0.100.22 → 0.100.23

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