@statewalker/webrun-rpc 0.8.0 → 0.10.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.
@@ -12,6 +12,13 @@ import { callPort, NO_TIMEOUT } from "./call-port.js";
12
12
  import { getPortCloseSignal } from "./close-signal.js";
13
13
  import { listenPort } from "./listen-port.js";
14
14
  import type { MessageTarget } from "./message-target.js";
15
+ import {
16
+ endPort,
17
+ portAgreement,
18
+ portBufferedAmount,
19
+ whenPortEnded,
20
+ whenPortReady,
21
+ } from "./port-credit.js";
15
22
  import { returningAtOnce } from "./return-at-once.js";
16
23
  import { throughAbort } from "./through-abort.js";
17
24
 
@@ -25,6 +32,83 @@ import { throughAbort } from "./through-abort.js";
25
32
  */
26
33
  export const STREAM_ABORT = "webrun-rpc:stream-abort";
27
34
 
35
+ /**
36
+ * The `type` of a pipelined chunk: a plain port message, not a `callPort`
37
+ * request, sent only on a port where both ends agreed `pipeline`. The window's
38
+ * credit is its confirmation.
39
+ */
40
+ const CHUNK = "webrun-rpc:chunk";
41
+
42
+ /**
43
+ * What a pipelined chunk counts beyond its bytes, against the window, on the
44
+ * receiving side. Both codecs' windows count more for the chunk's envelope
45
+ * (the estimate at least 64, msgpack's keys alone more than this), so an honest
46
+ * sender never makes the receiver count past the window; a sender of empty
47
+ * chunks still cannot have it hold an unbounded number of them.
48
+ */
49
+ const CHUNK_OVERHEAD = 32;
50
+
51
+ /**
52
+ * The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
53
+ * `view` itself when it already is one, else a copy. Structured clone carries
54
+ * and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
55
+ * views of the frame, while the window and the receiver's count see only the
56
+ * view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
57
+ * Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
58
+ * `slice()` is another view on the same memory.
59
+ */
60
+ function ownBytes(view: ArrayBufferView): Uint8Array {
61
+ if (
62
+ view instanceof Uint8Array &&
63
+ view.constructor === Uint8Array &&
64
+ view.byteOffset === 0 &&
65
+ view.byteLength === view.buffer.byteLength
66
+ ) {
67
+ return view;
68
+ }
69
+ return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
70
+ }
71
+
72
+ /**
73
+ * A producer's chunk as bytes this end may send, in either mode: any view or
74
+ * `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
75
+ * as the producer's own error, rather than reaching a peer that would refuse it.
76
+ */
77
+ /**
78
+ * A received confirmed chunk's value, as its own bytes, put back in place of
79
+ * the original in `chunk`: `listenPort` keeps the request until the handler
80
+ * returns, and the original would keep its whole buffer that long. A function
81
+ * of its own, so that no temporary of the suspended handler holds the
82
+ * original either.
83
+ */
84
+ function adoptValue(chunk: WireChunk): WireChunk["value"] {
85
+ const original: unknown = chunk.value;
86
+ if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
87
+ return chunk.value;
88
+ }
89
+
90
+ function chunkBytes(value: unknown): Uint8Array {
91
+ if (ArrayBuffer.isView(value)) return ownBytes(value);
92
+ if (
93
+ value instanceof ArrayBuffer ||
94
+ Object.prototype.toString.call(value) === "[object ArrayBuffer]"
95
+ ) {
96
+ return new Uint8Array(value as ArrayBuffer);
97
+ }
98
+ throw new TypeError(
99
+ `webrun-rpc: a stream chunk must be bytes (a typed array, a DataView or an ArrayBuffer), got ${value === null ? "null" : typeof value}`,
100
+ );
101
+ }
102
+
103
+ const MIXED_MESSAGE =
104
+ "webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
105
+
106
+ /** This end may pipeline its own chunks: agreed, and the window holds them. */
107
+ const mayPipeline = (port: MessageTarget): boolean => {
108
+ const agreement = portAgreement(port);
109
+ return !!agreement?.enforced && agreement.caps.has("pipeline");
110
+ };
111
+
28
112
  /** Caller's input travels on this channel; the handler listens on it. */
