@effectstream/runtime 0.100.20 → 0.100.22

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,609 +1,318 @@
1
1
  /**
2
- * Deterministic harness for reproducing sync-service issues. Two backends behind
3
- * one {@link setupHarness} API:
2
+ * Deterministic, in-process harness for reproducing sync-service issues.
4
3
  *
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}).
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).
12
9
  *
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()`.
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.
20
13
  */
21
- import net from "node:net";
22
14
  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";
15
+ import { type PgliteHandle, startPglite } from "@effectstream/db/start-pglite";
16
+ import { run, type Task } from "effection";
51
17
  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);
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);
157
40
  }
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}`);
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
+ };
174
54
  }
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
- }
55
+ override getConfig(): any {
56
+ return {
57
+ name: this.instanceName,
58
+ type: this.internalTypeName,
59
+ startBlockHeight: this.startBlockHeight,
60
+ scheduledPrefix: this.stateMachinePrefix,
61
+ };
209
62
  }
210
63
  }
211
64
 
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);
65
+ const noopStf: StartConfigGameStateTransitions = function* () {};
229
66
 
230
- const dataDir = mkdtempSync(join(tmpdir(), "repro-pg-"));
231
- const port = await freePort();
67
+ // ── Env / DB lifecycle ───────────────────────────────────────────────────────
232
68
 
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 ──────────────────────────────────────────────────────────────────
69
+ let portCounter = 5544;
282
70
 
283
71
  export type Harness = {
284
- /** Query pool against this harness's database (for assertions). */
72
+ pglite: PgliteHandle;
285
73
  pool: pg.Pool;
74
+ dataDir: string;
75
+ runNode: (opts: RunNodeOpts) => Promise<RunningNode>;
286
76
  /**
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.
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).
290
80
  */
291
- runToHeight: (opts: RunToHeightOpts) => Promise<void>;
292
- runNode: (opts: RunNodeOpts) => Promise<RunningNode>;
81
+ simulateRestart: () => Promise<void>;
293
82
  teardown: () => Promise<void>;
294
83
  };
295
84
 
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;
85
+ export type RunNodeOpts = {
86
+ config: ReturnType<ConfigBuilder["build"]> | any;
303
87
  /** Unique per boot so the (test-only) HTTP server never collides. */
304
88
  apiPort: number;
305
- /** Optional per-protocol page watermarks to also wait for before exiting. */
306
- waitPages?: Record<string, number>;
307
- securityNamespace?: string;
308
- /** Enable/disable empty-block coalescing. */
309
- coalesce?: boolean;
310
89
  };
311
90
 
312
- const RUNNER = join(import.meta.dir, "node-runner.ts");
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
+ };
313
99
 
314
- export type Backend = "pglite" | "postgres";
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-"));
315
108
 
316
- /** Backend keyed off the engine's `PGLITE` flag: `PGLITE=false` → real Postgres. */
317
- function defaultBackend(): Backend {
318
- return process.env.PGLITE === "false" ? "postgres" : "pglite";
319
- }
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
+ });
320
128
 
321
- /** `runToHeight` closure shared by both backends; only needs the child's dbEnv. */
322
- function makeRunToHeight(
323
- dbEnv: Record<string, string>,
324
- ): (opts: RunToHeightOpts) => Promise<void> {
325
- return (opts: RunToHeightOpts): Promise<void> => {
326
- const spec = {
327
- securityNamespace: opts.securityNamespace ?? "sync-repro",
328
- events: opts.events,
329
- parallelStepSize: opts.parallelStepSize,
330
- tips: opts.tips,
331
- target: opts.target,
332
- apiPort: opts.apiPort,
333
- waitPages: opts.waitPages,
334
- coalesce: opts.coalesce,
335
- };
336
- return new Promise<void>((resolve, reject) => {
337
- const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
338
- env: { ...process.env, ...dbEnv },
339
- stdio: process.env.REPRO_DEBUG
340
- ? ["ignore", "inherit", "inherit"]
341
- : ["ignore", "ignore", "pipe"],
342
- });
343
- // Keep only a tail of stderr so the MQTT-publish flood doesn't pile up;
344
- // surface it only if the child fails.
345
- const tail: string[] = [];
346
- child.stderr?.on("data", (b: Buffer) => {
347
- for (const line of b.toString().split("\n")) {
348
- if (line && !line.includes("publish")) tail.push(line);
349
- }
350
- if (tail.length > 40) tail.splice(0, tail.length - 40);
351
- });
352
- child.on("error", reject);
353
- child.on("exit", (code) => {
354
- if (code === 0) resolve();
355
- else {
356
- reject(
357
- new Error(
358
- `node subprocess exited ${code}\n${tail.join("\n")}`,
359
- ),
360
- );
361
- }
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
+ });
362
169
  });
363
170
  });
364
- };
365
- }
366
-
367
- /** `runNode` closure shared by both backends; spawns a subprocess and queries metrics. */
368
- function makeRunNode(
369
- dbEnv: Record<string, string>,
370
- ): (opts: RunNodeOpts) => Promise<RunningNode> {
371
- return async (opts: RunNodeOpts): Promise<RunningNode> => {
372
- const tips: Record<string, number> = {};
373
- for (const sp of toSyncProtocolWithNetwork(opts.config)) {
374
- const tip = TestChainControl.getTip(sp.syncProtocol.name);
375
- if (tip !== undefined) {
376
- tips[sp.syncProtocol.name] = tip;
377
- }
378
- }
379
-
380
- const spec = {
381
- securityNamespace: opts.config.securityNamespace ?? "sync-repro",
382
- events: [],
383
- tips,
384
- target: 999999,
385
- apiPort: opts.apiPort,
386
- config: opts.config,
387
- };
388
-
389
- const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
390
- env: { ...process.env, ...dbEnv, ENABLE_DEV_AND_DEBUG_ENDPOINTS: "true" },
391
- stdio: process.env.REPRO_DEBUG
392
- ? ["ignore", "inherit", "inherit"]
393
- : ["ignore", "ignore", "pipe"],
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;
394
176
  });
395
-
396
- let alive = true;
397
-
398
- const node: RunningNode = {
399
- child,
400
- apiPort: opts.apiPort,
401
- bufferSizes: {},
402
- pauses: {},
403
- ownBlocks: {},
404
- syncProtocols() {
405
- return Object.entries(this.bufferSizes).map(([name, buf]) => ({
406
- name,
407
- bufferedData: {
408
- size: () => buf,
409
- },
410
- backpressurePauses: this.pauses[name] ?? 0,
411
- lastPage: this.ownBlocks[name] != null ? { ownBlockNumber: this.ownBlocks[name] } : null,
412
- }));
413
- },
177
+ return {
178
+ task,
179
+ syncProtocols: () => captured,
180
+ bootError: () => bootErr,
414
181
  stop: async () => {
415
- alive = false;
416
- await poll.catch(() => {});
417
- child.kill("SIGKILL");
418
- await waitChildExit(child);
182
+ await task.halt();
419
183
  },
420
184
  };
421
-
422
- // Background loop scraping the node's debug metrics until stop() flips `alive`.
423
- const poll = (async () => {
424
- while (alive) {
425
- try {
426
- const res = await fetch(`http://127.0.0.1:${opts.apiPort}/debug/metrics`);
427
- if (res.ok) {
428
- const data = await res.json() as any;
429
- if (data && Array.isArray(data.protocols)) {
430
- for (const p of data.protocols) {
431
- node.bufferSizes[p.name] = p.buf;
432
- node.pauses[p.name] = p.pauses;
433
- node.ownBlocks[p.name] = p.ownBlockNumber;
434
- }
435
- }
436
- }
437
- } catch {
438
- // ignore
439
- }
440
- await sleep(25);
441
- }
442
- })();
443
-
444
- const up = await pollUntil(
445
- () => fetch(`http://127.0.0.1:${opts.apiPort}/health`).then((r) => r.ok),
446
- 10_000,
447
- );
448
- if (!up) {
449
- await node.stop();
450
- throw new Error(`node subprocess failed to boot on port ${opts.apiPort}`);
451
- }
452
-
453
- return node;
454
185
  };
