@rindle/api-server 0.9.0 → 0.10.1

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/src/streams.ts CHANGED
@@ -1,4 +1,4 @@
1
- // LM stream checkpointing — the two-plane response path (designs/LM-STREAM-CHECKPOINT-DESIGN.md).
1
+ // LM stream checkpointing — the two-plane response path (designs-implemented/LM-STREAM-CHECKPOINT-DESIGN.md).
2
2
  //
3
3
  // A model response arrives as hundreds of tiny deltas per second. Every one wants to be on a screen
4
4
  // immediately; none wants to be a durable write. So the response runs on TWO planes sharing ONE
@@ -177,6 +177,40 @@ export interface AuthorizeStreamInput<User> {
177
177
  request?: unknown;
178
178
  }
179
179
 
180
+ /**
181
+ * Optional cross-process transport for the LIVE plane
182
+ * (designs-implemented/LM-STREAM-RELAY-DESIGN.md). Both methods are independently optional; which
183
+ * ones you implement is which topology you built — an addressing adapter (a Durable Object named by
184
+ * `streamId`, `fly-replay`) implements only `attach`; a broadcast adapter (Redis pub/sub, NATS)
185
+ * mirrors with `publish` and subscribes with `attach`; a log adapter (Redis Streams, Kafka) appends
186
+ * and replays. Never consulted for the durable plane — checkpoints are unaffected by any of this.
187
+ *
188
+ * The plane does not trust what `attach` yields: frames are run through the conform pass
189
+ * ({@link StreamRelayConform}) and any contract violation downgrades the subscription to `stale`,
190
+ * which already means "you are on the durable plane now". A broken relay costs a reader smooth
191
+ * tokens, never corrupted text — and can never reach the producer.
192
+ */
193
+ export interface StreamRelay {
194
+ /** Producer side: every frame this process's producer fans out (`chunk`, `durable`, and the
195
+ * terminal `end`), mirrored outward. MUST NOT block; a throw or rejected promise is caught and
196
+ * routed to {@link RindleStreamOptions.onRelayError} — a relay outage may cost the live leg,
197
+ * never the generation. Returned promises are observed but never awaited. */
198
+ publish?(streamId: string, frame: StreamFrame): void | PromiseLike<void>;
199
+ /** Subscriber side: this process is not hosting `streamId`. Return a frame source, or `undefined`
200
+ * for `absent` — exactly the no-relay answer. Consulted only AFTER `authorize` has passed, and
201
+ * only on a live-plane miss (a local stream always wins). The plane closes the source
202
+ * (`return()`) when the reader disconnects. An adapter that cannot serve `from` (pub/sub has no
203
+ * history) yields `stale` and stops — the reader converges on the durable plane (§5). */
204
+ attach?(streamId: string, from: number): Promise<AsyncIterable<StreamFrame> | undefined>;
205
+ }
206
+
207
+ export interface StreamRelayErrorInfo {
208
+ streamId: string;
209
+ /** Where it failed: mirroring a frame out (`publish`), dialing the adapter (`attach`), or
210
+ * consuming/conforming its frames (`frames`). */
211
+ phase: "publish" | "attach" | "frames";
212
+ }
213
+
180
214
  export interface RindleStreamOptions<User> {
181
215
  /** Where checkpoints land: the app's tables (the default path) or a raw `commit` callback. */
182
216
  checkpoint: StreamCheckpointTarget;
@@ -199,11 +233,26 @@ export interface RindleStreamOptions<User> {
199
233
  retainChars?: number;
200
234
  /** How long a sealed stream stays joinable before eviction. Default 30s. */
201
235
  lingerMs?: number;
202
- /** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024. */
236
+ /** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024.
237
+ * Relayed readers reuse the same bound: a slow reader on a relayed stream costs itself the live
238
+ * leg exactly as a local one does. */
203
239
  maxQueuedFrames?: number;
204
240
  /** A checkpoint that exhausted its retries. The stream keeps streaming — this is a durability
205
241
  * stall, not a stream stall — so the error must not vanish. Absent ⇒ `console.error`. */
206
242
  onCheckpointError?: (err: unknown, info: { streamId: string; from: number; seq: number }) => void;
243
+ /** Cross-process transport for the live plane ({@link StreamRelay}). Without one, a subscriber
244
+ * that lands on a process not hosting its stream gets `absent` and reads the durable plane at
245
+ * checkpoint granularity — correct, just chunky. */
246
+ relay?: StreamRelay;
247
+ /** Bound on `relay.attach`: a hung adapter yields `absent`, not a hung HTTP request. An
248
+ * addressing adapter MAY deliberately spend this window waiting out a subscribe that races its
249
+ * own kick. Default 2000. */
250
+ relayAttachTimeoutMs?: number;
251
+ /** A diagnostic, never a control path: relay failures (a throwing or rejecting `publish`, a failed
252
+ * or timed-out `attach`, a conform violation in the frames) land here, wrapped so a throwing hook
253
+ * cannot reach the plane. The reader-facing outcome is always the same legal `absent`/`stale`.
254
+ * Absent ⇒ `console.error`. */
255
+ onRelayError?: (err: unknown, info: StreamRelayErrorInfo) => void;
207
256
  }
208
257
 
209
258
  // ------------------------------------------------------------------------------- producer handle
@@ -271,6 +320,7 @@ const DEFAULT_CHECKPOINT_RETRIES = 3;
271
320
  const DEFAULT_RETAIN_CHARS = 64 * 1024;
272
321
  const DEFAULT_LINGER_MS = 30_000;
273
322
  const DEFAULT_MAX_QUEUED_FRAMES = 1024;
323
+ const DEFAULT_RELAY_ATTACH_TIMEOUT_MS = 2000;
274
324
  /** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
275
325
  * authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
276
326
  const RETRY_BACKOFF_MS = 50;
@@ -565,6 +615,156 @@ class Subscriber {
565
615
  }
566
616
  }
567
617
 
618
+ // ------------------------------------------------------------------------------- the relay conform pass
619
+
620
+ /**
621
+ * One frame source arriving over a relay, conformed to the CP §4 contract
622
+ * (designs-implemented/LM-STREAM-RELAY-DESIGN.md §4).
623
+ *
624
+ * An adapter is app code talking to Redis or a socket, and its frames feed `spliceStreamText` on a
625
+ * browser — so the plane does not trust them. This pass enforces the frame invariants against the
626
+ * prefix actually delivered and downgrades EVERY violation to a legal `stale` and nothing else:
627
+ * `stale` already means "you are on the durable plane now, the store is the whole truth", so a
628
+ * broken relay costs a reader smooth tokens, never corrupted text — and cannot wedge a producer.
629
+ *
630
+ * Replayed spans (a reconnecting adapter re-delivering what it already sent) are ABSORBED rather
631
+ * than punished — deduping against the delivered prefix is what makes reconnect-replay safe without
632
+ * every adapter hand-rolling it. Spans that overlap the prefix but extend past it pass through
633
+ * whole: the client splices at the frame's own offset, so an exact overlap re-covers and appends.
634
+ *
635
+ * Pure state, no I/O, no plane: `feed` maps one incoming frame to 0-2 outgoing frames (a missing
636
+ * `open` is synthesized at the join offset); `end`/`fail` close out a source that finished or threw
637
+ * without a terminal. After a terminal, every method returns `[]`.
638
+ */
639
+ export class StreamRelayConform {
640
+ private readonly streamId: string;
641
+ /** The requested join offset — the synthesized `open`'s position, and where the prefix starts. */
642
+ private readonly from: number;
643
+ private readonly onViolation: ((reason: string) => void) | undefined;
644
+ /** End of the delivered prefix. */
645
+ private pos: number;
646
+ private lastDurable = 0;
647
+ private opened = false;
648
+ private done = false;
649
+
650
+ constructor(streamId: string, from: number, onViolation?: (reason: string) => void) {
651
+ this.streamId = streamId;
652
+ this.from = from;
653
+ this.pos = from;
654
+ this.onViolation = onViolation;
655
+ }
656
+
657
+ feed(frame: StreamFrame): StreamFrame[] {
658
+ if (this.done) return [];
659
+ switch (frame.type) {
660
+ case "open": {
661
+ if (this.opened) return []; // a reconnecting adapter's second open: absorbed
662
+ if (
663
+ frame.streamId !== this.streamId ||
664
+ !Number.isInteger(frame.from) ||
665
+ !Number.isInteger(frame.seq) ||
666
+ !Number.isInteger(frame.durableSeq) ||
667
+ frame.from < 0 ||
668
+ frame.seq < frame.from
669
+ ) {
670
+ return this.violate(`relay open for ${JSON.stringify(frame.streamId)} at ${frame.from} is malformed`);
671
+ }
672
+ // An open PAST the requested offset is the adapter saying it cannot serve `from` (pub/sub
673
+ // has no history, §5): delivering it would leave a hole in the middle of the response, so
674
+ // the honest answer is the durable plane.
675
+ if (frame.from > this.from) {
676
+ return this.violate(`relay open at ${frame.from} cannot serve the requested ${this.from}`);
677
+ }
678
+ this.opened = true;
679
+ this.pos = frame.from;
680
+ return [frame];
681
+ }
682
+ case "chunk": {
683
+ if (
684
+ !Number.isInteger(frame.from) ||
685
+ !Number.isInteger(frame.seq) ||
686
+ frame.from < 0 ||
687
+ typeof frame.text !== "string" ||
688
+ frame.text.length !== frame.seq - frame.from
689
+ ) {
690
+ return this.violate(`relay chunk ${frame.from}→${frame.seq} does not span exactly its offsets`);
691
+ }
692
+ if (frame.from > this.pos) {
693
+ return this.violate(`relay chunk at ${frame.from} leaves a gap after ${this.pos}`);
694
+ }
695
+ if (frame.seq <= this.pos) return []; // entirely within the delivered prefix (a replay): absorbed
696
+ const out = this.opened ? [] : [this.synthOpen()];
697
+ this.pos = frame.seq;
698
+ out.push(frame);
699
+ return out;
700
+ }
701
+ case "durable": {
702
+ // Purely informational to a reader (the hook ignores it; SSE uses it as a resume id), so a
703
+ // claim that rewinds — or outruns what this subscription has SEEN produced, which P forbids
704
+ // — is dropped rather than downgraded.
705
+ if (!Number.isInteger(frame.seq) || frame.seq < this.lastDurable || frame.seq > this.pos) return [];
706
+ const out = this.opened ? [] : [this.synthOpen()];
707
+ this.lastDurable = frame.seq;
708
+ out.push(frame);
709
+ return out;
710
+ }
711
+ case "end": {
712
+ if (!Number.isInteger(frame.seq) || frame.seq < 0) {
713
+ return this.violate(`relay end at ${String(frame.seq)} is malformed`);
714
+ }
715
+ // An `end` whose durable length outruns the delivered prefix means the adapter LOST text
716
+ // (durable never exceeds produced), so the reader is short and must not be told it saw
717
+ // everything. Downgrading keeps `end` meaning the same thing relayed as local: the whole
718
+ // produced text arrived.
719
+ if (frame.seq > this.pos) {
720
+ return this.violate(`relay end at ${frame.seq} outruns the ${this.pos} characters delivered`);
721
+ }
722
+ this.done = true;
723
+ const out = this.opened ? [] : [this.synthOpen()];
724
+ out.push(frame);
725
+ return out;
726
+ }
727
+ case "stale":
728
+ case "absent": {
729
+ // Legal bare — the local plane's own floor/eviction answers carry no `open` either.
730
+ this.done = true;
731
+ return [frame];
732
+ }
733
+ }
734
+ }
735
+
736
+ /** The source completed without a terminal (a truncated relay): the reader falls back. */
737
+ end(): StreamFrame[] {
738
+ if (this.done) return [];
739
+ this.onViolation?.("relay source ended without a terminal frame");
740
+ return this.terminate();
741
+ }
742
+
743
+ /** The source threw mid-iteration, or the plane is dropping a reader that stopped draining:
744
+ * a bare `stale` at the delivered position. */
745
+ fail(): StreamFrame[] {
746
+ return this.done ? [] : this.terminate();
747
+ }
748
+
749
+ /** A synthesized join, for an adapter that (correctly, in broadcast mode) never mirrors the
750
+ * per-subscriber `open`: positioned at the requested offset, which the reader asked from because
751
+ * its durable view already holds it. */
752
+ private synthOpen(): StreamFrame {
753
+ this.opened = true;
754
+ return { type: "open", streamId: this.streamId, from: this.from, seq: this.from, durableSeq: this.from, ended: false };
755
+ }
756
+
757
+ private terminate(): StreamFrame[] {
758
+ this.done = true;
759
+ return [{ type: "stale", floorSeq: this.pos, durableSeq: this.lastDurable }];
760
+ }
761
+
762
+ private violate(reason: string): StreamFrame[] {
763
+ this.onViolation?.(reason);
764
+ return this.terminate();
765
+ }
766
+ }
767
+
568
768
  // ------------------------------------------------------------------------------- the live stream
569
769
 
570
770
  interface Waiter {
@@ -812,6 +1012,8 @@ class LiveStream<User> {
812
1012
  status: seal.status,
813
1013
  ...(seal.error !== undefined ? { error: seal.error } : {}),
814
1014
  };
1015
+ // The terminal reaches the relay too (it bypasses `fanout` locally only to bypass the cap).
1016
+ this.plane.publishRelay(this.streamId, frame);
815
1017
  for (const sub of [...this.subs]) sub.finish(frame);
816
1018
  this.plane.retire(this.streamId);
817
1019
  if (this.sealError) this.sealed?.reject(this.sealError);
@@ -852,6 +1054,9 @@ class LiveStream<User> {
852
1054
  // ---- subscriber side
853
1055
 
854
1056
  private fanout(frame: StreamFrame): void {
1057
+ // Every frame local subscribers get, the relay gets — including when nobody local is attached
1058
+ // (a broadcast relay's whole point). Wrapped so an outage costs the live leg, never this stream.
1059
+ this.plane.publishRelay(this.streamId, frame);
855
1060
  for (const sub of [...this.subs]) {
856
1061
  if (!sub.offer(frame)) {
857
1062
  // Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
@@ -921,8 +1126,11 @@ export class StreamPlane<User> {
921
1126
  readonly retainChars: number;
922
1127
  readonly lingerMs: number;
923
1128
  readonly maxQueuedFrames: number;
1129
+ readonly relayAttachTimeoutMs: number;
924
1130
 
925
1131
  private readonly live = new Map<string, LiveStream<User>>();
1132
+ /** Live relayed subscriptions, so teardown ({@link closeSync}) releases their drivers too. */
1133
+ private readonly relayed = new Set<Subscriber>();
926
1134
  private readonly opts: RindleStreamOptions<User>;
927
1135
  private readonly sink: StreamSqlSink | undefined;
928
1136
  private readonly mapped: MappedTableSql | undefined;
@@ -939,7 +1147,15 @@ export class StreamPlane<User> {
939
1147
  this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
940
1148
  this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
941
1149
  this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
1150
+ this.relayAttachTimeoutMs = opts.relayAttachTimeoutMs ?? DEFAULT_RELAY_ATTACH_TIMEOUT_MS;
942
1151
  this.openToken = opts.hostId ?? randomOpenToken();
1152
+ // Relay misconfiguration is refused loudly (the room-profile rule), never ignored.
1153
+ if (opts.relay !== undefined && opts.relay.publish === undefined && opts.relay.attach === undefined) {
1154
+ throw new TypeError("streams.relay implements neither publish nor attach — which topology is this? (LM-STREAM-RELAY §3.1)");
1155
+ }
1156
+ if (opts.relay === undefined && (opts.relayAttachTimeoutMs !== undefined || opts.onRelayError !== undefined)) {
1157
+ throw new TypeError("streams.relayAttachTimeoutMs/onRelayError do nothing without streams.relay");
1158
+ }
943
1159
  if ("tables" in opts.checkpoint) {
944
1160
  if (!sink) throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
945
1161
  this.tables = opts.checkpoint.tables;
@@ -1076,10 +1292,164 @@ export class StreamPlane<User> {
1076
1292
  request: input.request,
1077
1293
  });
1078
1294
  if (verdict === false) throw new StreamForbidden(input.streamId);
1079
- if (!stream) return oneFrame(input.streamId, { type: "absent" });
1295
+ if (!stream) {
1296
+ // The relay is consulted only on a live-plane miss, and only after `authorize` passed — a
1297
+ // denial must not become an existence probe against the relay either. A local stream always
1298
+ // wins: the producer's own readers keep the lowest-latency path.
1299
+ const relayed = await this.attachRelay(input.streamId, from);
1300
+ return relayed ?? oneFrame(input.streamId, { type: "absent" });
1301
+ }
1080
1302
  return stream.subscribe(from);
1081
1303
  }
1082
1304
 
1305
+ /** The subscribe-miss leg (LM-STREAM-RELAY §3): ask the app's relay for the frames of a stream
1306
+ * this process is not hosting. `undefined` — no relay, no `attach`, the adapter declined, timed
1307
+ * out, or threw — is `absent`, exactly today's answer. */
1308
+ private async attachRelay(streamId: string, from: number): Promise<StreamSubscription | undefined> {
1309
+ const relay = this.opts.relay;
1310
+ if (relay?.attach === undefined) return undefined;
1311
+ // Floored like the local join, but clamped only below: the producer's length is not known here.
1312
+ const at = Number.isFinite(from) ? Math.max(Math.floor(from), 0) : 0;
1313
+ let source: AsyncIterable<StreamFrame> | undefined;
1314
+ try {
1315
+ source = await this.boundedAttach(relay, streamId, at);
1316
+ } catch (err) {
1317
+ this.reportRelayError(err, { streamId, phase: "attach" });
1318
+ return undefined;
1319
+ }
1320
+ if (source === undefined) return undefined;
1321
+ return this.relaySubscription(streamId, at, source);
1322
+ }
1323
+
1324
+ /** `attach`, bounded by {@link RindleStreamOptions.relayAttachTimeoutMs}: a hung adapter yields
1325
+ * `absent`, not a hung HTTP request. A source that resolves after the deadline is closed, not
1326
+ * leaked. */
1327
+ private boundedAttach(
1328
+ relay: StreamRelay,
1329
+ streamId: string,
1330
+ from: number,
1331
+ ): Promise<AsyncIterable<StreamFrame> | undefined> {
1332
+ // `async` wrapping so a synchronously-throwing adapter is an attach failure, not a plane throw.
1333
+ const attempt = (async () => relay.attach!(streamId, from))();
1334
+ return new Promise((resolve, reject) => {
1335
+ let late = false;
1336
+ const t = timer(() => {
1337
+ late = true;
1338
+ reject(new Error(`stream ${streamId}: relay.attach timed out after ${this.relayAttachTimeoutMs}ms`));
1339
+ }, this.relayAttachTimeoutMs);
1340
+ attempt.then(
1341
+ (source) => {
1342
+ clearTimeout(t);
1343
+ if (!late) return resolve(source);
1344
+ closeFrameSource(source); // too late to serve the reader; don't leak the channel
1345
+ },
1346
+ (err) => {
1347
+ clearTimeout(t);
1348
+ if (!late) reject(err);
1349
+ },
1350
+ );
1351
+ });
1352
+ }
1353
+
1354
+ /** Wrap an adapter's frame source as a plane subscription: conform every frame (LM-STREAM-RELAY
1355
+ * §4), bound the reader with the same queue cap as a local one (§7), and tear the adapter down
1356
+ * when either side lets go. The driver never throws into the plane: adapter failures become one
1357
+ * `stale`. */
1358
+ private relaySubscription(streamId: string, from: number, source: AsyncIterable<StreamFrame>): StreamSubscription {
1359
+ const conform = new StreamRelayConform(streamId, from, (reason) =>
1360
+ this.reportRelayError(new Error(reason), { streamId, phase: "frames" }),
1361
+ );
1362
+ let closed = false;
1363
+ let signalClose!: () => void;
1364
+ const closedP = new Promise<void>((resolve) => (signalClose = resolve));
1365
+ const closedTag = closedP.then(() => "closed" as const);
1366
+ const release = (): void => {
1367
+ if (!closed) {
1368
+ closed = true;
1369
+ signalClose();
1370
+ }
1371
+ };
1372
+ const sub = new Subscriber(this.maxQueuedFrames, (s) => {
1373
+ this.relayed.delete(s);
1374
+ release();
1375
+ });
1376
+ this.relayed.add(sub);
1377
+ let it: AsyncIterator<StreamFrame> | undefined;
1378
+ /** @returns false once the subscription finished (terminal delivered, or the reader dropped). */
1379
+ const deliver = (frames: StreamFrame[]): boolean => {
1380
+ for (const frame of frames) {
1381
+ if (frame.type === "end" || frame.type === "stale" || frame.type === "absent") {
1382
+ sub.finish(frame);
1383
+ return false;
1384
+ }
1385
+ if (!sub.offer(frame)) {
1386
+ // The same bound as a local reader: a relayed subscriber that stops draining costs
1387
+ // itself the live leg, never unbounded memory.
1388
+ const [stale] = conform.fail();
1389
+ if (stale) sub.finish(stale);
1390
+ return false;
1391
+ }
1392
+ }
1393
+ return true;
1394
+ };
1395
+ void (async () => {
1396
+ try {
1397
+ // Iterator construction is adapter code too: keep a throwing factory inside the same
1398
+ // stale/report/cleanup boundary as a throwing `next()`.
1399
+ it = source[Symbol.asyncIterator]();
1400
+ for (;;) {
1401
+ // Raced rather than awaited bare: a reader disconnect must release this driver even when
1402
+ // the adapter never yields another frame (its own `return()` may be queued behind the
1403
+ // pending `next()` forever).
1404
+ const res = await Promise.race([it.next(), closedTag]);
1405
+ if (res === "closed") return;
1406
+ if (res.done) {
1407
+ deliver(conform.end());
1408
+ return;
1409
+ }
1410
+ if (!deliver(conform.feed(res.value))) return;
1411
+ }
1412
+ } catch (err) {
1413
+ this.reportRelayError(err, { streamId, phase: "frames" });
1414
+ deliver(conform.fail());
1415
+ } finally {
1416
+ release();
1417
+ // If construction itself threw there is no iterator to return, and asking the source to
1418
+ // construct a second one during cleanup could repeat side effects or throw again.
1419
+ closeFrameSource(undefined, it);
1420
+ }
1421
+ })();
1422
+ return { streamId, frames: sub.frames(), close: () => sub.close() };
1423
+ }
1424
+
1425
+ /** Mirror one producer frame outward (LM-STREAM-RELAY §3). Never blocks or breaks the producer:
1426
+ * a throw or rejected promise is reported and swallowed — a relay outage may cost relayed
1427
+ * readers the live leg, never the generation or its checkpoints. */
1428
+ publishRelay(streamId: string, frame: StreamFrame): void {
1429
+ const relay = this.opts.relay;
1430
+ if (relay?.publish === undefined) return;
1431
+ try {
1432
+ const published = relay.publish(streamId, frame);
1433
+ if (published !== undefined) {
1434
+ void Promise.resolve(published).catch((err) =>
1435
+ this.reportRelayError(err, { streamId, phase: "publish" }),
1436
+ );
1437
+ }
1438
+ } catch (err) {
1439
+ this.reportRelayError(err, { streamId, phase: "publish" });
1440
+ }
1441
+ }
1442
+
1443
+ reportRelayError(err: unknown, info: StreamRelayErrorInfo): void {
1444
+ // Same discipline as reportCheckpointError: a diagnostic must never take its caller down.
1445
+ try {
1446
+ if (this.opts.onRelayError) this.opts.onRelayError(err, info);
1447
+ else console.error(`[rindle api-server] stream ${info.streamId}: relay ${info.phase} failed:`, err);
1448
+ } catch (hookErr) {
1449
+ console.error(`[rindle api-server] stream ${info.streamId}: onRelayError itself threw:`, hookErr);
1450
+ }
1451
+ }
1452
+
1083
1453
  /** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
1084
1454
  * graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
1085
1455
  * than claiming completion (§5). Wire it to SIGTERM. */
@@ -1091,6 +1461,8 @@ export class StreamPlane<User> {
1091
1461
  closeSync(): void {
1092
1462
  for (const s of this.live.values()) s.detachAll();
1093
1463
  this.live.clear();
1464
+ for (const s of [...this.relayed]) s.close();
1465
+ this.relayed.clear();
1094
1466
  }
1095
1467
 
1096
1468
  /** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
@@ -1240,6 +1612,18 @@ export class StreamForbidden extends Error {
1240
1612
  }
1241
1613
  }
1242
1614
 
1615
+ /** Best-effort adapter teardown (the `for await` discipline, by hand): never awaited into the
1616
+ * plane — a hung `return()` must not hold anything — and a synchronously-throwing one is the
1617
+ * adapter's bug, not the plane's problem. */
1618
+ function closeFrameSource(source: AsyncIterable<StreamFrame> | undefined, it?: AsyncIterator<StreamFrame>): void {
1619
+ try {
1620
+ const iter = it ?? source?.[Symbol.asyncIterator]();
1621
+ void Promise.resolve(iter?.return?.()).catch(() => {});
1622
+ } catch {
1623
+ // ignored — see above
1624
+ }
1625
+ }
1626
+
1243
1627
  function oneFrame(streamId: string, frame: StreamFrame): StreamSubscription {
1244
1628
  let taken = false;
1245
1629
  return {