29
113
  const CHANNEL_IN = "in";
30
114
  /** The handler's output travels on this channel; the caller listens on it. */
@@ -64,7 +148,9 @@ export interface DuplexOverPortOptions {
64
148
  * same way on the other channel. Within each direction the next chunk is never
65
149
  * sent until the previous one has been delivered *and* pulled past by the
66
150
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
67
- * `listenPort` withholds it until then.
151
+ * `listenPort` withholds it until then. On a port whose window both ends
152
+ * speak and that agreed `pipeline`, chunks go as plain messages instead, and
153
+ * the window's credit is the confirmation: up to a window is in flight.
68
154
  *
69
155
  * A stream port carries exactly one invocation. To make several calls, open
70
156
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -86,7 +172,7 @@ export function serveDuplexOverPort(
86
172
  const notice = installAbortNotice(port, controller);
87
173
  abortOnPortClose(port, controller);
88
174
  const clock = installStreamTimeout(controller, options.timeout);
89
- const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
175
+ const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
90
176
  let output: AsyncGenerator<Uint8Array>;
91
177
  try {
92
178
  output = handler(inbound.stream);
@@ -100,19 +186,60 @@ export function serveDuplexOverPort(
100
186
  options,
101
187
  controller.signal,
102
188
  clock.touch,
189
+ () => {},
103
190
  );
104
191
  void pump.catch(() => {});
105
192
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
106
193
  return teardownOnce(controller, notice, clock, inbound, undefined);
107
194
  }
108
- const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
195
+ let onSent = () => {};
196
+ const sent = new Promise<void>((resolve) => {
197
+ onSent = resolve;
198
+ });
199
+ const pump = sendChunks(
200
+ port,
201
+ CHANNEL_OUT,
202
+ output,
203
+ options,
204
+ controller.signal,
205
+ clock.touch,
206
+ onSent,
207
+ );
109
208
  void pump.catch(() => {
110
209
  // Reported to the peer inside sendChunks; nothing to surface locally.
111
210
  });
112
211
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
212
+ void endServedStream(port, sent, inbound.over);
113
213
  return teardownOnce(controller, notice, clock, inbound, output);
114
214
  }
115
215
 
216
+ /**
217
+ * The serving side's normal finish. Once the handler's last chunk has gone
218
+ * without an error, and no more input is coming (`over`: the caller's last
219
+ * chunk arrived without an error and was read, or the caller ended its
220
+ * direction behind it, for a handler that did not read all its input), this
221
+ * side ends its direction too, where `end` was agreed. Without `end` nothing
222
+ * happens here: the caller closes the port. A stream that never gets that far
223
+ * was abandoned, and the caller's notice and `close` free the port; this then
224
+ * never resumes. It needs no race against the port's close signal: nothing
225
+ * awaits it, and what keeps it waiting is the input's watcher, a listener of
226
+ * the port that a close drops, after which the wait is collected.
227
+ *
228
+ * Nothing this side still posts is lost behind its `end`. The caller ends only
229
+ * once its own last chunk was confirmed, so no confirmation is owed to it
230
+ * here, and what the freed port's close signal aborts has settled: the last
231
+ * chunk has left, and input nobody reads is dropped, as on a close.
232
+ */
233
+ async function endServedStream(
234
+ port: MessageTarget,
235
+ sent: Promise<void>,
236
+ over: Promise<void>,
237
+ ): Promise<void> {
238
+ await sent;
239
+ await over;
240
+ endPort(port);
241
+ }
242
+
116
243
  /**
117
244
  * A stream that completes normally has nobody left to call the returned
118
245
  * teardown — the caller only knows the stream ended, not that it must also
@@ -171,17 +298,25 @@ function runCallerSide(
171
298
  const notice = installAbortNotice(port, controller);
172
299
  abortOnPortClose(port, controller);
173
300
  const clock = installStreamTimeout(controller, options.timeout);
174
- const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
175
- const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
301
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
302
+ let sent = false;
303
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
304
+ sent = true;
305
+ });
176
306
  void pump.catch(() => {
177
307
  // The outbound half's failure surfaces to the peer, not to this consumer:
178
308
  // the consumer's contract is the inbound half.
179
309
  });
180
310
 
181
311
  const stream = (async function* () {
312
+ let received = false;
182
313
  try {
183
314
  yield* inbound.stream;
315
+ received = true;
184
316
  } finally {
317
+ // Both directions ended normally: the handler's end arrived without an
318
+ // error, and this side's left without one. Anything else is abandoned.
319
+ const ended = received && sent;
185
320
  // Abort BEFORE awaiting the pump. `callPort` runs with NO_TIMEOUT here,
186
321
  // so an un-aborted in-flight chunk call would never settle and this
187
322
  // `finally` would deadlock — which is exactly the defect found in the
@@ -189,15 +324,26 @@ function runCallerSide(
189
324
  if (!controller.signal.aborted) {
190
325
  controller.abort(new Error("webrun-rpc: the caller abandoned the stream"));
191
326
  }
192
- notice.post();
327
+ // A normal finish ends this direction where `end` was agreed, rather
328
+ // than closing: the port is then freed on both ends once the handler's
329
+ // side ends too, and the peer never mistakes it for an abandonment.
330
+ if (!ended) notice.post();
193
331
  notice.stop();
194
332
  clock.stop();
195
333
  inbound.stop();
196
334
  await pump.catch(() => {});
197
- try {
198
- await port.close?.();
199
- } catch {
200
- /* the port may already be gone; nothing to unwind */
335
+ // By now `listenPort` has confirmed the handler's last chunk: this
336
+ // generator ends only after that chunk's delivery settled, and the
337
+ // confirmation leaves in the promise turns right after it. It must, as
338
+ // anything posted after the `end` is dropped.
339
+ if (!(ended && endPort(port))) {
340
+ // No `end`: the notice (if not posted above), then `close`.
341
+ notice.post();
342
+ try {
343
+ await port.close?.();
344
+ } catch {
345
+ /* the port may already be gone; nothing to unwind */
346
+ }
201
347
  }
202
348
  }