455
- }
456
-
457
- /**
458
- * Fresh harness on the chosen backend (default {@link defaultBackend}, override
459
- * with `{ backend }`). One Harness per node lineage: its database outlives a
460
- * restart, so committed state persists exactly as in production.
461
- */
462
- export async function setupHarness(
463
- opts: { backend?: Backend } = {},
464
- ): Promise<Harness> {
465
- const backend = opts.backend ?? defaultBackend();
466
- return backend === "postgres"
467
- ? setupPostgresHarness()
468
- : setupPgliteHarness();
469
- }
470
-
471
- /** Real Postgres backend — a fresh database on the shared throwaway cluster. */
472
- async function setupPostgresHarness(): Promise<Harness> {
473
- const cluster = await ensureCluster();
474
- const dbName = `repro_${process.pid}_${dbCounter++}`;
475
- activeHarnessCount++;
476
-
477
- // CREATE DATABASE on the maintenance db, then point everything at it.
478
- const admin = new pg.Pool({
479
- host: "127.0.0.1",
480
- port: cluster.port,
481
- user: "postgres",
482
- database: "postgres",
483
- max: 1,
484
- });
485
- await admin.query(`CREATE DATABASE "${dbName}"`);
486
- await admin.end();
487
-
488
- // Parent assertions go through poll.ts's `q`; PGLITE=false → plain queries.
489
- process.env.PGLITE = "false";
490
186
 
491
- const dbEnv = {
492
- PGLITE: "false",
493
- ALLOW_NO_PG_IVM: "true",
494
- DB_HOST: "127.0.0.1",
495
- DB_PORT: String(cluster.port),
496
- DB_NAME: dbName,
497
- DB_USER: "postgres",
498
- DB_PW: "",
499
- MQTT_BROKER: "false",
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
+ },
500
204
  };
205
+ return harness;
206
+ }
501
207
 
