@cosmicdrift/kumiko-framework 0.306.0 → 0.307.0

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.
Files changed (33) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/sse-broker.test.ts +49 -0
  4. package/src/api/redis-sse-broker.ts +17 -3
  5. package/src/api/request-context.ts +24 -0
  6. package/src/api/sse-broker.ts +29 -11
  7. package/src/changes.json +26 -0
  8. package/src/db/queries/event-consumer.ts +57 -3
  9. package/src/db/queries/event-store.ts +69 -0
  10. package/src/db/tenant-db.ts +43 -5
  11. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  12. package/src/event-store/admin-api.ts +5 -0
  13. package/src/event-store/event-store.ts +16 -7
  14. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  15. package/src/jobs/job-runner.ts +61 -6
  16. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  17. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  18. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  19. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  20. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  21. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  22. package/src/pipeline/dispatch-batch.ts +49 -19
  23. package/src/pipeline/dispatch-stream.ts +7 -3
  24. package/src/pipeline/dispatcher-utils.ts +21 -2
  25. package/src/pipeline/dispatcher.ts +71 -6
  26. package/src/pipeline/event-consumer-state.ts +26 -0
  27. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  28. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  29. package/src/pipeline/event-dispatcher.ts +167 -50
  30. package/src/pipeline/pending-gap-ranges.ts +72 -0
  31. package/src/pipeline/system-hooks.ts +8 -1
  32. package/src/pipeline/write-origin.ts +31 -10
  33. package/src/stack/test-stack.ts +1 -1
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createSystemUser } from "../../engine/system-user";
3
3
  import { InternalError } from "../../errors";
4
+ import { VersionConflictError as EventStoreVersionConflictError } from "../../event-store/errors";
4
5
  import {
5
6
  describeShape,
6
7
  dispatcherSpanAttributes,
@@ -115,6 +116,13 @@ describe("wrapToKumiko", () => {
115
116
  expect(wrapped.code).toBe("internal_error");
116
117
  expect(wrapped.cause).toBeInstanceOf(TypeError);
117
118
  });
119
+
120
+ test("maps an event-store version conflict to a 409 version_conflict", () => {
121
+ const wrapped = wrapToKumiko(new EventStoreVersionConflictError("agg-1", 3));
122
+ expect(wrapped.code).toBe("version_conflict");
123
+ expect(wrapped.httpStatus).toBe(409);
124
+ expect(wrapped.details).toMatchObject({ entityId: "agg-1", expectedVersion: 3 });
125
+ });
118
126
  });
119
127
 
