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