502
- const pool = new pg.Pool({
503
- host: "127.0.0.1",
504
- port: cluster.port,
505
- user: "postgres",
506
- database: dbName,
507
- max: 10,
508
- });
208
+ // ── Polling helpers ──────────────────────────────────────────────────────────
509
209
 
510
- const teardown = async (): Promise<void> => {
511
- await pool.end().catch(() => {});
512
- const admin = new pg.Pool({
513
- host: "127.0.0.1",
514
- port: cluster.port,
515
- user: "postgres",
516
- database: "postgres",
517
- max: 1,
518
- });
519
- try {
520
- // Drop lingering backends so DROP DATABASE isn't blocked by open conns.
521
- await admin.query(
522
- `SELECT pg_terminate_backend(pid) FROM pg_stat_activity
523
- WHERE datname = $1 AND pid <> pg_backend_pid()`,
524
- [dbName],
525
- );
526
- await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`);
527
- } catch {
528
- // Best-effort: the cluster is torn down wholesale below.
529
- } finally {
530
- await admin.end().catch(() => {});
531
- }
210
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
532
211
 
533
- activeHarnessCount--;
534
- if (activeHarnessCount <= 0) {
535
- await stopCluster();
536
- }
537
- };
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
+ }
538
226
 
539
- return { pool, runToHeight: makeRunToHeight(dbEnv), runNode: makeRunNode(dbEnv), teardown };
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
+ );
540
245
  }
541
246
 
542
247
  /**
543
- * PGLite backend — in-memory WASM Postgres behind a `startPglite` gateway.
544
- *
545
- * The gateway runs in its OWN subprocess rather than in this test process.
546
- * PGLite is an Emscripten WASM build that loads the `pg_ivm` extension via
547
- * dlopen into a shared function table; two instances alive in one process
548
- * collide ("call_indirect signature mismatch"). bun runs every test file — and
549
- * every harness within a file (e.g. buffering's 1a + 1b) — in one process, so an
550
- * in-process gateway means multiple instances coexisting. One gateway = one
551
- * subprocess = exactly one PGLite instance per process, which sidesteps it.
552
- *
553
- * The single WASM backend still tolerates no overlapping queries, so the node
554
- * subprocess routes its polls through the global DB mutex (node-runner.ts /
555
- * poll.ts), both pools cap at one connection, and the gateway serializes all
556
- * clients (start-pglite.ts). The gateway reads its config from ENV.
248
+ * Wait until the finalized height stops advancing for `quietMs` (the system
249
+ * has reached its deterministic stable state given fixed tips).
557
250
  */
558
- async function setupPgliteHarness(): Promise<Harness> {
559
- const port = await freePort();
560
- const dbName = "postgres";
561
-
562
- // PGLITE=true makes parent assertions (poll.ts `q`) take the DB mutex. The
563
- // parent never loads PGLite itself — the gateway subprocess does.
564
- process.env.PGLITE = "true";
565
-
566
- // A fresh `memory://` instance, isolated per harness, in its own process.
567
- const gatewayEnv = {
568
- ...process.env,
569
- PGLITE: "true",
570
- PGLITE_DATA_DIR: "memory://",
571
- DB_USER: "postgres",
572
- DB_NAME: dbName,
573
- };
574
- const gateway = spawn("bun", [PGLITE_GATEWAY, "--port", String(port)], {
575
- env: gatewayEnv,
576
- stdio: process.env.REPRO_DEBUG ? "inherit" : ["ignore", "ignore", "ignore"],
577
- });
578
- await waitPortOpen(port);
579
-
580
- const dbEnv = {
581
- PGLITE: "true",
582
- DB_HOST: "127.0.0.1",
583
- DB_PORT: String(port),
584
- DB_NAME: dbName,
585
- DB_USER: "postgres",
586
- DB_PW: "",
587
- PGLITE_DATA_DIR: "memory://",
588
- MQTT_BROKER: "false",
589
- };
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
+ }
590
273
 
591
- // One connection: the gateway is a single WASM backend.
592
- const pool = new pg.Pool({
593
- host: "127.0.0.1",
594
- port,
595
- user: "postgres",
596
- database: dbName,
597
- max: 1,
598
- });
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
+ }
599
286
 
600
- const teardown = async (): Promise<void> => {
601
- await pool.end().catch(() => {});
602
- gateway.kill("SIGKILL");
603
- await waitChildExit(gateway);
604
- };
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
+ }
605
299
 
606
- return { pool, runToHeight: makeRunToHeight(dbEnv), runNode: makeRunNode(dbEnv), teardown };
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
+ }
607
316
  }
608
317
 
609
318
  /** Sample a protocol's in-memory buffered-data size now. */
@@ -611,6 +320,8 @@ export function bufferSize(
611
320
  node: RunningNode,
612
321
  protocolName: string,
613
322
  ): number {
614
- return node.bufferSizes[protocolName] ?? 0;
323
+ const sp = node.syncProtocols().find((p) => p.name === protocolName);
324
+ return sp ? sp.bufferedData.size() : 0;
615
325
  }
616
326
 
327
+ export { sleep };