@effectstream/runtime 0.200.5 → 0.200.6
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 +12 -12
- package/src/api/http-server.ts +47 -5
- package/src/api/trust-proxy.test.ts +22 -0
- package/src/main.ts +117 -8
- package/src/process-blocks.ts +20 -1
- package/src/telemetry.ts +27 -1
- package/test/lifecycle/fixtures/runtime-lifecycle.ts +246 -0
- package/test/lifecycle/fixtures/telemetry-lifecycle.ts +51 -0
- package/test/lifecycle/http-server-lifecycle.test.ts +219 -0
- package/test/lifecycle/process-blocks-lifecycle.test.ts +68 -0
- package/test/lifecycle/runtime-lifecycle.test.ts +128 -0
- package/test/lifecycle/support.ts +153 -0
- package/test/lifecycle/telemetry-lifecycle.test.ts +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/runtime",
|
|
3
|
-
"version": "0.200.
|
|
3
|
+
"version": "0.200.6",
|
|
4
4
|
"license": "(MIT OR Apache-2.0)",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/concise": "0.200.
|
|
17
|
-
"@effectstream/config": "0.200.
|
|
18
|
-
"@effectstream/coroutine": "0.200.
|
|
19
|
-
"@effectstream/crypto": "0.200.
|
|
20
|
-
"@effectstream/db": "0.200.
|
|
21
|
-
"@effectstream/event-client": "0.200.
|
|
22
|
-
"@effectstream/event-server": "0.200.
|
|
23
|
-
"@effectstream/log": "0.200.
|
|
24
|
-
"@effectstream/sm": "0.200.
|
|
25
|
-
"@effectstream/sync": "0.200.
|
|
26
|
-
"@effectstream/utils": "0.200.
|
|
16
|
+
"@effectstream/concise": "0.200.6",
|
|
17
|
+
"@effectstream/config": "0.200.6",
|
|
18
|
+
"@effectstream/coroutine": "0.200.6",
|
|
19
|
+
"@effectstream/crypto": "0.200.6",
|
|
20
|
+
"@effectstream/db": "0.200.6",
|
|
21
|
+
"@effectstream/event-client": "0.200.6",
|
|
22
|
+
"@effectstream/event-server": "0.200.6",
|
|
23
|
+
"@effectstream/log": "0.200.6",
|
|
24
|
+
"@effectstream/sm": "0.200.6",
|
|
25
|
+
"@effectstream/sync": "0.200.6",
|
|
26
|
+
"@effectstream/utils": "0.200.6",
|
|
27
27
|
"@fastify/cors": "^11.0.1",
|
|
28
28
|
"@metamask/json-rpc-engine": "^10.0.3",
|
|
29
29
|
"pg": "8.21.0",
|
package/src/api/http-server.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import fastify, {
|
|
1
|
+
import fastify, {
|
|
2
|
+
type FastifyInstance,
|
|
3
|
+
type FastifyReply,
|
|
4
|
+
type FastifyRequest,
|
|
5
|
+
type FastifyServerOptions,
|
|
6
|
+
} from "fastify";
|
|
2
7
|
import { evmRpcEngine } from "./rpc-evm/eip1193.ts";
|
|
3
8
|
import { appliedBlockStatus } from "./apply-status.ts";
|
|
4
9
|
import { finalizedStreamStatus } from "./stream-status.ts";
|
|
5
10
|
import { buildHealthReport, healthHttpStatus } from "./health.ts";
|
|
6
11
|
import type { Pool } from "pg";
|
|
7
12
|
import cors from "@fastify/cors";
|
|
8
|
-
import { ensure,
|
|
13
|
+
import { ensure, suspend, until, useScope } from "effection";
|
|
9
14
|
import {
|
|
10
15
|
acquireDBMutex,
|
|
11
16
|
getAllAddresses,
|
|
@@ -53,6 +58,23 @@ import {
|
|
|
53
58
|
import { PrimitiveRegistry } from "@effectstream/sm";
|
|
54
59
|
import { ConfigNetworkType, getWriteNamespace, usePaimaStaticConfig } from "@effectstream/config";
|
|
55
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Parse `EFFECTSTREAM_TRUST_PROXY` into Fastify's `trustProxy` option.
|
|
63
|
+
*
|
|
64
|
+
* - `true` / `1` / empty → `true` (trust every hop; the default)
|
|
65
|
+
* - `false` / `0` → `false`
|
|
66
|
+
* - anything else → comma-separated proxy IPs / CIDRs
|
|
67
|
+
*/
|
|
68
|
+
export function parseTrustProxy(raw: string | undefined): boolean | string[] {
|
|
69
|
+
const value = (raw ?? "").trim();
|
|
70
|
+
if (value === "" || value.toLowerCase() === "true" || value === "1") return true;
|
|
71
|
+
if (value.toLowerCase() === "false" || value === "0") return false;
|
|
72
|
+
return value
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((entry) => entry.trim())
|
|
75
|
+
.filter((entry) => entry.length > 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
function tableListContains(
|
|
57
79
|
list: Array<{ table_name: string | null }>,
|
|
58
80
|
name: string,
|
|
@@ -165,10 +187,22 @@ export const startHttpServer = function* (
|
|
|
165
187
|
apiRouter?: StartConfigApiRouter,
|
|
166
188
|
grammar?: GrammarDefinition,
|
|
167
189
|
) {
|
|
190
|
+
// This operation's own scope. Work a request starts is run in it rather than
|
|
191
|
+
// in a detached `run()`, so an in-flight request is visible to — and
|
|
192
|
+
// cancelled by — the shutdown that closes the server below.
|
|
193
|
+
const scope = yield* useScope();
|
|
168
194
|
// Use dbConn directly; queries are executed via pgtyped PreparedQuery.run
|
|
169
195
|
// Allow any webpage to access the server.
|
|
170
196
|
// This node is not specific for a specific website.
|
|
171
|
-
|
|
197
|
+
// trustProxy decides what `request.ip` means. Deployments front this server
|
|
198
|
+
// with nginx/Caddy, and without it Fastify reports the proxy's address for
|
|
199
|
+
// every client — so anything keyed per IP (the rate limiter above all)
|
|
200
|
+
// shares ONE bucket across every user behind the proxy.
|
|
201
|
+
const serverOptions: FastifyServerOptions = {
|
|
202
|
+
routerOptions: { maxParamLength: 300 },
|
|
203
|
+
trustProxy: parseTrustProxy(ENV.EFFECTSTREAM_TRUST_PROXY),
|
|
204
|
+
};
|
|
205
|
+
const server = fastify(serverOptions);
|
|
172
206
|
// OpenAPI Docs
|
|
173
207
|
yield* registerOpenApiDocumentation(server, ENV.EFFECTSTREAM_API_PORT);
|
|
174
208
|
|
|
@@ -978,7 +1012,10 @@ export const startHttpServer = function* (
|
|
|
978
1012
|
reply,
|
|
979
1013
|
) => {
|
|
980
1014
|
const { name } = request.query;
|
|
981
|
-
|
|
1015
|
+
// Scope-bound, not detached: waiting for the mutex must end when the
|
|
1016
|
+
// server's scope does, otherwise shutdown blocks on a request that
|
|
1017
|
+
// only cancellation could release.
|
|
1018
|
+
await scope.run(() => acquireDBMutex(`http-server:${name}`));
|
|
982
1019
|
return "ok";
|
|
983
1020
|
},
|
|
984
1021
|
);
|
|
@@ -1039,8 +1076,13 @@ export const startHttpServer = function* (
|
|
|
1039
1076
|
);
|
|
1040
1077
|
});
|
|
1041
1078
|
|
|
1079
|
+
// Close unconditionally, including when `listen()` below rejects. The Fastify
|
|
1080
|
+
// instance is fully built by this point — CORS, Swagger and every plugin the
|
|
1081
|
+
// host registered through `apiRouter` — and `close()` is what runs their
|
|
1082
|
+
// `onClose` hooks. Gating on `server.server.listening` skipped all of it on a
|
|
1083
|
+
// bind conflict and leaked the instance.
|
|
1042
1084
|
yield* ensure(function* () {
|
|
1043
|
-
|
|
1085
|
+
yield* until(server.close());
|
|
1044
1086
|
});
|
|
1045
1087
|
const address = yield* until(
|
|
1046
1088
|
server.listen({ port: ENV.EFFECTSTREAM_API_PORT, host: "0.0.0.0" }),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseTrustProxy } from "./http-server.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseTrustProxy", () => {
|
|
5
|
+
test("defaults to trusting every hop", () => {
|
|
6
|
+
expect(parseTrustProxy(undefined)).toBe(true);
|
|
7
|
+
expect(parseTrustProxy("")).toBe(true);
|
|
8
|
+
expect(parseTrustProxy("true")).toBe(true);
|
|
9
|
+
expect(parseTrustProxy("TRUE")).toBe(true);
|
|
10
|
+
expect(parseTrustProxy("1")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
test("can be switched off", () => {
|
|
13
|
+
expect(parseTrustProxy("false")).toBe(false);
|
|
14
|
+
expect(parseTrustProxy("0")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
test("a bare number that is not 0/1 is treated as an address list entry", () => {
|
|
17
|
+
expect(parseTrustProxy("2")).toEqual(["2"]);
|
|
18
|
+
});
|
|
19
|
+
test("a list becomes trimmed proxy addresses", () => {
|
|
20
|
+
expect(parseTrustProxy(" 127.0.0.1, 10.0.0.0/8 ,")).toEqual(["127.0.0.1", "10.0.0.0/8"]);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/main.ts
CHANGED
|
@@ -58,6 +58,46 @@ export function* init() {
|
|
|
58
58
|
yield* initTelemetry();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Report a set of failures the way the rest of the codebase already does (see
|
|
63
|
+
* `db/scripts/start-pglite.ts` and the MQTT broker): a lone failure keeps its
|
|
64
|
+
* identity, several become one ordered `AggregateError`.
|
|
65
|
+
*/
|
|
66
|
+
function throwCleanupErrors(errors: unknown[], message: string): void {
|
|
67
|
+
if (errors.length === 1) throw errors[0];
|
|
68
|
+
if (errors.length > 1) throw new AggregateError(errors, message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Spawn a child of the runtime that reports its own failure instead of only
|
|
73
|
+
* racing to become the scope's error.
|
|
74
|
+
*
|
|
75
|
+
* Effection tears the scope down on the first child failure and keeps exactly
|
|
76
|
+
* one error; anything else that fails while unwinding replaces it. Recording
|
|
77
|
+
* the failure where it happens keeps it ahead of every cleanup error in the
|
|
78
|
+
* aggregate `start()` finally throws.
|
|
79
|
+
*/
|
|
80
|
+
function* superviseChild(
|
|
81
|
+
causes: unknown[],
|
|
82
|
+
name: string,
|
|
83
|
+
body: () => Operation<unknown>,
|
|
84
|
+
): Operation<void> {
|
|
85
|
+
yield* spawn(function* () {
|
|
86
|
+
try {
|
|
87
|
+
yield* body();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!causes.includes(error)) causes.push(error);
|
|
90
|
+
log.local(
|
|
91
|
+
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
92
|
+
"child-task",
|
|
93
|
+
SeverityNumber.ERROR,
|
|
94
|
+
(l) => l(`child task "${name}" failed: ${String(error)}`),
|
|
95
|
+
);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
/**
|
|
62
102
|
* Main entry point to start the Paima Engine Node.
|
|
63
103
|
*
|
|
@@ -67,13 +107,50 @@ export function* init() {
|
|
|
67
107
|
* @param config - Paima Engine Node configuration object.
|
|
68
108
|
*/
|
|
69
109
|
export function* start(config: StartConfig): Operation<void> {
|
|
70
|
-
|
|
110
|
+
// Why the runtime is stopping, in the order the failures happened.
|
|
111
|
+
const causes: unknown[] = [];
|
|
112
|
+
// What went wrong while releasing resources, in teardown order.
|
|
113
|
+
const cleanups: unknown[] = [];
|
|
114
|
+
|
|
115
|
+
// Registered first, so it runs last: one place reports everything that went
|
|
116
|
+
// wrong. Without it effection keeps only the error thrown by whichever
|
|
117
|
+
// teardown ran last, so a failing `dbConn.end()` silently replaced the real
|
|
118
|
+
// cause of the shutdown.
|
|
119
|
+
yield* ensure(function* () {
|
|
120
|
+
throwCleanupErrors(
|
|
121
|
+
[...causes, ...cleanups],
|
|
122
|
+
"Effectstream runtime did not shut down cleanly",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
71
125
|
|
|
72
126
|
const dbConn = getConnection();
|
|
73
127
|
yield* ensure(function* () {
|
|
74
|
-
|
|
128
|
+
try {
|
|
129
|
+
yield* call(() => dbConn.end());
|
|
130
|
+
} catch (error) {
|
|
131
|
+
cleanups.push(error);
|
|
132
|
+
}
|
|
75
133
|
});
|
|
76
134
|
|
|
135
|
+
try {
|
|
136
|
+
// The body gets its own frame, so everything it owns — broker, HTTP
|
|
137
|
+
// server, merge/heartbeat/snapshot children — is released, and every
|
|
138
|
+
// release failure recorded, before the pool above is drained.
|
|
139
|
+
yield* call(() => runRuntime(config, dbConn, causes, cleanups));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// A body or child failure. `superviseChild` may already have recorded it.
|
|
142
|
+
if (!causes.includes(error)) causes.push(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function* runRuntime(
|
|
147
|
+
config: StartConfig,
|
|
148
|
+
dbConn: ReturnType<typeof getConnection>,
|
|
149
|
+
causes: unknown[],
|
|
150
|
+
cleanups: unknown[],
|
|
151
|
+
): Operation<void> {
|
|
152
|
+
const { syncInfo } = config;
|
|
153
|
+
|
|
77
154
|
const syncProtocols = yield* startup(dbConn as any, // Client,
|
|
78
155
|
syncInfo, config);
|
|
79
156
|
|
|
@@ -90,9 +167,33 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
90
167
|
yield* startSync(syncProtocol);
|
|
91
168
|
}
|
|
92
169
|
|
|
93
|
-
// Create MQTT Broker
|
|
170
|
+
// Create MQTT Broker. Startup must fail fast on a bind conflict (e.g. a
|
|
171
|
+
// stale engine still holding the port) instead of running without a broker.
|
|
94
172
|
if (ENV.MQTT_BROKER) {
|
|
95
|
-
new EventBroker("effectstream-engine")
|
|
173
|
+
const eventBroker = new EventBroker("effectstream-engine");
|
|
174
|
+
let startFailed = false;
|
|
175
|
+
// Registered BEFORE start(): `start()` binds its TCP listener before it
|
|
176
|
+
// resolves, so a cancellation that lands while we are still awaiting it
|
|
177
|
+
// (a SIGINT during a slow bind) must still release both listeners. The
|
|
178
|
+
// broker coordinates shutdown with its own listen outcome, so calling
|
|
179
|
+
// `shutdown()` mid-start is safe and does not deadlock.
|
|
180
|
+
yield* ensure(function* () {
|
|
181
|
+
// A failed start already ran — and reported — the broker's own cleanup.
|
|
182
|
+
if (startFailed) return;
|
|
183
|
+
try {
|
|
184
|
+
yield* call(() => eventBroker.shutdown());
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Recorded rather than logged away: the broker builds a causally
|
|
187
|
+
// ordered AggregateError that the host needs to see.
|
|
188
|
+
cleanups.push(error);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
yield* call(() => eventBroker.start());
|
|
193
|
+
} catch (error) {
|
|
194
|
+
startFailed = true;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
96
197
|
}
|
|
97
198
|
|
|
98
199
|
// 20× main clock block time (NTP if present, else protocol 0).
|
|
@@ -107,7 +208,7 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
107
208
|
const lagThresholdMs = ENV.EFFECTSTREAM_LAG_THRESHOLD_MS ??
|
|
108
209
|
(clockBlockTimeMS != null ? clockBlockTimeMS * 20 : 60_000);
|
|
109
210
|
|
|
110
|
-
yield*
|
|
211
|
+
yield* superviseChild(causes, "http-server", function* () {
|
|
111
212
|
yield* startHttpServer(
|
|
112
213
|
dbConn,
|
|
113
214
|
syncProtocols,
|
|
@@ -123,10 +224,14 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
123
224
|
const { stream: finalizedStream, subscription: finalizedBlocks } =
|
|
124
225
|
yield* createBoundedFinalizedStream(ENV.EFFECTSTREAM_FINALIZED_STREAM_CAP);
|
|
125
226
|
|
|
126
|
-
yield*
|
|
227
|
+
yield* superviseChild(
|
|
228
|
+
causes,
|
|
229
|
+
"merge",
|
|
230
|
+
() => startMerge(syncProtocols, finalizedStream),
|
|
231
|
+
);
|
|
127
232
|
|
|
128
233
|
const heartbeatIntervalMs = 60_000;
|
|
129
|
-
yield*
|
|
234
|
+
yield* superviseChild(causes, "heartbeat", function* () {
|
|
130
235
|
while (true) {
|
|
131
236
|
yield* sleep(heartbeatIntervalMs);
|
|
132
237
|
const now = Date.now();
|
|
@@ -162,7 +267,11 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
162
267
|
? lastHashRow.effectstream_block_hash!.toString() as EffectstreamBlockHash
|
|
163
268
|
: null;
|
|
164
269
|
if (config.snapshotConfig) {
|
|
165
|
-
yield*
|
|
270
|
+
yield* superviseChild(
|
|
271
|
+
causes,
|
|
272
|
+
"snapshot-loop",
|
|
273
|
+
() => runSnapshotLoop(config.snapshotConfig!),
|
|
274
|
+
);
|
|
166
275
|
}
|
|
167
276
|
|
|
168
277
|
const coalescer = createEmptyBlockCoalescer({
|
package/src/process-blocks.ts
CHANGED
|
@@ -410,10 +410,12 @@ export function* processFinalizedBlockWithRetry(
|
|
|
410
410
|
let attempt = 0;
|
|
411
411
|
while (true) {
|
|
412
412
|
let dbClient: PoolClient | undefined;
|
|
413
|
+
let checkout: Promise<PoolClient> | undefined;
|
|
413
414
|
let transientErr: unknown;
|
|
414
415
|
try {
|
|
415
416
|
yield* acquireDBMutex(lockName);
|
|
416
|
-
|
|
417
|
+
checkout = pool.connect();
|
|
418
|
+
dbClient = yield* until(checkout);
|
|
417
419
|
return yield* processFinalizedBlock(
|
|
418
420
|
value,
|
|
419
421
|
config,
|
|
@@ -434,6 +436,23 @@ export function* processFinalizedBlockWithRetry(
|
|
|
434
436
|
} catch {
|
|
435
437
|
/* already released/destroyed */
|
|
436
438
|
}
|
|
439
|
+
} else if (checkout) {
|
|
440
|
+
// Cancellation won the race with the checkout. `until` abandons the
|
|
441
|
+
// promise, but the pool still hands the client over afterwards and
|
|
442
|
+
// nobody is left in this scope to give it back — a leaked client keeps
|
|
443
|
+
// the pool one connection short and blocks `pool.end()` forever.
|
|
444
|
+
void checkout.then(
|
|
445
|
+
(client) => {
|
|
446
|
+
try {
|
|
447
|
+
client.release();
|
|
448
|
+
} catch {
|
|
449
|
+
/* already released/destroyed */
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
() => {
|
|
453
|
+
/* the checkout itself failed; nothing to return */
|
|
454
|
+
},
|
|
455
|
+
);
|
|
437
456
|
}
|
|
438
457
|
}
|
|
439
458
|
|
package/src/telemetry.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
|
2
2
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
3
3
|
import opentelemetry from "@opentelemetry/api";
|
|
4
4
|
import { defaultOtelSetup, PaimaTelemetryContext } from "@effectstream/log";
|
|
5
|
-
import type
|
|
5
|
+
import { call, ensure, type Operation } from "effection";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { parse } from "jsonc-parser";
|
|
@@ -34,4 +34,30 @@ export function* initTelemetry(): Operation<void> {
|
|
|
34
34
|
);
|
|
35
35
|
|
|
36
36
|
sdk.start();
|
|
37
|
+
|
|
38
|
+
// `initTelemetry` is delegated with `yield*`, so this registers in the
|
|
39
|
+
// caller's scope: telemetry lives exactly as long as whoever called `init()`,
|
|
40
|
+
// and — being the first thing that scope acquires — is released last, after
|
|
41
|
+
// the broker, HTTP server, database and child tasks it instruments.
|
|
42
|
+
//
|
|
43
|
+
// The SDK owns a periodic metric reader plus batch span/log processors; left
|
|
44
|
+
// running they keep exporting (and holding timers) past the lifetime of the
|
|
45
|
+
// node that created them.
|
|
46
|
+
let stopped = false;
|
|
47
|
+
yield* ensure(function* () {
|
|
48
|
+
// A re-entrant unwind must not shut the same SDK down twice: OpenTelemetry
|
|
49
|
+
// rejects the second call rather than treating it as a no-op.
|
|
50
|
+
if (stopped) return;
|
|
51
|
+
stopped = true;
|
|
52
|
+
try {
|
|
53
|
+
yield* call(() => sdk.shutdown());
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Reported, never fatal. `shutdown()` also rejects when the collector is
|
|
56
|
+
// simply unreachable, and a lost telemetry flush must not turn an
|
|
57
|
+
// otherwise clean runtime shutdown into a failed one — every resource
|
|
58
|
+
// that matters (broker, HTTP, database, child tasks) has already been
|
|
59
|
+
// released by the time this runs.
|
|
60
|
+
console.error("OpenTelemetry shutdown failed:", error);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
37
63
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess fixture driving the real `start()` from `src/main.ts` (spec 00031:
|
|
3
|
+
* G2, G3, G5, G8, G10b).
|
|
4
|
+
*
|
|
5
|
+
* A scenario is one process: the runtime boots against an in-process PGLite
|
|
6
|
+
* gateway on an OS-selected port above 10000, the scenario's failure is
|
|
7
|
+
* injected, the runtime is torn down, and the observable outcome is printed as
|
|
8
|
+
* stdout markers for the parent test to assert on.
|
|
9
|
+
*
|
|
10
|
+
* Booting `start()` in the shared `bun test` process is not an option — the
|
|
11
|
+
* existing reproduction harness documents that a second in-process boot stalls
|
|
12
|
+
* the sync loops — so every scenario gets its own process, exactly like
|
|
13
|
+
* `test/reproduction/node-runner.ts`.
|
|
14
|
+
*
|
|
15
|
+
* Usage: `bun runtime-lifecycle.ts '<json Spec>'`
|
|
16
|
+
*/
|
|
17
|
+
import type { Server } from "node:net";
|
|
18
|
+
import pg from "pg";
|
|
19
|
+
import { run, type Task } from "effection";
|
|
20
|
+
import { EventBroker } from "@effectstream/event-server";
|
|
21
|
+
import { startPglite, type PgliteHandle } from "@effectstream/db/start-pglite";
|
|
22
|
+
import {
|
|
23
|
+
toSyncProtocolWithNetwork,
|
|
24
|
+
withEffectstreamStaticConfig,
|
|
25
|
+
} from "@effectstream/config";
|
|
26
|
+
import { TestChainControl } from "@effectstream/sync";
|
|
27
|
+
import { init, start } from "../../../src/main.ts";
|
|
28
|
+
import type { StartConfigGameStateTransitions } from "../../../src/types.ts";
|
|
29
|
+
import {
|
|
30
|
+
buildConfig,
|
|
31
|
+
TEST_PRIMITIVE_TYPE,
|
|
32
|
+
TestEventPrimitive,
|
|
33
|
+
} from "../../reproduction/scenario.ts";
|
|
34
|
+
import { listenTcp, sleep, tcpConnects, waitUntil } from "../support.ts";
|
|
35
|
+
|
|
36
|
+
export type Spec = {
|
|
37
|
+
mode:
|
|
38
|
+
| "broker-cancel-during-start"
|
|
39
|
+
| "broker-shutdown-error"
|
|
40
|
+
| "child-failure-with-pool-end-error";
|
|
41
|
+
apiPort: number;
|
|
42
|
+
pglitePort: number;
|
|
43
|
+
brokerTcpPort: number;
|
|
44
|
+
brokerWsPort: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const spec: Spec = JSON.parse(process.argv[2] ?? "{}");
|
|
48
|
+
const noopStf: StartConfigGameStateTransitions = function* () {};
|
|
49
|
+
|
|
50
|
+
/** Flatten an error tree into a stable, greppable one-line shape. */
|
|
51
|
+
function describeError(error: unknown): string {
|
|
52
|
+
if (error instanceof AggregateError) {
|
|
53
|
+
return `Aggregate[${error.errors.map(describeError).join(" | ")}]`;
|
|
54
|
+
}
|
|
55
|
+
if (error instanceof Error) {
|
|
56
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
57
|
+
return code ? `${code}:${error.message}` : error.message;
|
|
58
|
+
}
|
|
59
|
+
return String(error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Report whether a port is released within `ms`. Polling rather than sampling
|
|
64
|
+
* once keeps a busy machine's slow-but-correct release from being read as a
|
|
65
|
+
* leak; a genuine leak stays bound for the whole window.
|
|
66
|
+
*/
|
|
67
|
+
async function portState(
|
|
68
|
+
port: number,
|
|
69
|
+
ms = 5_000,
|
|
70
|
+
): Promise<"bound" | "free"> {
|
|
71
|
+
const end = Date.now() + ms;
|
|
72
|
+
for (;;) {
|
|
73
|
+
if (!(await tcpConnects(port))) return "free";
|
|
74
|
+
if (Date.now() >= end) return "bound";
|
|
75
|
+
await sleep(50);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function say(marker: string, value: string): void {
|
|
80
|
+
console.log(`${marker}:${value}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main(): Promise<void> {
|
|
84
|
+
// The engine reads every one of these lazily through ENV getters, so setting
|
|
85
|
+
// them here (before the runtime is constructed) is enough.
|
|
86
|
+
process.env.PGLITE = "true";
|
|
87
|
+
process.env.PGLITE_DATA_DIR = "memory://";
|
|
88
|
+
process.env.DB_HOST = "127.0.0.1";
|
|
89
|
+
process.env.DB_PORT = String(spec.pglitePort);
|
|
90
|
+
process.env.DB_NAME = "postgres";
|
|
91
|
+
process.env.DB_USER = "postgres";
|
|
92
|
+
process.env.DB_PW = "";
|
|
93
|
+
process.env.EFFECTSTREAM_API_PORT = String(spec.apiPort);
|
|
94
|
+
process.env.EFFECTSTREAM_COALESCE_EMPTY_BLOCKS = "false";
|
|
95
|
+
process.env.MQTT_ENGINE_BROKER_PORT = String(spec.brokerTcpPort);
|
|
96
|
+
process.env.MQTT_ENGINE_BROKER_WS_PORT = String(spec.brokerWsPort);
|
|
97
|
+
const usesBroker = spec.mode !== "child-failure-with-pool-end-error";
|
|
98
|
+
process.env.MQTT_BROKER = usesBroker ? "true" : "false";
|
|
99
|
+
// Telemetry is exercised by its own fixture; here it would only add an
|
|
100
|
+
// unreachable-collector flush to every teardown.
|
|
101
|
+
process.env.OTEL_SDK_DISABLED = "true";
|
|
102
|
+
|
|
103
|
+
let brokerStartGateInstalled = false;
|
|
104
|
+
if (spec.mode === "broker-cancel-during-start") {
|
|
105
|
+
// Let the broker really bind, then hang so the runtime is cancelled while
|
|
106
|
+
// `start()` is still awaiting it.
|
|
107
|
+
const never = new Promise<void>(() => {});
|
|
108
|
+
const realStart = EventBroker.prototype.start;
|
|
109
|
+
EventBroker.prototype.start = function (this: EventBroker): Promise<void> {
|
|
110
|
+
return realStart.call(this).then(() => never);
|
|
111
|
+
};
|
|
112
|
+
brokerStartGateInstalled = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (spec.mode === "broker-shutdown-error") {
|
|
116
|
+
const realShutdown = (EventBroker.prototype as unknown as {
|
|
117
|
+
shutdown: () => Promise<void>;
|
|
118
|
+
}).shutdown;
|
|
119
|
+
(EventBroker.prototype as unknown as { shutdown: () => Promise<void> })
|
|
120
|
+
.shutdown = function (this: EventBroker): Promise<void> {
|
|
121
|
+
// Release the real resources first, then fail: a broker that frees its
|
|
122
|
+
// ports but reports a cleanup error must not be reported as clean.
|
|
123
|
+
return realShutdown.call(this).then(() => {
|
|
124
|
+
throw new Error("broker-shutdown-boom");
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let apiBlocker: Server | undefined;
|
|
130
|
+
if (spec.mode === "child-failure-with-pool-end-error") {
|
|
131
|
+
// Wildcard bind: the runtime listens on 0.0.0.0, and on BSD a loopback-only
|
|
132
|
+
// holder would not conflict with it.
|
|
133
|
+
apiBlocker = await listenTcp(spec.apiPort, "0.0.0.0");
|
|
134
|
+
const realEnd = pg.Pool.prototype.end;
|
|
135
|
+
void realEnd;
|
|
136
|
+
pg.Pool.prototype.end = function (this: pg.Pool): Promise<void> {
|
|
137
|
+
return Promise.reject(new Error("pool-end-boom"));
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const database: PgliteHandle = await startPglite(spec.pglitePort);
|
|
142
|
+
await database.db.waitReady;
|
|
143
|
+
say("PGLITE_READY", String(database.port));
|
|
144
|
+
|
|
145
|
+
const cfg = buildConfig({
|
|
146
|
+
securityNamespace: "runtime-lifecycle",
|
|
147
|
+
events: [],
|
|
148
|
+
});
|
|
149
|
+
const syncInfo = toSyncProtocolWithNetwork(cfg);
|
|
150
|
+
// Explicit, tiny tips. Without them the synthetic TEST chain falls back to a
|
|
151
|
+
// wall-clock tip and the node tries to sync decades of blocks.
|
|
152
|
+
for (const entry of syncInfo) {
|
|
153
|
+
TestChainControl.setTip(entry.syncProtocol.name, 3);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let started = false;
|
|
157
|
+
const task: Task<unknown> = run(function* () {
|
|
158
|
+
yield* init();
|
|
159
|
+
yield* withEffectstreamStaticConfig(cfg, function* () {
|
|
160
|
+
yield* start({
|
|
161
|
+
appName: "runtime-lifecycle",
|
|
162
|
+
appVersion: "1.0.0",
|
|
163
|
+
syncInfo,
|
|
164
|
+
gameStateTransitions: noopStf,
|
|
165
|
+
userDefinedPrimitives: { [TEST_PRIMITIVE_TYPE]: TestEventPrimitive },
|
|
166
|
+
events: false,
|
|
167
|
+
dev: {
|
|
168
|
+
resetPublicData: false,
|
|
169
|
+
onStarted: () => {
|
|
170
|
+
started = true;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
const settled = Promise.resolve(task).then(
|
|
177
|
+
() => "ok",
|
|
178
|
+
(error: unknown) => `err:${describeError(error)}`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
if (spec.mode === "child-failure-with-pool-end-error") {
|
|
183
|
+
// The HTTP child fails at listen; the runtime tears itself down.
|
|
184
|
+
say("TASK_SETTLED", await settled);
|
|
185
|
+
} else {
|
|
186
|
+
await waitUntil(() => started, 60_000, "runtime startup");
|
|
187
|
+
// Both listeners: the broker binds TCP first, so waiting only on TCP
|
|
188
|
+
// would race the WebSocket half of its startup.
|
|
189
|
+
await waitUntil(
|
|
190
|
+
() => tcpConnects(spec.brokerTcpPort),
|
|
191
|
+
60_000,
|
|
192
|
+
"broker TCP listener",
|
|
193
|
+
);
|
|
194
|
+
await waitUntil(
|
|
195
|
+
() => tcpConnects(spec.brokerWsPort),
|
|
196
|
+
60_000,
|
|
197
|
+
"broker WebSocket listener",
|
|
198
|
+
);
|
|
199
|
+
say("BROKER_TCP_BOUND", "yes");
|
|
200
|
+
if (spec.mode === "broker-shutdown-error") {
|
|
201
|
+
// This scenario is about a *failing* shutdown, not about cancelling a
|
|
202
|
+
// start. Wait until the runtime is demonstrably past the broker block
|
|
203
|
+
// — the HTTP server is spawned after it — so the broker's own cleanup
|
|
204
|
+
// registration is not itself the thing under test.
|
|
205
|
+
await waitUntil(
|
|
206
|
+
() => tcpConnects(spec.apiPort),
|
|
207
|
+
60_000,
|
|
208
|
+
"API listener (runtime past the broker block)",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const halted = await task.halt().then(
|
|
212
|
+
() => "ok",
|
|
213
|
+
(error: unknown) => `err:${describeError(error)}`,
|
|
214
|
+
);
|
|
215
|
+
// Give the OS a moment to release listeners before sampling them.
|
|
216
|
+
await sleep(100);
|
|
217
|
+
say("AFTER_HALT_BROKER_TCP", await portState(spec.brokerTcpPort));
|
|
218
|
+
say("AFTER_HALT_BROKER_WS", await portState(spec.brokerWsPort));
|
|
219
|
+
say("AFTER_HALT_API", await portState(spec.apiPort));
|
|
220
|
+
say("HALT_SETTLED", halted);
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
if (brokerStartGateInstalled) {
|
|
224
|
+
// Nothing else runs after this, but keep the process from holding a
|
|
225
|
+
// half-started broker if the scenario bailed out early.
|
|
226
|
+
}
|
|
227
|
+
await database.close({ force: true }).catch(() => {});
|
|
228
|
+
if (apiBlocker) {
|
|
229
|
+
await new Promise<void>((resolve) => apiBlocker!.close(() => resolve()));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
say("FIXTURE_DONE", spec.mode);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main().then(
|
|
237
|
+
() => process.exit(0),
|
|
238
|
+
(error: unknown) => {
|
|
239
|
+
console.error(
|
|
240
|
+
`FIXTURE_FAILED:${
|
|
241
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error)
|
|
242
|
+
}`,
|
|
243
|
+
);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
},
|
|
246
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess fixture for the telemetry lifecycle reproductions (spec 00031, G4).
|
|
3
|
+
*
|
|
4
|
+
* `init()` installs OpenTelemetry auto-instrumentation, which monkey-patches
|
|
5
|
+
* `http`, `pg` and friends process-wide. Running it inside the shared `bun test`
|
|
6
|
+
* process would leak that patching into every other suite, so each scenario gets
|
|
7
|
+
* its own process and communicates through stdout markers.
|
|
8
|
+
*
|
|
9
|
+
* Usage: `bun telemetry-lifecycle.ts <observe|failing-shutdown>`
|
|
10
|
+
*/
|
|
11
|
+
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
12
|
+
import { ensure, run, suspend } from "effection";
|
|
13
|
+
import { init } from "../../../src/main.ts";
|
|
14
|
+
|
|
15
|
+
const mode = process.argv[2] ?? "observe";
|
|
16
|
+
|
|
17
|
+
const realShutdown = NodeSDK.prototype.shutdown;
|
|
18
|
+
NodeSDK.prototype.shutdown = function (this: NodeSDK): Promise<void> {
|
|
19
|
+
console.log("TELEMETRY_SHUTDOWN");
|
|
20
|
+
if (mode === "failing-shutdown") {
|
|
21
|
+
return Promise.reject(new Error("telemetry-shutdown-boom"));
|
|
22
|
+
}
|
|
23
|
+
return realShutdown.call(this);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const realConsoleError = console.error;
|
|
27
|
+
console.error = (...args: unknown[]) => {
|
|
28
|
+
realConsoleError(...args);
|
|
29
|
+
const rendered = args.map((arg) =>
|
|
30
|
+
arg instanceof Error ? arg.message : String(arg)
|
|
31
|
+
).join(" ");
|
|
32
|
+
if (rendered.includes("telemetry-shutdown-boom")) {
|
|
33
|
+
console.log("TELEMETRY_SHUTDOWN_REPORTED");
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const task = run(function* () {
|
|
38
|
+
yield* init();
|
|
39
|
+
// Stands in for every resource the runtime acquires after init(): broker,
|
|
40
|
+
// HTTP, database, child tasks. Registered later, so it must tear down first.
|
|
41
|
+
yield* ensure(function* () {
|
|
42
|
+
console.log("SIBLING_CLEANUP");
|
|
43
|
+
});
|
|
44
|
+
yield* suspend();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const outcome = await task.halt().then(
|
|
48
|
+
() => "ok",
|
|
49
|
+
(error: unknown) => `err:${error instanceof Error ? error.message : String(error)}`,
|
|
50
|
+
);
|
|
51
|
+
console.log(`HALT_SETTLED:${outcome}`);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproductions for the HTTP-server half of the runtime resource lifecycle
|
|
3
|
+
* (spec 00031: G1, G9, G10).
|
|
4
|
+
*
|
|
5
|
+
* `startHttpServer` is driven directly — it is the unit that owns the API port,
|
|
6
|
+
* the Fastify instance and every plugin the host registered through
|
|
7
|
+
* `apiRouter` — so each defect is observable without booting a database.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, expect, test } from "bun:test";
|
|
10
|
+
import pg from "pg";
|
|
11
|
+
import { run, type Task } from "effection";
|
|
12
|
+
import { withEffectstreamStaticConfig } from "@effectstream/config";
|
|
13
|
+
import {
|
|
14
|
+
acquireDBMutex,
|
|
15
|
+
releaseDBMutex,
|
|
16
|
+
waitUntilFree,
|
|
17
|
+
} from "@effectstream/db";
|
|
18
|
+
import type { FastifyInstance } from "fastify";
|
|
19
|
+
import { startHttpServer } from "../../src/api/http-server.ts";
|
|
20
|
+
import type { StartConfigApiRouter } from "../../src/types.ts";
|
|
21
|
+
import {
|
|
22
|
+
closeTcp,
|
|
23
|
+
connectTcp,
|
|
24
|
+
freePortAbove10000,
|
|
25
|
+
listenTcp,
|
|
26
|
+
restoreEnv,
|
|
27
|
+
saveEnv,
|
|
28
|
+
settle,
|
|
29
|
+
sleep,
|
|
30
|
+
tcpRebindable,
|
|
31
|
+
waitUntil,
|
|
32
|
+
withDeadline,
|
|
33
|
+
} from "./support.ts";
|
|
34
|
+
|
|
35
|
+
const ENV_KEYS = [
|
|
36
|
+
"EFFECTSTREAM_API_PORT",
|
|
37
|
+
"ENABLE_DEV_AND_DEBUG_ENDPOINTS",
|
|
38
|
+
"PGLITE",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
const STATIC_CONFIG = {
|
|
42
|
+
securityNamespace: "runtime-lifecycle",
|
|
43
|
+
allNetworks: { viemNetworks: {} },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A pool that is never queried. Every route that touches the database is only
|
|
48
|
+
* *registered* by `startHttpServer`; these tests never invoke one, so no
|
|
49
|
+
* connection is ever opened.
|
|
50
|
+
*/
|
|
51
|
+
function idlePool(): pg.Pool {
|
|
52
|
+
return new pg.Pool({
|
|
53
|
+
host: "127.0.0.1",
|
|
54
|
+
port: 1,
|
|
55
|
+
user: "unused",
|
|
56
|
+
database: "unused",
|
|
57
|
+
max: 1,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Owned = {
|
|
62
|
+
env: ReturnType<typeof saveEnv>;
|
|
63
|
+
pools: pg.Pool[];
|
|
64
|
+
tasks: Task<unknown>[];
|
|
65
|
+
servers: Awaited<ReturnType<typeof listenTcp>>[];
|
|
66
|
+
sockets: Awaited<ReturnType<typeof connectTcp>>[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const owned: Owned = {
|
|
70
|
+
env: saveEnv(ENV_KEYS),
|
|
71
|
+
pools: [],
|
|
72
|
+
tasks: [],
|
|
73
|
+
servers: [],
|
|
74
|
+
sockets: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function startServer(
|
|
78
|
+
pool: pg.Pool,
|
|
79
|
+
apiRouter?: StartConfigApiRouter,
|
|
80
|
+
): Task<unknown> {
|
|
81
|
+
const task = run(function* () {
|
|
82
|
+
yield* withEffectstreamStaticConfig(STATIC_CONFIG, function* () {
|
|
83
|
+
yield* startHttpServer(pool, [], 60_000, apiRouter);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
owned.tasks.push(task);
|
|
87
|
+
// start() rejects on a bind conflict; the tests that expect it observe the
|
|
88
|
+
// task themselves, this only keeps the rejection from going unhandled.
|
|
89
|
+
Promise.resolve(task).catch(() => {});
|
|
90
|
+
return task;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
for (const socket of owned.sockets.splice(0)) socket.destroy();
|
|
95
|
+
for (const task of owned.tasks.splice(0)) {
|
|
96
|
+
// Bounded: on the unfixed runtime a halt can block behind an in-flight
|
|
97
|
+
// request forever, and cleanup must not take the whole file down with it.
|
|
98
|
+
await Promise.race([task.halt().catch(() => {}), sleep(2_000)]);
|
|
99
|
+
}
|
|
100
|
+
for (const server of owned.servers.splice(0)) await closeTcp(server);
|
|
101
|
+
for (const pool of owned.pools.splice(0)) await pool.end().catch(() => {});
|
|
102
|
+
// The DB mutex is a module global shared by every suite in this process. A
|
|
103
|
+
// reproduced leak must not be inherited by the next test file.
|
|
104
|
+
const mutex = waitUntilFree();
|
|
105
|
+
if (mutex.db_mutex === "locked") releaseDBMutex(mutex.running.name);
|
|
106
|
+
restoreEnv(owned.env);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("G1: a rejected HTTP listen still runs the apiRouter's onClose hooks", async () => {
|
|
110
|
+
const port = await freePortAbove10000();
|
|
111
|
+
owned.servers.push(await listenTcp(port));
|
|
112
|
+
process.env.EFFECTSTREAM_API_PORT = String(port);
|
|
113
|
+
|
|
114
|
+
const closeHooks: string[] = [];
|
|
115
|
+
const apiRouter: StartConfigApiRouter = (server: FastifyInstance) => {
|
|
116
|
+
server.addHook("onClose", async () => {
|
|
117
|
+
closeHooks.push("apiRouter");
|
|
118
|
+
});
|
|
119
|
+
return Promise.resolve();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const pool = idlePool();
|
|
123
|
+
owned.pools.push(pool);
|
|
124
|
+
const outcome = await withDeadline(settle(startServer(pool, apiRouter)));
|
|
125
|
+
|
|
126
|
+
expect(outcome.ok).toBe(false);
|
|
127
|
+
expect((outcome as { error: NodeJS.ErrnoException }).error.code).toBe(
|
|
128
|
+
"EADDRINUSE",
|
|
129
|
+
);
|
|
130
|
+
// The Fastify instance was fully built (plugins registered, undici/hook
|
|
131
|
+
// state live) before listen failed. Skipping close on a failed bind leaks it
|
|
132
|
+
// and silently skips every host-registered teardown hook.
|
|
133
|
+
expect(closeHooks).toEqual(["apiRouter"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Spec 00031 Acceptance 1 for the API port. This is an acceptance guard, not a
|
|
137
|
+
// reproduction: it is already green on the PR base (see plan P1.4, G10a), and
|
|
138
|
+
// it exists so a shutdown change cannot silently regress rebindability.
|
|
139
|
+
test("G10a (acceptance): the API port rebinds immediately after shutdown, repeatedly, with a live keep-alive client", async () => {
|
|
140
|
+
const port = await freePortAbove10000();
|
|
141
|
+
process.env.EFFECTSTREAM_API_PORT = String(port);
|
|
142
|
+
|
|
143
|
+
for (let round = 0; round < 3; round++) {
|
|
144
|
+
const pool = idlePool();
|
|
145
|
+
owned.pools.push(pool);
|
|
146
|
+
const task = startServer(pool);
|
|
147
|
+
await waitUntil(
|
|
148
|
+
async () => (await fetch(`http://127.0.0.1:${port}/health`)
|
|
149
|
+
.then((r) => r.status === 200 || r.status === 503)
|
|
150
|
+
.catch(() => false)),
|
|
151
|
+
10_000,
|
|
152
|
+
`round ${round}: server listening`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// A real keep-alive client: an idle HTTP/1.1 socket the kernel still holds
|
|
156
|
+
// open. `server.close()` alone waits for it, so shutdown must force it.
|
|
157
|
+
const keepAlive = await connectTcp(port);
|
|
158
|
+
owned.sockets.push(keepAlive);
|
|
159
|
+
keepAlive.write(
|
|
160
|
+
`GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\n\r\n`,
|
|
161
|
+
);
|
|
162
|
+
await new Promise<void>((resolve, reject) => {
|
|
163
|
+
keepAlive.once("data", () => resolve());
|
|
164
|
+
keepAlive.once("error", reject);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await withDeadline(task.halt(), 5_000);
|
|
168
|
+
expect(await tcpRebindable(port)).toBe(true);
|
|
169
|
+
}
|
|
170
|
+
}, 30_000);
|
|
171
|
+
|
|
172
|
+
test("G9: a per-request DB-mutex task does not outlive the HTTP server's scope", async () => {
|
|
173
|
+
const port = await freePortAbove10000();
|
|
174
|
+
process.env.EFFECTSTREAM_API_PORT = String(port);
|
|
175
|
+
process.env.ENABLE_DEV_AND_DEBUG_ENDPOINTS = "true";
|
|
176
|
+
// The mutex is a no-op unless PGLITE is on; the route exists to serialize
|
|
177
|
+
// PGLite's single WASM backend, so that is the configuration under test.
|
|
178
|
+
process.env.PGLITE = "true";
|
|
179
|
+
|
|
180
|
+
const pool = idlePool();
|
|
181
|
+
owned.pools.push(pool);
|
|
182
|
+
const task = startServer(pool);
|
|
183
|
+
await waitUntil(
|
|
184
|
+
async () => (await fetch(`http://127.0.0.1:${port}/health`)
|
|
185
|
+
.then((r) => r.status === 200 || r.status === 503)
|
|
186
|
+
.catch(() => false)),
|
|
187
|
+
10_000,
|
|
188
|
+
"server listening",
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Hold the mutex so the request's `run(() => acquireDBMutex(...))` cannot
|
|
192
|
+
// settle: it is now a detached Effection task with a pending acquisition.
|
|
193
|
+
await run(() => acquireDBMutex("test-holder"));
|
|
194
|
+
const request = settle(
|
|
195
|
+
fetch(`http://127.0.0.1:${port}/db_acquire_lock?name=ghost`),
|
|
196
|
+
);
|
|
197
|
+
await waitUntil(
|
|
198
|
+
() => waitUntilFree().waiting.some((w) => w.name === "http-server:ghost"),
|
|
199
|
+
5_000,
|
|
200
|
+
"request task queued on the mutex",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Shut the runtime's HTTP scope down while that request is in flight. The
|
|
204
|
+
// request is only unblockable by cancellation, so a scope that cannot reach
|
|
205
|
+
// its own in-flight work never settles.
|
|
206
|
+
const halted = await settle(withDeadline(task.halt(), 3_000));
|
|
207
|
+
|
|
208
|
+
// Release unconditionally so the request can drain on either code path.
|
|
209
|
+
releaseDBMutex("test-holder");
|
|
210
|
+
await settle(withDeadline(request, 3_000));
|
|
211
|
+
await sleep(150);
|
|
212
|
+
|
|
213
|
+
expect(halted.ok).toBe(true);
|
|
214
|
+
// Nothing the runtime owned is running any more, so the mutex the test
|
|
215
|
+
// released must still be free. On the unfixed runtime the detached task
|
|
216
|
+
// survives the halt and grabs it — a post-shutdown side effect.
|
|
217
|
+
expect(waitUntilFree().running.name).toBe("");
|
|
218
|
+
expect(waitUntilFree().db_mutex).toBe("free");
|
|
219
|
+
}, 20_000);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproduction for the block-processing checkout boundary (spec 00031: G7).
|
|
3
|
+
*
|
|
4
|
+
* `processFinalizedBlockWithRetry` is driven directly against a stub pool: the
|
|
5
|
+
* defect is entirely in the window between `pool.connect()` being issued and
|
|
6
|
+
* its client being assigned, so no database is involved.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
9
|
+
import type { Pool, PoolClient } from "pg";
|
|
10
|
+
import { run } from "effection";
|
|
11
|
+
import { processFinalizedBlockWithRetry } from "../../src/process-blocks.ts";
|
|
12
|
+
import type { ChainBlock } from "@effectstream/sync";
|
|
13
|
+
import type { StartConfig } from "../../src/types.ts";
|
|
14
|
+
import { restoreEnv, saveEnv, settle, sleep, waitUntil } from "./support.ts";
|
|
15
|
+
|
|
16
|
+
let env: ReturnType<typeof saveEnv>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
env = saveEnv(["PGLITE"]);
|
|
20
|
+
// `PGLITE` defaults to true, which turns the per-block mutex on. This test is
|
|
21
|
+
// about the pool checkout, so keep it out of the mutex's way entirely.
|
|
22
|
+
process.env.PGLITE = "false";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => restoreEnv(env));
|
|
26
|
+
|
|
27
|
+
test("G7: halting while the pool checkout is in flight still returns the client", async () => {
|
|
28
|
+
const released: unknown[] = [];
|
|
29
|
+
let resolveConnect: ((client: PoolClient) => void) | undefined;
|
|
30
|
+
|
|
31
|
+
const pool = {
|
|
32
|
+
connect: () =>
|
|
33
|
+
new Promise<PoolClient>((resolve) => {
|
|
34
|
+
resolveConnect = resolve;
|
|
35
|
+
}),
|
|
36
|
+
} as unknown as Pool;
|
|
37
|
+
|
|
38
|
+
const task = run(() =>
|
|
39
|
+
processFinalizedBlockWithRetry(
|
|
40
|
+
{ blockNumber: 1 } as unknown as ChainBlock,
|
|
41
|
+
{} as StartConfig,
|
|
42
|
+
pool,
|
|
43
|
+
null,
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
// The operation is cancelled, so its own promise settles as halted.
|
|
47
|
+
void settle(Promise.resolve(task));
|
|
48
|
+
|
|
49
|
+
await waitUntil(
|
|
50
|
+
() => resolveConnect !== undefined,
|
|
51
|
+
5_000,
|
|
52
|
+
"pool.connect() issued",
|
|
53
|
+
);
|
|
54
|
+
await task.halt();
|
|
55
|
+
|
|
56
|
+
// The pool hands the client over after the halt — exactly what happens when
|
|
57
|
+
// a real checkout wins the race with cancellation. Nobody is left to return
|
|
58
|
+
// it, so the pool is permanently one client short and `pool.end()` hangs.
|
|
59
|
+
const client = {
|
|
60
|
+
release: (error?: Error) => {
|
|
61
|
+
released.push(error ?? null);
|
|
62
|
+
},
|
|
63
|
+
} as unknown as PoolClient;
|
|
64
|
+
resolveConnect!(client);
|
|
65
|
+
await sleep(200);
|
|
66
|
+
|
|
67
|
+
expect(released.length).toBe(1);
|
|
68
|
+
}, 20_000);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproductions for the runtime-level resource boundaries in `src/main.ts`
|
|
3
|
+
* (spec 00031: G2, G3, G5, G8, G10b).
|
|
4
|
+
*
|
|
5
|
+
* Every scenario boots the real `start()` in its own subprocess — see
|
|
6
|
+
* `fixtures/runtime-lifecycle.ts` — so these tests only allocate ports, launch
|
|
7
|
+
* the scenarios once, and assert on the markers each one printed.
|
|
8
|
+
*/
|
|
9
|
+
import { beforeAll, expect, test } from "bun:test";
|
|
10
|
+
import { freeDistinctPorts } from "./support.ts";
|
|
11
|
+
|
|
12
|
+
const FIXTURE = new URL("./fixtures/runtime-lifecycle.ts", import.meta.url)
|
|
13
|
+
.pathname;
|
|
14
|
+
const CWD = new URL("../../../../..", import.meta.url).pathname;
|
|
15
|
+
|
|
16
|
+
type Mode =
|
|
17
|
+
| "broker-cancel-during-start"
|
|
18
|
+
| "broker-shutdown-error"
|
|
19
|
+
| "child-failure-with-pool-end-error";
|
|
20
|
+
|
|
21
|
+
const MODES: Mode[] = [
|
|
22
|
+
"broker-cancel-during-start",
|
|
23
|
+
"broker-shutdown-error",
|
|
24
|
+
"child-failure-with-pool-end-error",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const outputs = new Map<Mode, string>();
|
|
28
|
+
|
|
29
|
+
async function runScenario(mode: Mode, ports: number[]): Promise<string> {
|
|
30
|
+
const [apiPort, pglitePort, brokerTcpPort, brokerWsPort] = ports;
|
|
31
|
+
const spec = JSON.stringify({
|
|
32
|
+
mode,
|
|
33
|
+
apiPort,
|
|
34
|
+
pglitePort,
|
|
35
|
+
brokerTcpPort,
|
|
36
|
+
brokerWsPort,
|
|
37
|
+
});
|
|
38
|
+
const child = Bun.spawn([process.execPath, FIXTURE, spec], {
|
|
39
|
+
cwd: CWD,
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
44
|
+
new Response(child.stdout).text(),
|
|
45
|
+
new Response(child.stderr).text(),
|
|
46
|
+
child.exited,
|
|
47
|
+
]);
|
|
48
|
+
const output = `${stdout}\n${stderr}`;
|
|
49
|
+
expect(exitCode, `${mode} fixture failed:\n${output}`).toBe(0);
|
|
50
|
+
expect(output, `${mode} fixture did not finish:\n${output}`).toContain(
|
|
51
|
+
`FIXTURE_DONE:${mode}`,
|
|
52
|
+
);
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read a `MARKER:value` line the fixture printed. */
|
|
57
|
+
function marker(mode: Mode, name: string): string {
|
|
58
|
+
const output = outputs.get(mode) ?? "";
|
|
59
|
+
const match = output.match(new RegExp(`^${name}:(.*)$`, "m"));
|
|
60
|
+
if (!match) {
|
|
61
|
+
throw new Error(`marker ${name} missing from ${mode} output:\n${output}`);
|
|
62
|
+
}
|
|
63
|
+
return match[1].trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
// Allocate every port in one sequential pass BEFORE launching anything:
|
|
68
|
+
// concurrent allocation can hand the same OS-selected port to two scenarios,
|
|
69
|
+
// and a leaked listener in one would then be misread as the other's.
|
|
70
|
+
const ports = await freeDistinctPorts(4 * MODES.length);
|
|
71
|
+
// The scenarios are independent processes on disjoint ports, so run them
|
|
72
|
+
// concurrently: each one boots a database and the whole engine.
|
|
73
|
+
const results = await Promise.all(
|
|
74
|
+
MODES.map((mode, index) =>
|
|
75
|
+
runScenario(mode, ports.slice(index * 4, index * 4 + 4))
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
MODES.forEach((mode, index) => outputs.set(mode, results[index]));
|
|
79
|
+
}, 300_000);
|
|
80
|
+
|
|
81
|
+
test("G2: cancelling start() while the broker is still starting still shuts the broker down", () => {
|
|
82
|
+
// The broker really bound both listeners before start() was cancelled.
|
|
83
|
+
expect(marker("broker-cancel-during-start", "BROKER_TCP_BOUND")).toBe("yes");
|
|
84
|
+
expect(marker("broker-cancel-during-start", "AFTER_HALT_BROKER_TCP")).toBe(
|
|
85
|
+
"free",
|
|
86
|
+
);
|
|
87
|
+
expect(marker("broker-cancel-during-start", "AFTER_HALT_BROKER_WS")).toBe(
|
|
88
|
+
"free",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("G10b: the broker's ports are immediately rebindable after a cancelled start", () => {
|
|
93
|
+
// Spec Acceptance 1 for the broker half: a leaked listener is exactly what
|
|
94
|
+
// makes a second run of the same node fail on a fixed port.
|
|
95
|
+
const tcp = marker("broker-cancel-during-start", "AFTER_HALT_BROKER_TCP");
|
|
96
|
+
const ws = marker("broker-cancel-during-start", "AFTER_HALT_BROKER_WS");
|
|
97
|
+
expect({ tcp, ws }).toEqual({ tcp: "free", ws: "free" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("G3: a broker shutdown failure is reported, not swallowed", () => {
|
|
101
|
+
expect(marker("broker-shutdown-error", "BROKER_TCP_BOUND")).toBe("yes");
|
|
102
|
+
// The broker released its ports and then failed; the runtime must not
|
|
103
|
+
// report that teardown as clean.
|
|
104
|
+
expect(marker("broker-shutdown-error", "AFTER_HALT_BROKER_TCP")).toBe("free");
|
|
105
|
+
expect(marker("broker-shutdown-error", "HALT_SETTLED")).toContain(
|
|
106
|
+
"broker-shutdown-boom",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("G8: a dbConn.end() failure does not replace the primary error", () => {
|
|
111
|
+
// Scenario: the API port is occupied, so the spawned HTTP child fails at
|
|
112
|
+
// listen (the primary, causal failure), and the pool's end() then rejects.
|
|
113
|
+
const settled = marker("child-failure-with-pool-end-error", "TASK_SETTLED");
|
|
114
|
+
expect(settled).toContain("pool-end-boom");
|
|
115
|
+
expect(settled).toContain("EADDRINUSE");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("G5: a failing child task's error survives the runtime's own cleanup", () => {
|
|
119
|
+
// Same scenario, read from the child-supervision side: the runtime has no
|
|
120
|
+
// boundary that retains a child's failure once a later frame cleanup throws,
|
|
121
|
+
// so the only error the host ever sees is the cleanup's.
|
|
122
|
+
const settled = marker("child-failure-with-pool-end-error", "TASK_SETTLED");
|
|
123
|
+
expect(settled).toContain("EADDRINUSE");
|
|
124
|
+
// ...and it must come first: the child failed before anything was cleaned up.
|
|
125
|
+
expect(settled.indexOf("EADDRINUSE")).toBeLessThan(
|
|
126
|
+
settled.indexOf("pool-end-boom"),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the runtime resource-lifecycle reproductions.
|
|
3
|
+
*
|
|
4
|
+
* Every bind in this suite uses an OS-selected port above 10000 (never the
|
|
5
|
+
* 9999/8883/8884 product defaults) so the suite is safe on a shared machine and
|
|
6
|
+
* can run concurrently with anything else.
|
|
7
|
+
*/
|
|
8
|
+
import net, { type AddressInfo, type Server, type Socket } from "node:net";
|
|
9
|
+
|
|
10
|
+
/** Grab an OS-selected free TCP port above 10000. */
|
|
11
|
+
export async function freePortAbove10000(): Promise<number> {
|
|
12
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
13
|
+
const server = net.createServer();
|
|
14
|
+
await new Promise<void>((resolve, reject) => {
|
|
15
|
+
server.once("error", reject);
|
|
16
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
17
|
+
});
|
|
18
|
+
const port = (server.address() as AddressInfo).port;
|
|
19
|
+
await closeTcp(server);
|
|
20
|
+
if (port > 10_000) return port;
|
|
21
|
+
}
|
|
22
|
+
throw new Error("Unable to acquire an OS-selected port above 10000");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** N distinct free ports above 10000. */
|
|
26
|
+
export async function freeDistinctPorts(count: number): Promise<number[]> {
|
|
27
|
+
const ports = new Set<number>();
|
|
28
|
+
while (ports.size < count) ports.add(await freePortAbove10000());
|
|
29
|
+
return [...ports];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Occupy `port`. Defaults to the wildcard address because the runtime's HTTP
|
|
34
|
+
* server binds `0.0.0.0`: on BSD/macOS a loopback-only holder does NOT conflict
|
|
35
|
+
* with a wildcard bind, so a `127.0.0.1` blocker would silently fail to block.
|
|
36
|
+
*/
|
|
37
|
+
export async function listenTcp(
|
|
38
|
+
port: number,
|
|
39
|
+
host = "0.0.0.0",
|
|
40
|
+
): Promise<Server> {
|
|
41
|
+
const server = net.createServer();
|
|
42
|
+
await new Promise<void>((resolve, reject) => {
|
|
43
|
+
server.once("error", reject);
|
|
44
|
+
server.listen(port, host, resolve);
|
|
45
|
+
});
|
|
46
|
+
return server;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function closeTcp(server?: Server): Promise<void> {
|
|
50
|
+
if (!server?.listening) return;
|
|
51
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when `port` can be bound again right now. */
|
|
55
|
+
export async function tcpRebindable(
|
|
56
|
+
port: number,
|
|
57
|
+
host = "0.0.0.0",
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
const server = await listenTcp(port, host);
|
|
61
|
+
await closeTcp(server);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True once a TCP connection to `port` succeeds; never rejects. */
|
|
69
|
+
export function tcpConnects(port: number): Promise<boolean> {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const sock = net.connect(port, "127.0.0.1");
|
|
72
|
+
sock.once("connect", () => {
|
|
73
|
+
sock.destroy();
|
|
74
|
+
resolve(true);
|
|
75
|
+
});
|
|
76
|
+
sock.once("error", () => {
|
|
77
|
+
sock.destroy();
|
|
78
|
+
resolve(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function connectTcp(port: number): Promise<Socket> {
|
|
84
|
+
const socket = net.connect({ port, host: "127.0.0.1" });
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
socket.once("connect", resolve);
|
|
87
|
+
socket.once("error", reject);
|
|
88
|
+
});
|
|
89
|
+
return socket;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sleep(ms: number): Promise<void> {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Poll `check` until it is true, or throw after `ms`. */
|
|
97
|
+
export async function waitUntil(
|
|
98
|
+
check: () => boolean | Promise<boolean>,
|
|
99
|
+
ms = 5_000,
|
|
100
|
+
label = "condition",
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const end = Date.now() + ms;
|
|
103
|
+
for (;;) {
|
|
104
|
+
if (await check()) return;
|
|
105
|
+
if (Date.now() >= end) throw new Error(`${label} not met within ${ms}ms`);
|
|
106
|
+
await sleep(5);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve `promise`, or throw once `ms` has elapsed. */
|
|
111
|
+
export async function withDeadline<T>(
|
|
112
|
+
promise: PromiseLike<T>,
|
|
113
|
+
ms = 10_000,
|
|
114
|
+
): Promise<T> {
|
|
115
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
try {
|
|
117
|
+
return await Promise.race([
|
|
118
|
+
Promise.resolve(promise),
|
|
119
|
+
new Promise<never>((_, reject) => {
|
|
120
|
+
timer = setTimeout(
|
|
121
|
+
() => reject(new Error(`deadline exceeded after ${ms}ms`)),
|
|
122
|
+
ms,
|
|
123
|
+
);
|
|
124
|
+
}),
|
|
125
|
+
]);
|
|
126
|
+
} finally {
|
|
127
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Settle a promise into `{ ok }` / `{ ok: false, error }` without throwing. */
|
|
132
|
+
export async function settle<T>(
|
|
133
|
+
promise: PromiseLike<T>,
|
|
134
|
+
): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
|
|
135
|
+
try {
|
|
136
|
+
return { ok: true, value: await promise };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { ok: false, error };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type EnvSnapshot = Record<string, string | undefined>;
|
|
143
|
+
|
|
144
|
+
export function saveEnv(keys: readonly string[]): EnvSnapshot {
|
|
145
|
+
return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function restoreEnv(snapshot: EnvSnapshot): void {
|
|
149
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
150
|
+
if (value === undefined) delete process.env[key];
|
|
151
|
+
else process.env[key] = value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproductions for the telemetry half of the runtime resource lifecycle
|
|
3
|
+
* (spec 00031: G4, plus the spec's "a telemetry failure does not skip broker,
|
|
4
|
+
* HTTP, database, or child-task cleanup" edge case).
|
|
5
|
+
*
|
|
6
|
+
* Each scenario runs in its own subprocess — see `fixtures/telemetry-lifecycle.ts`
|
|
7
|
+
* for why — and is asserted from the marker order on its stdout.
|
|
8
|
+
*/
|
|
9
|
+
import { expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
const FIXTURE = new URL("./fixtures/telemetry-lifecycle.ts", import.meta.url)
|
|
12
|
+
.pathname;
|
|
13
|
+
|
|
14
|
+
async function runFixture(mode: string): Promise<string> {
|
|
15
|
+
const child = Bun.spawn([process.execPath, FIXTURE, mode], {
|
|
16
|
+
cwd: new URL("../../../../..", import.meta.url).pathname,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
});
|
|
20
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
21
|
+
new Response(child.stdout).text(),
|
|
22
|
+
new Response(child.stderr).text(),
|
|
23
|
+
child.exited,
|
|
24
|
+
]);
|
|
25
|
+
const output = `${stdout}\n${stderr}`;
|
|
26
|
+
expect(exitCode, output).toBe(0);
|
|
27
|
+
return output;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("G4: halting the scope that ran init() shuts the OpenTelemetry SDK down", async () => {
|
|
31
|
+
const output = await runFixture("observe");
|
|
32
|
+
|
|
33
|
+
expect(output).toContain("SIBLING_CLEANUP");
|
|
34
|
+
expect(output).toContain("HALT_SETTLED:ok");
|
|
35
|
+
// `init()` starts a NodeSDK (periodic metric reader, batch span/log
|
|
36
|
+
// processors, auto-instrumentation) and never tears it down.
|
|
37
|
+
expect(output).toContain("TELEMETRY_SHUTDOWN");
|
|
38
|
+
// Telemetry is acquired first, so it must be released last: everything it
|
|
39
|
+
// instruments has to be able to emit while it is still shutting down.
|
|
40
|
+
expect(output.indexOf("SIBLING_CLEANUP")).toBeLessThan(
|
|
41
|
+
output.indexOf("TELEMETRY_SHUTDOWN"),
|
|
42
|
+
);
|
|
43
|
+
}, 60_000);
|
|
44
|
+
|
|
45
|
+
test("G4: a failing telemetry shutdown neither skips other cleanup nor disappears", async () => {
|
|
46
|
+
const output = await runFixture("failing-shutdown");
|
|
47
|
+
|
|
48
|
+
// Spec edge case: the sibling resource is torn down regardless, and it is
|
|
49
|
+
// torn down first — telemetry is released last.
|
|
50
|
+
expect(output).toContain("SIBLING_CLEANUP");
|
|
51
|
+
expect(output).toContain("TELEMETRY_SHUTDOWN");
|
|
52
|
+
expect(output.indexOf("SIBLING_CLEANUP")).toBeLessThan(
|
|
53
|
+
output.indexOf("TELEMETRY_SHUTDOWN"),
|
|
54
|
+
);
|
|
55
|
+
// The failure is reported rather than swallowed silently...
|
|
56
|
+
expect(output).toContain("TELEMETRY_SHUTDOWN_REPORTED");
|
|
57
|
+
// ...but a lost telemetry flush does not turn an otherwise clean shutdown
|
|
58
|
+
// into a failed one: `shutdown()` also rejects when the collector is simply
|
|
59
|
+
// unreachable, and every resource that matters is already released.
|
|
60
|
+
expect(output).toContain("HALT_SETTLED:ok");
|
|
61
|
+
}, 60_000);
|