203
349
  })();
@@ -325,7 +471,13 @@ function receiveChunks(
325
471
  channelName: string,
326
472
  controller: AbortController,
327
473
  touch: () => void,
328
- ): { stream: AsyncGenerator<Uint8Array>; stop(): void; ended: Promise<void> } {
474
+ notice: { post(): void },
475
+ ): {
476
+ stream: AsyncGenerator<Uint8Array>;
477
+ stop(): void;
478
+ ended: Promise<void>;
479
+ over: Promise<void>;
480
+ } {
329
481
  let deliver: ChunkReceiver<Uint8Array> | undefined;
330
482
  const waiting: Array<() => void> = [];
331
483
  let finished = false;
@@ -339,6 +491,13 @@ function receiveChunks(
339
491
  const ended = new Promise<void>((resolve) => {
340
492
  resolveEnded = resolve;
341
493
  });
494
+ // Resolves once this direction finished normally: the peer's last chunk,
495
+ // without an error, was handed to the consumer, or arrived and the peer
496
+ // then ended its direction (see the watcher below).
497
+ let resolveOver: () => void = () => {};
498
+ const over = new Promise<void>((resolve) => {
499
+ resolveOver = resolve;
500
+ });
342
501
  // Set by `onAbort` the instant the signal fires, independent of whether a
343
502
  // consumer has started iterating yet. `recieveIterator`'s installer below
344
503
  // runs lazily, on the consumer's first `.next()` — if abort fires first,
@@ -356,44 +515,104 @@ function receiveChunks(
356
515
  for (const r of waiting.splice(0)) r();
357
516
  };
358
517
 
518
+ // A protocol violation fails the stream and closes its port.
519
+ const violate = (message: string): Error => {
520
+ poison = new Error(message);
521
+ // Route through the existing abort machinery instead of duplicating
522
+ // it by hand. `onAbort` below (a) sets `aborted`/`abortReason`, so
523
+ // `recieveIterator`'s installer replays the error even if poison
524
+ // fires before the consumer's first `.next()` — otherwise `deliver`
525
+ // is still undefined here and the local stream hangs forever; and
526
+ // (b) since `controller.signal` is the very signal `sendChunks`
527
+ // passes into `callPort` for *both* directions of this stream,
528
+ // aborting it rejects any outbound call parked at `NO_TIMEOUT` —
529
+ // otherwise `port.close()` below makes that call unsettleable, the
530
+ // pump never returns, the handler's `finally` never runs, and
531
+ // `disarmClockWhenBothSidesSettle` never fires. `onAbort` also
532
+ // resolves `ended`, making a separate call here redundant.
533
+ controller.abort(poison);
534
+ // Close on the next macrotask, so listenPort still gets to post the
535
+ // refusal on this one — a virtual port goes inert the instant it
536
+ // closes, and a silent drop would leave the offender hanging rather
537
+ // than telling it what it did wrong.
538
+ setTimeout(() => {
539
+ try {
540
+ void port.close?.();
541
+ } catch {
542
+ /* already gone */
543
+ }
544
+ }, 0);
545
+ return poison;
546
+ };
547
+
548
+ // Where `end` is agreed, the peer's `end` is the other way this direction
549
+ // can be over. This watcher sees the peer's last chunk even once the
550
+ // consumer stopped reading (and the listeners below are gone): an `end`
551
+ // behind a last chunk without an error finishes the input, and one without
552
+ // it is a violation, or the consumer would wait forever for a chunk that
553
+ // cannot come. It stays on the port until that `end`, and it refers to
554
+ // `port` on purpose: the mux holds a port's listeners, so a stream that
555
+ // only waits for the peer's `end` keeps its port, which nothing else of
556
+ // its own may still hold. A close drops it with the other listeners.
557
+ let lastChunkArrived = false;
558
+ let watching = false;
559
+ const watchPeerEnd = (): boolean => {
560
+ if (watching) return true;
561
+ if (!portAgreement(port)?.caps.has("end")) return false;
562
+ watching = true;
563
+ void whenPortEnded(port).then(() => {
564
+ port.removeEventListener("message", watcher);
565
+ if (lastChunkArrived) {
566
+ resolveOver();
567
+ return;
568
+ }
569
+ notice.post();
570
+ violate(
571
+ "webrun-rpc: the peer ended its direction without finishing it; the stream port is closed",
572
+ );
573
+ });
574
+ return true;
575
+ };
576
+ const watcher = (event: MessageEvent): void => {
577
+ const data = event.data as
578
+ | { type?: unknown; channelName?: unknown; chunk?: WireChunk; params?: WireChunk }
579
+ | undefined;
580
+ if (data?.channelName !== channelName) return;
581
+ const chunk =
582
+ data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : undefined;
583
+ if (!chunk) return;
584
+ // The opener learns the agreement from the ack, which comes before any
585
+ // chunk: decided at the first one, and without `end` nothing is watched.
586
+ if (!watchPeerEnd()) {
587
+ port.removeEventListener("message", watcher);
588
+ return;
589
+ }
590
+ if (chunk.done && !chunk.error) lastChunkArrived = true;
591
+ };
592
+ port.addEventListener("message", watcher);
593
+ watchPeerEnd();
594
+
595
+ // Bytes of pipelined chunks delivered and not yet consumed (see `onChunk`).
596
+ let held = 0;
359
597
  const off = listenPort<WireChunk, void>(
360
598
  port,
361
- async ({ done, value, error }) => {
599
+ async (params) => {
600
+ const { done, error } = params;
601
+ const value = adoptValue(params);
362
602
  touch();
363
603
  if (poison) throw poison;
604
+ // A confirmed chunk is held outside the pipelined count: one beside
605
+ // pipelined ones would let a peer hold more than the window plus one
606
+ // chunk. An honest sender switches to pipelining once and stays.
607
+ if (held > 0) throw violate(MIXED_MESSAGE);
364
608
  if (outstanding) {
365
609
  // Spec D15: a second chunk before the first was confirmed is a
366
610
  // protocol violation, not a resource question. A count of one needs
367
611
  // no threshold and no byte accounting, and it bounds memory by
368
612
  // construction: maxPorts x one chunk.
369
- poison = new Error(
613
+ throw violate(
370
614
  "webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed",
371
615
  );
372
- // Route through the existing abort machinery instead of duplicating
373
- // it by hand. `onAbort` below (a) sets `aborted`/`abortReason`, so
374
- // `recieveIterator`'s installer replays the error even if poison
375
- // fires before the consumer's first `.next()` — otherwise `deliver`
376
- // is still undefined here and the local stream hangs forever; and
377
- // (b) since `controller.signal` is the very signal `sendChunks`
378
- // passes into `callPort` for *both* directions of this stream,
379
- // aborting it rejects any outbound call parked at `NO_TIMEOUT` —
380
- // otherwise `port.close()` below makes that call unsettleable, the
381
- // pump never returns, the handler's `finally` never runs, and
382
- // `disarmClockWhenBothSidesSettle` never fires. `onAbort` also
383
- // resolves `ended`, making a separate call here redundant.
384
- controller.abort(poison);
385
- // Close on the next macrotask, so listenPort still gets to post the
386
- // refusal on this one — a virtual port goes inert the instant it
387
- // closes, and a silent drop would leave the offender hanging rather
388
- // than telling it what it did wrong.
389
- setTimeout(() => {
390
- try {
391
- void port.close?.();
392
- } catch {
393
- /* already gone */
394
- }
395
- }, 0);
396
- throw poison;
397
616
  }
398
617
  outstanding = true;
399
618
  try {
@@ -404,6 +623,7 @@ function receiveChunks(
404
623
  value,
405
624
  error: error ? deserializeError(error) : undefined,
406
625
  });
626
+ if (done && !error) resolveOver();
407
627
  } finally {
408
628
  outstanding = false;
409
629
  }
@@ -412,6 +632,59 @@ function receiveChunks(
412
632
  { channelName },
413
633
  );
414
634
 
635
+ // Pipelined chunks: plain messages, held until the consumer pulls past
636
+ // them. The listener's promise settles only then, so the mux returns the
637
+ // window's credit for a chunk once it is consumed, not once it arrived —
638
+ // that is what bounds a stalled reader to one window. A peer that ignores
639
+ // the credit is caught by counting what is held: an honest one never makes
640
+ // it pass the window, so past the window plus this chunk it is refused.
641
+ const onChunk = (event: MessageEvent): Promise<void> | undefined => {
642
+ const data = event.data as
643
+ | { type?: unknown; channelName?: unknown; chunk?: WireChunk }
644
+ | undefined;
645
+ if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
646
+ if (poison) return;
647
+ // Agreed is enough here, unlike for sending: the peer pipelines only once
648
+ // the window holds its own sends, and agreeing told it that it may.
649
+ const agreement = portAgreement(port);
650
+ if (!agreement?.caps.has("pipeline")) {
651
+ violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
652
+ return;
653
+ }
654
+ if (outstanding) {
655
+ violate(MIXED_MESSAGE);
656
+ return;
657
+ }
658
+ // What is held is counted by its bytes, so it must be bytes.
659
+ if (data.chunk.value !== undefined && !ArrayBuffer.isView(data.chunk.value)) {
660
+ violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
661
+ return;
662
+ }
663
+ if (held > agreement.window) {
664
+ violate(
665
+ `webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`,
666
+ );
667
+ return;
668
+ }
669
+ touch();
670
+ const { done, error } = data.chunk;
671
+ const value = data.chunk.value && ownBytes(data.chunk.value);
672
+ const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
673
+ held += size;
674
+ return (async () => {
675
+ try {
676
+ await ready();
677
+ if (finished) return;
678
+ await deliver?.({ done, value, error: error ? deserializeError(error) : undefined });
679
+ if (done && !error) resolveOver();
680
+ if (done) resolveEnded();
681
+ } finally {
682
+ held -= size;
683
+ }
684
+ })();
685
+ };
686
+ port.addEventListener("message", onChunk);
687
+
415
688
  // Every path that stops listening to this channel — the consumer walking
416
689
  // away early (`break`/`return`/`throw` on the `for await`), an external
417
690
  // `stop()`, or the abort branch below — means no more chunks are coming
@@ -429,6 +702,7 @@ function receiveChunks(
429
702
  finished = true;
430
703
  wake();
431
704
  off();
705
+ port.removeEventListener("message", onChunk);
432
706
  controller.signal.removeEventListener("abort", onAbort);
433
707
  resolveEnded();
434
708
  };
@@ -464,12 +738,16 @@ function receiveChunks(
464
738
  stream,
465
739
  stop: detach,
466
740
  ended,
741
+ over,
467
742
  };
468
743
  }
469
744
 
470
745
  /**
471
746
  * The sending half of one direction: one `callPort` per chunk, one call
472
- * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
747
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
748
+ * or, once pipelining is agreed, plain chunk messages held back by the window.
749
+ * `onSent` runs once the end of the output has left without an error: posted
750
+ * when pipelined, confirmed when not.
473
751
  */
474
752
  async function sendChunks(
475
753
  port: MessageTarget,
@@ -478,6 +756,7 @@ async function sendChunks(
478
756
  { maxMessageSize, log }: DuplexOverPortOptions,
479
757
  signal: AbortSignal,
480
758
  touch: () => void,
759
+ onSent: () => void,
481
760
  ): Promise<void> {
482
761
  const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
483
762
  const stream = throughAbort(framed, signal);
@@ -487,21 +766,38 @@ async function sendChunks(
487
766
  const producer = output as Partial<AsyncIterator<Uint8Array>>;
488
767
  const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
489
768
  if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
769
+ // A pipelined sender waits only for room in the window, or for the abort.
770
+ let onAborted = () => {};
771
+ const aborted = new Promise<void>((resolve) => {
772
+ onAborted = () => resolve();
773
+ });
774
+ if (signal.aborted) onAborted();
775
+ else signal.addEventListener("abort", onAborted, { once: true });
490
776
  try {
491
777
  await sendIterator<Uint8Array>(async ({ done, value, error }) => {
492
778
  if (signal.aborted) return;
493
779
  const chunk: WireChunk = {
494
780
  done,
495
- value,
781
+ value: done ? value : chunkBytes(value),
496
782
  error: error === undefined ? undefined : serializeError(error),
497
783
  };
498
784
  log?.("[duplexOverPort] send", { channelName, done, size: value?.byteLength });
785
+ // Decided per chunk: acknowledged calls until `pipeline` is agreed (the
786
+ // opener learns it from the ack), plain messages from then on.
787
+ if (mayPipeline(port)) {
788
+ port.postMessage({ type: CHUNK, channelName, chunk });
789
+ touch();
790
+ if (done && !error) onSent();
791
+ if (portBufferedAmount(port) > 0) await Promise.race([whenPortReady(port), aborted]);
792
+ return;
793
+ }
499
794
  await callPort<void, WireChunk>(port, chunk, {
500
795
  channelName,
501
796
  timeout: NO_TIMEOUT,
502
797
  signal,
503
798
  });
504
799
  touch();
800
+ if (done && !error) onSent();
505
801
  }, stream);
506
802
  } catch (err) {
507
803
  // An abort is the expected way this ends when the local side walks away;
@@ -510,5 +806,6 @@ async function sendChunks(
510
806
  throw err;
511
807
  } finally {
512
808
  signal.removeEventListener("abort", returnProducer);
809
+ signal.removeEventListener("abort", onAborted);
513
810
  }
514
811
  }