@effectstream/runtime 0.100.22 → 0.100.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- 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 +68 -7
- package/src/api/stream-status.ts +35 -0
- package/src/coalesce.ts +291 -0
- package/src/finalized-stream.ts +73 -0
- package/src/main.ts +111 -89
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +7 -0
- package/test/coalesce.unit.test.ts +69 -0
- package/test/reproduction/buffering.test.ts +650 -0
- package/test/reproduction/coalesce-memory.test.ts +181 -0
- package/test/reproduction/coalesce.test.ts +133 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +573 -270
- package/test/reproduction/node-runner.ts +154 -0
- package/test/reproduction/poll.ts +160 -0
- package/test/reproduction/resume.test.ts +77 -0
- package/test/reproduction/sanity.test.ts +40 -91
- package/test/reproduction/scenario.ts +126 -0
|
@@ -1,318 +1,623 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic
|
|
2
|
+
* Deterministic harness for reproducing sync-service issues. Two backends behind
|
|
3
|
+
* one {@link setupHarness} API:
|
|
3
4
|
*
|
|
4
|
-
* -
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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()`.
|
|
13
20
|
*/
|
|
21
|
+
import net from "node:net";
|
|
14
22
|
import pg from "pg";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 the cluster was stopped (last harness tore down), start a fresh one.
|
|
229
|
+
if (clusterPromise && !clusterStopped) return clusterPromise;
|
|
230
|
+
clusterPromise = undefined;
|
|
231
|
+
clusterPromise = (async () => {
|
|
232
|
+
const bin = resolvePgBinDir()!;
|
|
233
|
+
clusterBin = bin;
|
|
234
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
235
|
+
|
|
236
|
+
cleanupOrphanedClusters(bin, env);
|
|
66
237
|
|
|
67
|
-
|
|
238
|
+
const dataDir = mkdtempSync(join(tmpdir(), "repro-pg-"));
|
|
239
|
+
const port = await freePort();
|
|
68
240
|
|
|
69
|
-
|
|
241
|
+
execFileSync(join(bin, "initdb"), [
|
|
242
|
+
"-D", dataDir,
|
|
243
|
+
"-U", "postgres",
|
|
244
|
+
"--auth=trust",
|
|
245
|
+
"--locale=C",
|
|
246
|
+
"--encoding=UTF8",
|
|
247
|
+
"--no-instructions",
|
|
248
|
+
], { env, stdio: "ignore" });
|
|
249
|
+
|
|
250
|
+
// Write our owner marker AFTER initdb — it refuses a non-empty data dir, so
|
|
251
|
+
// this must not exist beforehand. Read later by cleanupOrphanedClusters.
|
|
252
|
+
writeFileSync(join(dataDir, "repro-owner.pid"), String(process.pid));
|
|
253
|
+
appendFileSync(join(dataDir, "postgresql.conf"), `\nport = ${port}\n`);
|
|
254
|
+
execFileSync(join(bin, "pg_ctl"), [
|
|
255
|
+
"-D", dataDir,
|
|
256
|
+
"-l", join(dataDir, "server.log"),
|
|
257
|
+
"-w",
|
|
258
|
+
"start",
|
|
259
|
+
], { env, stdio: "ignore" });
|
|
260
|
+
|
|
261
|
+
clusterStopped = false;
|
|
262
|
+
|
|
263
|
+
const stop = () => {
|
|
264
|
+
spawnSync(join(bin, "pg_ctl"), ["-D", dataDir, "stop", "-m", "immediate"], {
|
|
265
|
+
env,
|
|
266
|
+
stdio: "ignore",
|
|
267
|
+
});
|
|
268
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
269
|
+
clusterStopped = true;
|
|
270
|
+
};
|
|
271
|
+
process.once("exit", stop);
|
|
272
|
+
return { port, dataDir };
|
|
273
|
+
})();
|
|
274
|
+
return clusterPromise;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Stop the shared cluster if it's running (called when last harness tears down). */
|
|
278
|
+
async function stopCluster(): Promise<void> {
|
|
279
|
+
if (!clusterPromise || clusterStopped) return;
|
|
280
|
+
const cluster = await clusterPromise;
|
|
281
|
+
if (clusterStopped || !clusterBin) return;
|
|
282
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
283
|
+
spawnSync(join(clusterBin, "pg_ctl"), ["-D", cluster.dataDir, "stop", "-m", "immediate"], {
|
|
284
|
+
env,
|
|
285
|
+
stdio: "ignore",
|
|
286
|
+
});
|
|
287
|
+
rmSync(cluster.dataDir, { recursive: true, force: true });
|
|
288
|
+
clusterStopped = true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Harness ──────────────────────────────────────────────────────────────────
|
|
70
292
|
|
|
71
293
|
export type Harness = {
|
|
72
|
-
|
|
294
|
+
/** Query pool against this harness's database (for assertions). */
|
|
73
295
|
pool: pg.Pool;
|
|
74
|
-
dataDir: string;
|
|
75
|
-
runNode: (opts: RunNodeOpts) => Promise<RunningNode>;
|
|
76
296
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
297
|
+
* Boot one node in a subprocess, sync it to `target` height (+ optional page
|
|
298
|
+
* watermarks), then let it exit. Resolves once the child exits cleanly; the
|
|
299
|
+
* database keeps everything it committed. Rejects if the child fails.
|
|
80
300
|
*/
|
|
81
|
-
|
|
301
|
+
runToHeight: (opts: RunToHeightOpts) => Promise<void>;
|
|
302
|
+
runNode: (opts: RunNodeOpts) => Promise<RunningNode>;
|
|
82
303
|
teardown: () => Promise<void>;
|
|
83
304
|
};
|
|
84
305
|
|
|
85
|
-
export type
|
|
86
|
-
|
|
306
|
+
export type RunToHeightOpts = {
|
|
307
|
+
events: EventSpec[];
|
|
308
|
+
parallelStepSize?: number;
|
|
309
|
+
/** Manual chain tips, e.g. { mainClock: 100, parallelP: 200 }. */
|
|
310
|
+
tips: Record<string, number>;
|
|
311
|
+
/** Finalized height the node must reach before exiting. */
|
|
312
|
+
target: number;
|
|
87
313
|
/** Unique per boot so the (test-only) HTTP server never collides. */
|
|
88
314
|
apiPort: number;
|
|
315
|
+
/** Optional per-protocol page watermarks to also wait for before exiting. */
|
|
316
|
+
waitPages?: Record<string, number>;
|
|
317
|
+
securityNamespace?: string;
|
|
318
|
+
/** Enable/disable empty-block coalescing. */
|
|
319
|
+
coalesce?: boolean;
|
|
89
320
|
};
|
|
90
321
|
|
|
91
|
-
|
|
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
|
-
};
|
|
322
|
+
const RUNNER = join(import.meta.dir, "node-runner.ts");
|
|
99
323
|
|
|
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-"));
|
|
324
|
+
export type Backend = "pglite" | "postgres";
|
|
108
325
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
process.env.
|
|
112
|
-
|
|
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
|
-
});
|
|
326
|
+
/** Backend keyed off the engine's `PGLITE` flag: `PGLITE=false` → real Postgres. */
|
|
327
|
+
function defaultBackend(): Backend {
|
|
328
|
+
return process.env.PGLITE === "false" ? "postgres" : "pglite";
|
|
329
|
+
}
|
|
128
330
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
331
|
+
/** `runToHeight` closure shared by both backends; only needs the child's dbEnv. */
|
|
332
|
+
function makeRunToHeight(
|
|
333
|
+
dbEnv: Record<string, string>,
|
|
334
|
+
): (opts: RunToHeightOpts) => Promise<void> {
|
|
335
|
+
return (opts: RunToHeightOpts): Promise<void> => {
|
|
336
|
+
const spec = {
|
|
337
|
+
securityNamespace: opts.securityNamespace ?? "sync-repro",
|
|
338
|
+
events: opts.events,
|
|
339
|
+
parallelStepSize: opts.parallelStepSize,
|
|
340
|
+
tips: opts.tips,
|
|
341
|
+
target: opts.target,
|
|
342
|
+
apiPort: opts.apiPort,
|
|
343
|
+
waitPages: opts.waitPages,
|
|
344
|
+
coalesce: opts.coalesce,
|
|
345
|
+
};
|
|
346
|
+
return new Promise<void>((resolve, reject) => {
|
|
347
|
+
const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
|
|
348
|
+
env: { ...process.env, ...dbEnv },
|
|
349
|
+
stdio: process.env.REPRO_DEBUG
|
|
350
|
+
? ["ignore", "inherit", "inherit"]
|
|
351
|
+
: ["ignore", "ignore", "pipe"],
|
|
352
|
+
});
|
|
353
|
+
// Keep only a tail of stderr so the MQTT-publish flood doesn't pile up;
|
|
354
|
+
// surface it only if the child fails.
|
|
355
|
+
const tail: string[] = [];
|
|
356
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
357
|
+
for (const line of b.toString().split("\n")) {
|
|
358
|
+
if (line && !line.includes("publish")) tail.push(line);
|
|
359
|
+
}
|
|
360
|
+
if (tail.length > 40) tail.splice(0, tail.length - 40);
|
|
361
|
+
});
|
|
362
|
+
child.on("error", reject);
|
|
363
|
+
child.on("exit", (code) => {
|
|
364
|
+
if (code === 0) resolve();
|
|
365
|
+
else {
|
|
366
|
+
reject(
|
|
367
|
+
new Error(
|
|
368
|
+
`node subprocess exited ${code}\n${tail.join("\n")}`,
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
169
372
|
});
|
|
170
373
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** `runNode` closure shared by both backends; spawns a subprocess and queries metrics. */
|
|
378
|
+
function makeRunNode(
|
|
379
|
+
dbEnv: Record<string, string>,
|
|
380
|
+
): (opts: RunNodeOpts) => Promise<RunningNode> {
|
|
381
|
+
return async (opts: RunNodeOpts): Promise<RunningNode> => {
|
|
382
|
+
const tips: Record<string, number> = {};
|
|
383
|
+
for (const sp of toSyncProtocolWithNetwork(opts.config)) {
|
|
384
|
+
const tip = TestChainControl.getTip(sp.syncProtocol.name);
|
|
385
|
+
if (tip !== undefined) {
|
|
386
|
+
tips[sp.syncProtocol.name] = tip;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const spec = {
|
|
391
|
+
securityNamespace: opts.config.securityNamespace ?? "sync-repro",
|
|
392
|
+
events: [],
|
|
393
|
+
tips,
|
|
394
|
+
target: 999999,
|
|
395
|
+
apiPort: opts.apiPort,
|
|
396
|
+
config: opts.config,
|
|
397
|
+
coalesce: opts.coalesce,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
|
|
401
|
+
env: { ...process.env, ...dbEnv, ENABLE_DEV_AND_DEBUG_ENDPOINTS: "true" },
|
|
402
|
+
stdio: process.env.REPRO_DEBUG
|
|
403
|
+
? ["ignore", "inherit", "inherit"]
|
|
404
|
+
: ["ignore", "ignore", "pipe"],
|
|
176
405
|
});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
406
|
+
|
|
407
|
+
let alive = true;
|
|
408
|
+
|
|
409
|
+
const node: RunningNode = {
|
|
410
|
+
child,
|
|
411
|
+
apiPort: opts.apiPort,
|
|
412
|
+
bufferSizes: {},
|
|
413
|
+
pauses: {},
|
|
414
|
+
ownBlocks: {},
|
|
415
|
+
inFlight: 0,
|
|
416
|
+
coalescedBlocks: 0,
|
|
417
|
+
syncProtocols() {
|
|
418
|
+
return Object.entries(this.bufferSizes).map(([name, buf]) => ({
|
|
419
|
+
name,
|
|
420
|
+
bufferedData: {
|
|
421
|
+
size: () => buf,
|
|
422
|
+
},
|
|
423
|
+
backpressurePauses: this.pauses[name] ?? 0,
|
|
424
|
+
lastPage: this.ownBlocks[name] != null ? { ownBlockNumber: this.ownBlocks[name] } : null,
|
|
425
|
+
}));
|
|
426
|
+
},
|
|
181
427
|
stop: async () => {
|
|
182
|
-
|
|
428
|
+
alive = false;
|
|
429
|
+
await poll.catch(() => {});
|
|
430
|
+
child.kill("SIGKILL");
|
|
431
|
+
await waitChildExit(child);
|
|
183
432
|
},
|
|
184
433
|
};
|
|
185
|
-
};
|
|
186
434
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
435
|
+
// Background loop scraping the node's debug metrics until stop() flips `alive`.
|
|
436
|
+
const poll = (async () => {
|
|
437
|
+
while (alive) {
|
|
438
|
+
try {
|
|
439
|
+
const res = await fetch(`http://127.0.0.1:${opts.apiPort}/debug/metrics`);
|
|
440
|
+
if (res.ok) {
|
|
441
|
+
const data = await res.json() as any;
|
|
442
|
+
if (data && Array.isArray(data.protocols)) {
|
|
443
|
+
for (const p of data.protocols) {
|
|
444
|
+
node.bufferSizes[p.name] = p.buf;
|
|
445
|
+
node.pauses[p.name] = p.pauses;
|
|
446
|
+
node.ownBlocks[p.name] = p.ownBlockNumber;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (data?.finalizedStream) {
|
|
450
|
+
node.inFlight = data.finalizedStream.inFlight ?? 0;
|
|
451
|
+
node.coalescedBlocks = data.finalizedStream.coalesced ?? 0;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// ignore
|
|
456
|
+
}
|
|
457
|
+
await sleep(25);
|
|
458
|
+
}
|
|
459
|
+
})();
|
|
460
|
+
|
|
461
|
+
const up = await pollUntil(
|
|
462
|
+
() => fetch(`http://127.0.0.1:${opts.apiPort}/health`).then((r) => r.ok),
|
|
463
|
+
10_000,
|
|
464
|
+
);
|
|
465
|
+
if (!up) {
|
|
466
|
+
await node.stop();
|
|
467
|
+
throw new Error(`node subprocess failed to boot on port ${opts.apiPort}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return node;
|
|
204
471
|
};
|
|
205
|
-
return harness;
|
|
206
472
|
}
|
|
207
473
|
|
|
208
|
-
|
|
474
|
+
/**
|
|
475
|
+
* Fresh harness on the chosen backend (default {@link defaultBackend}, override
|
|
476
|
+
* with `{ backend }`). One Harness per node lineage: its database outlives a
|
|
477
|
+
* restart, so committed state persists exactly as in production.
|
|
478
|
+
*/
|
|
479
|
+
export async function setupHarness(
|
|
480
|
+
opts: { backend?: Backend } = {},
|
|
481
|
+
): Promise<Harness> {
|
|
482
|
+
const backend = opts.backend ?? defaultBackend();
|
|
483
|
+
return backend === "postgres"
|
|
484
|
+
? setupPostgresHarness()
|
|
485
|
+
: setupPgliteHarness();
|
|
486
|
+
}
|
|
209
487
|
|
|
210
|
-
|
|
488
|
+
/** Real Postgres backend — a fresh database on the shared throwaway cluster. */
|
|
489
|
+
async function setupPostgresHarness(): Promise<Harness> {
|
|
490
|
+
const cluster = await ensureCluster();
|
|
491
|
+
const dbName = `repro_${process.pid}_${dbCounter++}`;
|
|
492
|
+
activeHarnessCount++;
|
|
211
493
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if ((e as { code?: string })?.code === "42P01") return 0;
|
|
223
|
-
throw e;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
494
|
+
// CREATE DATABASE on the maintenance db, then point everything at it.
|
|
495
|
+
const admin = new pg.Pool({
|
|
496
|
+
host: "127.0.0.1",
|
|
497
|
+
port: cluster.port,
|
|
498
|
+
user: "postgres",
|
|
499
|
+
database: "postgres",
|
|
500
|
+
max: 1,
|
|
501
|
+
});
|
|
502
|
+
await admin.query(`CREATE DATABASE "${dbName}"`);
|
|
503
|
+
await admin.end();
|
|
226
504
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
);
|
|
505
|
+
const dbEnv = {
|
|
506
|
+
PGLITE: "false",
|
|
507
|
+
ALLOW_NO_PG_IVM: "true",
|
|
508
|
+
DB_HOST: "127.0.0.1",
|
|
509
|
+
DB_PORT: String(cluster.port),
|
|
510
|
+
DB_NAME: dbName,
|
|
511
|
+
DB_USER: "postgres",
|
|
512
|
+
DB_PW: "",
|
|
513
|
+
MQTT_BROKER: "false",
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const pool = new pg.Pool({
|
|
517
|
+
host: "127.0.0.1",
|
|
518
|
+
port: cluster.port,
|
|
519
|
+
user: "postgres",
|
|
520
|
+
database: dbName,
|
|
521
|
+
max: 10,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const teardown = async (): Promise<void> => {
|
|
525
|
+
await pool.end().catch(() => {});
|
|
526
|
+
const admin = new pg.Pool({
|
|
527
|
+
host: "127.0.0.1",
|
|
528
|
+
port: cluster.port,
|
|
529
|
+
user: "postgres",
|
|
530
|
+
database: "postgres",
|
|
531
|
+
max: 1,
|
|
532
|
+
});
|
|
533
|
+
try {
|
|
534
|
+
// Drop lingering backends so DROP DATABASE isn't blocked by open conns.
|
|
535
|
+
await admin.query(
|
|
536
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
|
537
|
+
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
538
|
+
[dbName],
|
|
539
|
+
);
|
|
540
|
+
await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`);
|
|
541
|
+
} catch {
|
|
542
|
+
// Best-effort: the cluster is torn down wholesale below.
|
|
543
|
+
} finally {
|
|
544
|
+
await admin.end().catch(() => {});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
activeHarnessCount--;
|
|
548
|
+
if (activeHarnessCount <= 0) {
|
|
549
|
+
await stopCluster();
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
return { pool, runToHeight: makeRunToHeight(dbEnv), runNode: makeRunNode(dbEnv), teardown };
|
|
245
554
|
}
|
|
246
555
|
|
|
247
556
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
557
|
+
* PGLite backend — in-memory WASM Postgres behind a `startPglite` gateway.
|
|
558
|
+
*
|
|
559
|
+
* The gateway runs in its OWN subprocess rather than in this test process.
|
|
560
|
+
* PGLite is an Emscripten WASM build that loads the `pg_ivm` extension via
|
|
561
|
+
* dlopen into a shared function table; two instances alive in one process
|
|
562
|
+
* collide ("call_indirect signature mismatch"). bun runs every test file — and
|
|
563
|
+
* every harness within a file (e.g. buffering's 1a + 1b) — in one process, so an
|
|
564
|
+
* in-process gateway means multiple instances coexisting. One gateway = one
|
|
565
|
+
* subprocess = exactly one PGLite instance per process, which sidesteps it.
|
|
566
|
+
*
|
|
567
|
+
* The single WASM backend still tolerates no overlapping queries, so the node
|
|
568
|
+
* subprocess routes its polls through the global DB mutex (node-runner.ts /
|
|
569
|
+
* poll.ts), both pools cap at one connection, and the gateway serializes all
|
|
570
|
+
* clients (start-pglite.ts). The gateway reads its config from ENV.
|
|
250
571
|
*/
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
}
|
|
572
|
+
async function setupPgliteHarness(): Promise<Harness> {
|
|
573
|
+
const port = await freePort();
|
|
574
|
+
const dbName = "postgres";
|
|
273
575
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
}
|
|
576
|
+
// PGLITE=true makes parent assertions (poll.ts `q`) take the DB mutex. The
|
|
577
|
+
// parent never loads PGLite itself — the gateway subprocess does.
|
|
578
|
+
process.env.PGLITE = "true";
|
|
286
579
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
580
|
+
// A fresh `memory://` instance, isolated per harness, in its own process.
|
|
581
|
+
const gatewayEnv = {
|
|
582
|
+
...process.env,
|
|
583
|
+
PGLITE: "true",
|
|
584
|
+
PGLITE_DATA_DIR: "memory://",
|
|
585
|
+
DB_USER: "postgres",
|
|
586
|
+
DB_NAME: dbName,
|
|
587
|
+
};
|
|
588
|
+
const gateway = spawn("bun", [PGLITE_GATEWAY, "--port", String(port)], {
|
|
589
|
+
env: gatewayEnv,
|
|
590
|
+
stdio: process.env.REPRO_DEBUG ? "inherit" : ["ignore", "ignore", "ignore"],
|
|
591
|
+
});
|
|
592
|
+
await waitPortOpen(port);
|
|
299
593
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
594
|
+
const dbEnv = {
|
|
595
|
+
PGLITE: "true",
|
|
596
|
+
DB_HOST: "127.0.0.1",
|
|
597
|
+
DB_PORT: String(port),
|
|
598
|
+
DB_NAME: dbName,
|
|
599
|
+
DB_USER: "postgres",
|
|
600
|
+
DB_PW: "",
|
|
601
|
+
PGLITE_DATA_DIR: "memory://",
|
|
602
|
+
MQTT_BROKER: "false",
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// One connection: the gateway is a single WASM backend.
|
|
606
|
+
const pool = new pg.Pool({
|
|
607
|
+
host: "127.0.0.1",
|
|
608
|
+
port,
|
|
609
|
+
user: "postgres",
|
|
610
|
+
database: dbName,
|
|
611
|
+
max: 1,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const teardown = async (): Promise<void> => {
|
|
615
|
+
await pool.end().catch(() => {});
|
|
616
|
+
gateway.kill("SIGKILL");
|
|
617
|
+
await waitChildExit(gateway);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
return { pool, runToHeight: makeRunToHeight(dbEnv), runNode: makeRunNode(dbEnv), teardown };
|
|
316
621
|
}
|
|
317
622
|
|
|
318
623
|
/** Sample a protocol's in-memory buffered-data size now. */
|
|
@@ -320,8 +625,6 @@ export function bufferSize(
|
|
|
320
625
|
node: RunningNode,
|
|
321
626
|
protocolName: string,
|
|
322
627
|
): number {
|
|
323
|
-
|
|
324
|
-
return sp ? sp.bufferedData.size() : 0;
|
|
628
|
+
return node.bufferSizes[protocolName] ?? 0;
|
|
325
629
|
}
|
|
326
630
|
|
|
327
|
-
export { sleep };
|