120
128
  describe("extractNestedSpecs", () => {
@@ -0,0 +1,278 @@
1
+ // kumiko_events.id is a bigserial: ids are assigned at INSERT time, before
2
+ // commit. Two concurrent writers can grab ids N and N+1 and commit out of
3
+ // order — if the N+1 transaction commits first, a plain `id > cursor` SELECT
4
+ // sees only N+1, delivers it, and would set cursor=N+1, permanently skipping
5
+ // N once its transaction commits afterwards (`id > cursor` never revisits
6
+ // it). event-dispatcher-delivery.ts's fetchPendingEvents/deliverEvents plus
7
+ // event-dispatcher.ts's processConsumer close this via pending_gaps: ids
8
+ // below the cursor that were invisible on some turn stay tracked (with the
9
+ // xmax that bounds their finality) until they either become visible
10
+ // (delivered) or are proven permanently rolled back (xmin passes that xmax).
11
+ // This mirrors the #443 fenced-rebuild gap, but for the live dispatch path
12
+ // (projection-rebuild.ts's final drain re-checks via a count fence;
13
+ // the live dispatcher has no such fence — it has pending_gaps instead).
14
+
15
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
16
+ import type { DbConnection, DbTx } from "../../db/connection";
17
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
18
+ import { asRawClient } from "../../db/query";
19
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
20
+ import { defineFeature } from "../../engine";
21
+ import { createEventDispatcher, type EventConsumer, type EventDispatcher } from "../../pipeline";
22
+ import {
23
+ resetEventStore,
24
+ setupTestStack,
25
+ type TestStack,
26
+ TestUsers,
27
+ unsafeCreateEntityTable,
28
+ } from "../../stack";
29
+ import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
30
+ import { generateId } from "../../utils";
31
+ import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
32
+
33
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
34
+ entityName: "widget",
35
+ });
36
+
37
+ const feature = defineFeature("commitorder", (r) => {
38
+ r.entity("widget", sharedWidgetEntity);
39
+ });
40
+
41
+ const admin = TestUsers.admin;
42
+ let stack: TestStack;
43
+ let tdb: TenantDb;
44
+
45
+ beforeAll(async () => {
46
+ stack = await setupTestStack({ features: [feature], systemHooks: [] });
47
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
48
+ tdb = createTenantDb(stack.db, admin.tenantId);
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await resetEventStore(stack, ["read_widgets"]);
53
+ });
54
+
55
+ // Normal append path — commits immediately on its own pooled connection,
56
+ // like the other dispatcher integration tests use for their control events.
57
+ async function appendWidget(name: string): Promise<void> {
58
+ await executor.create({ name }, admin, tdb);
59
+ }
60
+
61
+ // executor.create() manages and commits its own transaction internally, so
62
+ // it cannot be used for the low-id writer that must stay open on demand —
63
+ // raw SQL on a caller-held tx (same shape as projection-rebuild's #443
64
+ // test) is the only way to grab an id and defer its commit.
65
+ async function insertWidgetCreatedEvent(tx: DbTx, name: string): Promise<void> {
66
+ await asRawClient(tx).unsafe(
67
+ `INSERT INTO "kumiko_events"
68
+ (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
69
+ VALUES ($1::uuid, 'widget', $2::uuid, 1, 'widget.created', $3::jsonb, '{}'::jsonb, 'test')`,
70
+ [generateId(), admin.tenantId, JSON.stringify({ name })],
71
+ );
72
+ }
73
+
74
+ function buildDispatcher(consumer: EventConsumer): EventDispatcher {
75
+ return createEventDispatcher({
76
+ db: stack.db,
77
+ consumers: [consumer],
78
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
79
+ batchSize: 200,
80
+ pollIntervalMs: 5000,
81
+ });
82
+ }
83
+
84
+ class RollbackSentinel extends Error {}
85
+
86
+ // Grabs an id (like insertWidgetCreatedEvent) but never commits — the row
87
+ // stays permanently invisible. Simulates the "burnt gap" case: a pending
88
+ // entry whose row will never appear, provable only once xmin passes the
89
+ // xmax recorded when the gap was first detected.
90
+ async function insertAndRollBackWidgetEvent(db: DbConnection, name: string): Promise<void> {
91
+ await db
92
+ .begin(async (tx: DbTx) => {
93
+ await insertWidgetCreatedEvent(tx, name);
94
+ throw new RollbackSentinel();
95
+ })
96
+ .catch((e: unknown) => {
97
+ if (!(e instanceof RollbackSentinel)) throw e;
98
+ });
99
+ }
100
+
101
+ async function readPendingGaps(
102
+ db: DbConnection,
103
+ consumerName: string,
104
+ ): Promise<ReadonlyArray<{ from: string; to: string; xmax: string }>> {
105
+ const rows = (await asRawClient(db).unsafe(
106
+ `SELECT "pending_gaps", jsonb_typeof("pending_gaps") AS kind FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
107
+ [consumerName, SHARED_INSTANCE_SENTINEL],
108
+ )) as ReadonlyArray<{
109
+ pending_gaps: ReadonlyArray<{ from: string; to: string; xmax: string }>;
110
+ kind: string;
111
+ }>;
112
+ // A double-encoded write lands as a jsonb string scalar, not an array.
113
+ if (rows[0] && rows[0].kind !== "array") throw new Error(`pending_gaps is jsonb ${rows[0].kind}`);
114
+ return rows[0]?.pending_gaps ?? [];
115
+ }
116
+
117
+ // Jumps the id sequence far ahead before inserting, then leaves it there —
118
+ // simulates a retention prune (or a new consumer starting "beginning" over
119
+ // already-pruned history): a huge, permanent hole below the next cursor.
120
+ async function insertFarAwayWidgetEvent(db: DbConnection, name: string): Promise<bigint> {
121
+ return db.begin(async (tx: DbTx) => {
122
+ const [row] = (await asRawClient(tx).unsafe(
123
+ `SELECT nextval(pg_get_serial_sequence('kumiko_events', 'id')) AS n`,
124
+ )) as ReadonlyArray<{ n: string | bigint }>;
125
+ const jumpedId = BigInt(row?.n ?? 0) + 100_000n;
126
+ await asRawClient(tx).unsafe(
127
+ `SELECT setval(pg_get_serial_sequence('kumiko_events', 'id'), $1)`,
128
+ [jumpedId.toString()],
129
+ );
130
+ await asRawClient(tx).unsafe(
131
+ `INSERT INTO "kumiko_events"
132
+ (id, aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
133
+ VALUES ($1, $2::uuid, 'widget', $3::uuid, 1, 'widget.created', $4::jsonb, '{}'::jsonb, 'test')`,
134
+ [jumpedId.toString(), generateId(), admin.tenantId, JSON.stringify({ name })],
135
+ );
136
+ return jumpedId;
137
+ });
138
+ }
139
+
140
+ describe("event-dispatcher — commit order vs. id order", () => {
141
+ test("an event whose transaction commits late is still delivered once it commits", async () => {
142
+ const seen: Array<{ id: string; name: string }> = [];
143
+ const consumer: EventConsumer = {
144
+ name: "commitorder:consumer",
145
+ handler: async (event) => {
146
+ seen.push({ id: event.id, name: String(event.payload["name"]) });
147
+ },
148
+ };
149
+ const dispatcher = buildDispatcher(consumer);
150
+ await dispatcher.ensureRegistered();
151
+
152
+ const db = stack.db as DbConnection;
153
+
154
+ // Tx A grabs the LOW id first but holds its transaction open —
155
+ // uncommitted, so it stays invisible to fetchPendingEvents' plain SELECT.
156
+ let releaseA!: () => void;
157
+ const aGate = new Promise<void>((resolve) => {
158
+ releaseA = resolve;
159
+ });
160
+ let markAInserted!: () => void;
161
+ const aInserted = new Promise<void>((resolve) => {
162
+ markAInserted = resolve;
163
+ });
164
+ const aDone = db.begin(async (tx: DbTx) => {
165
+ await insertWidgetCreatedEvent(tx, "A");
166
+ markAInserted();
167
+ await aGate;
168
+ });
169
+ await aInserted;
170
+
171
+ // Tx B commits AFTER A grabbed its id, so B's id is HIGHER, and it's
172
+ // the only one visible when the dispatcher first polls.
173
+ await appendWidget("B");
174
+
175
+ const firstPass = await dispatcher.runOnce();
176
+ expect(firstPass.processed).toBe(1);
177
+ expect(seen).toEqual([expect.objectContaining({ name: "B" })]);
178
+
179
+ // A commits now — its lower id becomes visible, but the cursor already
180
+ // advanced past B's higher id. `WHERE id > cursor` alone never revisits
181
+ // it: the gap this test pins.
182
+ releaseA();
183
+ await aDone;
184
+
185
+ const secondPass = await dispatcher.runOnce();
186
+ expect(secondPass.processed).toBe(1);
187
+ // Delivery order is events.id order, not append order: A's lower id is
188
+ // delivered as a resolved pending gap, ahead of any id above the cursor.
189
+ expect(seen.map((e) => e.name)).toEqual(["B", "A"]);
190
+ expect(await readPendingGaps(db, consumer.name)).toEqual([]);
191
+ });
192
+
193
+ test("a rolled-back low-id write is proven burnt and never blocks later events", async () => {
194
+ const seen: string[] = [];
195
+ const consumer: EventConsumer = {
196
+ name: "commitorder:burnt-gap-consumer",
197
+ handler: async (event) => {
198
+ seen.push(String(event.payload["name"]));
199
+ },
200
+ };
201
+ const dispatcher = buildDispatcher(consumer);
202
+ await dispatcher.ensureRegistered();
203
+
204
+ const db = stack.db as DbConnection;
205
+
206
+ // Reserves a low id, then rolls back — that id's row will never exist.
207
+ await insertAndRollBackWidgetEvent(db, "burnt");
208
+ await appendWidget("real");
209
+
210
+ // First pass: "real" (the only visible row past the cursor) is
211
+ // delivered; the rolled-back id below it is recorded as a pending gap.
212
+ const firstPass = await dispatcher.runOnce();
213
+ expect(firstPass.processed).toBe(1);
214
+ expect(seen).toEqual(["real"]);
215
+ expect(await readPendingGaps(db, consumer.name)).not.toEqual([]);
216
+
217
+ let pendingGaps = await readPendingGaps(db, consumer.name);
218
+ // xmin is cluster-wide, so a parallel test's open transaction can hold it
219
+ // back briefly; wait for the condition, not a fixed number of passes.
220
+ await waitFor(
221
+ async () => {
222
+ await dispatcher.runOnce();
223
+ pendingGaps = await readPendingGaps(db, consumer.name);
224
+ expect(pendingGaps).toEqual([]);
225
+ },
226
+ { delays: [20, 100, 500, 1000, 3000] },
227
+ );
228
+
229
+ expect(pendingGaps).toEqual([]);
230
+ // The burnt id was never delivered — only "real" ever was.
231
+ expect(seen).toEqual(["real"]);
232
+ });
233
+
234
+ test("a huge id jump stays O(1) pending_gaps entries, not one per missing id", async () => {
235
+ const seen: string[] = [];
236
+ const consumer: EventConsumer = {
237
+ name: "commitorder:huge-gap-consumer",
238
+ handler: async (event) => {
239
+ seen.push(String(event.payload["name"]));
240
+ },
241
+ };
242
+ const dispatcher = buildDispatcher(consumer);
243
+ await dispatcher.ensureRegistered();
244
+
245
+ const db = stack.db as DbConnection;
246
+
247
+ await insertFarAwayWidgetEvent(db, "faraway");
248
+
249
+ const firstPass = await dispatcher.runOnce();
250
+ expect(firstPass.processed).toBe(1);
251
+ expect(seen).toEqual(["faraway"]);
252
+
253
+ // One contiguous range covers the whole skipped id space — not ~100000
254
+ // entries, one per missing id.
255
+ const gapsAfterJump = await readPendingGaps(db, consumer.name);
256
+ expect(gapsAfterJump.length).toBeLessThan(10);
257
+
258
+ // The sequence jumped past the gap, so a normal append never re-enters
259
+ // it — this event's id sits above "faraway", not inside the hole.
260
+ await appendWidget("after-jump");
261
+ const secondPass = await dispatcher.runOnce();
262
+ expect(secondPass.processed).toBe(1);
263
+ expect(seen).toEqual(["faraway", "after-jump"]);
264
+
265
+ let pendingGaps = await readPendingGaps(db, consumer.name);
266
+ // xmin is cluster-wide, so a parallel test's open transaction can hold it
267
+ // back briefly; wait for the condition, not a fixed number of passes.
268
+ await waitFor(
269
+ async () => {
270
+ await dispatcher.runOnce();
271
+ pendingGaps = await readPendingGaps(db, consumer.name);
272
+ expect(pendingGaps).toEqual([]);
273
+ },
274
+ { delays: [20, 100, 500, 1000, 3000] },
275
+ );
276
+ expect(pendingGaps).toEqual([]);
277
+ });
278
+ });
@@ -51,6 +51,7 @@ function stubState(): ConsumerStateRow {
51
51
  status: "idle",
52
52
  attempts: 0,
53
53
  rearmCount: 0,
54
+ pendingGaps: [],
54
55
  lastError: null,
55
56
  updatedAt: Temporal.Now.instant(),
56
57
  };
@@ -3,7 +3,7 @@
3
3
  // 1. buildServer returns a live eventDispatcher when consumers are wired.
4
4
  // 2. dispatcher.start() delivers without explicit runOnce; a handler
5
5
  // slower than pollIntervalMs doesn't queue overlapping passes
6
- // (passInFlight serialisation).
6
+ // (per-consumer in-flight guard).
7
7
  // 3. kumiko_event_consumer_lag_events is emitted per pass.
8
8
  //
9
9
  // History: this file originally also tested r.postEvent's tenant-scoped
@@ -46,7 +46,7 @@ type Observation = {
46
46
  };
47
47
  let observations: Observation[] = [];
48
48
  // A handler that sleeps a controllable amount of time. Drives the
49
- // slow-handler / passInFlight test.
49
+ // slow-handler / in-flight guard test.
50
50
  let slowHandlerDelayMs = 0;
51
51
  let slowHandlerInvocations: Array<{ start: number; end: number }> = [];
52
52
 
@@ -126,10 +126,10 @@ describe("E.1 — .start() lifecycle + slow handler", () => {
126
126
  }
127
127
  });
128
128
 
129
- test("slow handler doesn't queue overlapping passes (passInFlight serialises)", async () => {
130
- // 250ms handler >> 50ms pollIntervalMs — without passInFlight, the
129
+ test("slow handler doesn't queue overlapping passes (per-consumer in-flight guard)", async () => {
130
+ // 250ms handler >> 50ms pollIntervalMs — without the in-flight guard, the
131
131
  // setInterval would start a new pass every 50ms on top of the one in
132
- // flight. passInFlight must coalesce them. We verify: no two passes
132
+ // flight. The guard must coalesce them. We verify: no two passes
133
133
  // ran concurrently.
134
134
  slowHandlerDelayMs = 250;
135
135
 
@@ -143,7 +143,7 @@ describe("E.1 — .start() lifecycle + slow handler", () => {
143
143
  await waitFor(() => slowHandlerInvocations.length >= 3, 5000);
144
144
 
145
145
  // Check: no invocation overlapped with the next — every pass
146
- // finished before the following one started. passInFlight does
146
+ // finished before the following one started. The guard does
147
147
  // its job.
148
148
  const sorted = [...slowHandlerInvocations].sort((a, b) => a.start - b.start);
149
149
  for (let i = 1; i < sorted.length; i++) {
@@ -0,0 +1,126 @@
1
+ // A consumer stuck in a slow handler (Meili index+waitTask, for example) must
2
+ // not hold back delivery to the other consumers.
3
+
4
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
5
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
6
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
7
+ import { defineFeature } from "../../engine";
8
+ import {
9
+ createEventDispatcher,
10
+ type EventConsumer,
11
+ type EventDispatcher,
12
+ getConsumerState,
13
+ } from "../../pipeline";
14
+ import {
15
+ resetEventStore,
16
+ setupTestStack,
17
+ type TestStack,
18
+ TestUsers,
19
+ unsafeCreateEntityTable,
20
+ } from "../../stack";
21
+ import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
22
+
23
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
24
+ entityName: "widget",
25
+ });
26
+
27
+ const feature = defineFeature("perconsumerturns", (r) => {
28
+ r.entity("widget", sharedWidgetEntity);
29
+ });
30
+
31
+ const admin = TestUsers.admin;
32
+ let stack: TestStack;
33
+ let tdb: TenantDb;
34
+
35
+ beforeAll(async () => {
36
+ stack = await setupTestStack({ features: [feature], systemHooks: [] });
37
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
38
+ tdb = createTenantDb(stack.db, admin.tenantId);
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await resetEventStore(stack, ["read_widgets"]);
43
+ });
44
+
45
+ async function appendWidget(name: string): Promise<void> {
46
+ await executor.create({ name }, admin, tdb);
47
+ }
48
+
49
+ function buildDispatcher(consumers: readonly EventConsumer[]): EventDispatcher {
50
+ return createEventDispatcher({
51
+ db: stack.db,
52
+ consumers,
53
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
54
+ batchSize: 200,
55
+ pollIntervalMs: 30,
56
+ });
57
+ }
58
+
59
+ describe("event-dispatcher — per-consumer turns", () => {
60
+ test("a blocked consumer does not delay another consumer's delivery", async () => {
61
+ const blockingName = "perconsumerturns:blocking";
62
+ const fastName = "perconsumerturns:fast";
63
+
64
+ let aEntered = false;
65
+ let releaseA: (() => void) | undefined;
66
+ const aGate = new Promise<void>((resolve) => {
67
+ releaseA = resolve;
68
+ });
69
+ const fastSeen: string[] = [];
70
+
71
+ // Order matters: A first. The old serial doPass() iterated consumers in
72
+ // array order, so with the blocking consumer first, a regression back
73
+ // to that code blocks the fast one too — this test must be red on it.
74
+ const blockingConsumer: EventConsumer = {
75
+ name: blockingName,
76
+ handler: async () => {
77
+ aEntered = true;
78
+ await aGate;
79
+ },
80
+ };
81
+ const fastConsumer: EventConsumer = {
82
+ name: fastName,
83
+ handler: async (event) => {
84
+ fastSeen.push(String(event.payload["name"]));
85
+ },
86
+ };
87
+
88
+ const dispatcher = buildDispatcher([blockingConsumer, fastConsumer]);
89
+ await dispatcher.start();
90
+ try {
91
+ await appendWidget("one");
92
+
93
+ await waitFor(() => aEntered === true, { delays: [20, 50, 100, 250] });
94
+
95
+ // The fast consumer must advance while the blocking one is still stuck
96
+ // inside its handler — proves the two no longer share a pass barrier.
97
+ await waitFor(
98
+ async () => {
99
+ const fastState = await getConsumerState(stack.db, fastName);
100
+ return fastState?.lastProcessedEventId === 1n;
101
+ },
102
+ { delays: [20, 50, 100, 250, 500] },
103
+ );
104
+ expect(fastSeen).toEqual(["one"]);
105
+
106
+ const blockingStateWhileStuck = await getConsumerState(stack.db, blockingName);
107
+ expect(blockingStateWhileStuck?.lastProcessedEventId).toBe(0n);
108
+
109
+ releaseA?.();
110
+
111
+ await waitFor(
112
+ async () => {
113
+ const blockingState = await getConsumerState(stack.db, blockingName);
114
+ return blockingState?.lastProcessedEventId === 1n;
115
+ },
116
+ { delays: [20, 50, 100, 250, 500] },
117
+ );
118
+ } finally {
119
+ // Release before stop() unconditionally — stop() drains in-flight
120
+ // turns, which would hang forever if the blocking handler never
121
+ // settles (e.g. the assertions above threw before releaseA() ran).
122
+ releaseA?.();
123
+ await dispatcher.stop();
124
+ }
125
+ });
126
+ });