@polyengine/runtime 0.4.0 → 0.5.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.
@@ -186,6 +186,55 @@ export class HostBuffer {
186
186
  }
187
187
  return out;
188
188
  }
189
+ // --- A21 `ByteWindow` (embedder-api amendment A21, polyengine#128) ---
190
+ //
191
+ // A host buffer can be the PEER of a direct session on the other end of a
192
+ // host↔host rendezvous. Which of the two shapes it takes follows from the
193
+ // direction it was built for, exactly as `read`/`write` above do:
194
+ //
195
+ // * SOURCE (`values !== null`, a parked `write`): the window is a view of
196
+ // the offered chunk itself — the A5 borrow, scoped to the callback. No
197
+ // extra copy at all.
198
+ // * DESTINATION (`values === null`, a parked/arriving `read(max)`): there
199
+ // is no landing zone to view, so the window is a fresh scratch; the
200
+ // marked prefix becomes the delivered chunk (ownership passes with it,
201
+ // and `taken()` hands a sole chunk through unsliced).
202
+ /** The synthesized destination window, live for one direct invocation. */
203
+ #scratch = null;
204
+ byteView(n) {
205
+ assert_(n <= this.remain(), "host direct window beyond remaining");
206
+ if (this.values === null) {
207
+ // Stable for the whole invocation: `remaining()` re-derives on every
208
+ // call and the producer's earlier `set()`s must survive that.
209
+ if (this.#scratch === null || this.#scratch.length !== n) {
210
+ this.#scratch = new Uint8Array(n);
211
+ }
212
+ return this.#scratch;
213
+ }
214
+ assert_(this.values instanceof Uint8Array, "host direct window on a non-u8 chunk");
215
+ return this.values.subarray(this.progress, this.progress + n);
216
+ }
217
+ advanceBytes(k) {
218
+ assert_(k >= 0 && k <= this.remain(), "host direct advance beyond remaining");
219
+ if (this.values === null) {
220
+ // A callback may mark bytes it never actually looked at the window to
221
+ // write (nonsense, but the runtime must stay total rather than trip an
222
+ // internal assertion). The acknowledged prefix is then whatever the
223
+ // synthesized landing zone held — zeroes — which is the faithful
224
+ // analogue of the guest-peer case, where it would be whatever the
225
+ // reader's memory already contained.
226
+ const scratch = this.#scratch ?? new Uint8Array(k);
227
+ // Delivered as an owned chunk; `write` is the same call the reference
228
+ // copy would have made, so `remain()`/`taken()` stay consistent.
229
+ this.write(scratch.subarray(0, k));
230
+ }
231
+ else {
232
+ this.progress += k;
233
+ }
234
+ }
235
+ endWindow() {
236
+ this.#scratch = null;
237
+ }
189
238
  }
190
239
  /**
191
240
  * Every live `HostActivity` arm, by identity. These are the promises this
@@ -418,6 +467,209 @@ class HostActivity {
418
467
  this.#arm();
419
468
  }
420
469
  }
470
+ /**
471
+ * The `DirectDestination`/`DirectSource` object itself. One per INVOCATION,
472
+ * not per session: "the object dies when the callback returns" is the
473
+ * contract's validity window, and every later method call throws a
474
+ * `TypeError` naming the rule.
475
+ */
476
+ class DirectScope {
477
+ peer;
478
+ capacity;
479
+ marked = 0;
480
+ #live = true;
481
+ constructor(peer,
482
+ /** The peer's actual remaining capacity — never the parked sentinel. */
483
+ capacity) {
484
+ this.peer = peer;
485
+ this.capacity = capacity;
486
+ }
487
+ remaining() {
488
+ this.#check();
489
+ // Re-derived per call: `byteView` is grow-safe for a guest peer, and the
490
+ // `subarray` accounts for the marks made so far in this invocation.
491
+ return this.peer.byteView(this.capacity).subarray(this.marked);
492
+ }
493
+ markWritten(n) {
494
+ this.#mark(n, "markWritten");
495
+ }
496
+ markRead(n) {
497
+ this.#mark(n, "markRead");
498
+ }
499
+ #mark(n, who) {
500
+ this.#check();
501
+ if (!Number.isInteger(n) || n < 0) {
502
+ throw new TypeError(`${who}(${n}): a direct-access mark must be a non-negative integer`);
503
+ }
504
+ if (this.marked + n > this.capacity) {
505
+ throw new TypeError(`${who}(${n}) would take the invocation's cumulative mark to ` +
506
+ `${this.marked + n}, past the ${this.capacity} byte(s) the view ` +
507
+ `held on entry (embedder-api amendment A21)`);
508
+ }
509
+ this.marked += n;
510
+ }
511
+ #check() {
512
+ if (!this.#live) {
513
+ throw new TypeError("this direct-access view is dead: a DirectDestination/DirectSource " +
514
+ "is scoped to the synchronous callback invocation it was passed " +
515
+ "to, and retaining one past its return is misuse (embedder-api " +
516
+ "amendment A21, polyengine#128)");
517
+ }
518
+ }
519
+ /**
520
+ * End of the invocation: the object is dead, and every later method call
521
+ * throws. Releasing the peer's synthesized window is the caller's job
522
+ * (`DirectSession.runDirect`), because it must happen strictly after the
523
+ * acknowledged marks are applied.
524
+ */
525
+ die() {
526
+ this.#live = false;
527
+ }
528
+ }
529
+ /**
530
+ * A parked direct session, as both halves see it: a `DirectBuffer` to the
531
+ * rendezvous (task/streams.ts) and a promise to the embedder.
532
+ *
533
+ * It presents the ordinary buffer surface so the reference control flow keeps
534
+ * working unchanged — `remain()` answers a positive SENTINEL while the session
535
+ * is live, which only ever feeds the rendezvous' `min()` and so resolves to
536
+ * the peer's real capacity — but `read`/`write` are unreachable: the seam
537
+ * routes a direct buffer through `runDirect` instead.
538
+ */
539
+ class DirectSession {
540
+ t;
541
+ invoke;
542
+ direct = true;
543
+ /** Bytes acknowledged across the whole session. */
544
+ total = 0;
545
+ /** The callback said `"done"`, or the session failed / was settled. */
546
+ ended = false;
547
+ /** `ended` because the callback said so (A7 precision; see `DirectSessionInfo`). */
548
+ endedByVerdict = false;
549
+ /** Installed in the shared object's pending slot right now. */
550
+ pending = false;
551
+ /** `cancelWrite`/`cancelRead` arrived; stop at the next loop top. */
552
+ cancelled = false;
553
+ #settle = null;
554
+ #reject = null;
555
+ constructor(t, invoke) {
556
+ this.t = t;
557
+ this.invoke = invoke;
558
+ }
559
+ // --- buffer surface (definitions.py `Buffer`) ---
560
+ remain() {
561
+ // The sentinel is `Buffer.MAX_LENGTH`, the largest value the rendezvous
562
+ // can legally see; it never surfaces to the embedder because the scope is
563
+ // built from `min(peer.remain(), sentinel)`.
564
+ return this.ended ? 0 : BUFFER_MAX_LENGTH;
565
+ }
566
+ isZeroLength() {
567
+ return false;
568
+ }
569
+ read(_n) {
570
+ throw new Error("internal: a direct session must go through the A21 seam");
571
+ }
572
+ write(_vs) {
573
+ throw new Error("internal: a direct session must go through the A21 seam");
574
+ }
575
+ // --- the direct protocol ---
576
+ runDirect(peer, n) {
577
+ const scope = new DirectScope(peer, n);
578
+ try {
579
+ return this.#runDirect(scope, peer);
580
+ }
581
+ finally {
582
+ // Release any window the peer SYNTHESIZED (a `HostBuffer` destination's
583
+ // scratch). Strictly after `advanceBytes`, which is what turns the
584
+ // marked prefix of that scratch into the delivered chunk.
585
+ peer.endWindow?.();
586
+ }
587
+ }
588
+ #runDirect(scope, peer) {
589
+ let verdict;
590
+ try {
591
+ verdict = this.invoke(scope);
592
+ }
593
+ catch (e) {
594
+ // "A callback that throws rejects the session with that error, and the
595
+ // invocation's marks are discarded" — so nothing touches `peer`.
596
+ scope.die();
597
+ this.#fail(e);
598
+ return "failed";
599
+ }
600
+ scope.die();
601
+ if (verdict !== "more" && verdict !== "done") {
602
+ this.#fail(new TypeError(`a direct-access callback must return "more" or "done", got ` +
603
+ `${JSON.stringify(verdict)} (embedder-api amendment A21)`));
604
+ return "failed";
605
+ }
606
+ const k = scope.marked;
607
+ if (k === 0) {
608
+ if (verdict === "done") {
609
+ // Retraction: the speculative-park correction. The session ends with
610
+ // its running total and the peer's operation stays parked.
611
+ this.ended = true;
612
+ this.endedByVerdict = true;
613
+ return "retracted";
614
+ }
615
+ this.#fail(new TypeError('a direct-access callback returned "more" without marking any ' +
616
+ "bytes; a session that has nothing to offer retracts by " +
617
+ 'returning "done" (embedder-api amendment A21, polyengine#128)'));
618
+ return "failed";
619
+ }
620
+ // Marks acknowledge ON CLEAN RETURN ONLY: this is the first and only
621
+ // place the peer's progress moves, and it completes the copy with `k`.
622
+ peer.advanceBytes(k);
623
+ this.total += k;
624
+ if (verdict === "done") {
625
+ this.ended = true;
626
+ this.endedByVerdict = true;
627
+ }
628
+ return "copied";
629
+ }
630
+ failDirect(error) {
631
+ this.#fail(error);
632
+ }
633
+ // --- promise plumbing ---
634
+ /** Arm the settle hooks for one issuance of this session. */
635
+ arm(settle, reject) {
636
+ this.#settle = settle;
637
+ this.#reject = reject;
638
+ }
639
+ #take() {
640
+ const s = this.#settle, r = this.#reject;
641
+ this.#settle = null;
642
+ this.#reject = null;
643
+ return [s, r];
644
+ }
645
+ #fail(e) {
646
+ this.ended = true;
647
+ this.pending = false;
648
+ const [, r] = this.#take();
649
+ r?.(e);
650
+ }
651
+ /** The session is over; the driving loop resolves with `total`. */
652
+ finish() {
653
+ this.ended = true;
654
+ this.pending = false;
655
+ const [s] = this.#take();
656
+ s?.("done");
657
+ }
658
+ /** This issuance rendezvoused but the session lives; re-issue it. */
659
+ reissue() {
660
+ this.pending = false;
661
+ const [s] = this.#take();
662
+ s?.("reissue");
663
+ }
664
+ }
665
+ /** A21 is `stream<u8>` only; `null` (zero-width) is not u8 either. */
666
+ function requireU8Element(t, who) {
667
+ if (t === null || despecialize(t).kind !== "u8") {
668
+ throw new TypeError(`${who} is available on stream<u8> only; this stream's element type ` +
669
+ `is ${t === null ? "the zero-width payload" : despecialize(t).kind} ` +
670
+ `(embedder-api amendment A21, polyengine#128)`);
671
+ }
672
+ }
421
673
  /**
422
674
  * Attach host-activity bookkeeping to a shared object at the CABI seam.
423
675
  *
@@ -516,6 +768,92 @@ function mkStreamEnds(shared, activity) {
516
768
  else
517
769
  activity.notify();
518
770
  };
771
+ /** The live direct session on each end, if any (A21, polyengine#128). */
772
+ const direct = {
773
+ read: null,
774
+ write: null,
775
+ };
776
+ /**
777
+ * Drive one direct session from park to end (A21).
778
+ *
779
+ * Two shapes reach us, and the difference is *which side arrived second*:
780
+ *
781
+ * * the session is the PENDING side — every rendezvous fires `onCopy`, and
782
+ * the `"more"` verdict simply declines to `reclaim()`, so the session
783
+ * stays in the pending slot for the next peer operation. This is
784
+ * `write()`'s "stay parked until the offer is exhausted" mechanism, with
785
+ * the callback's verdict in place of `buf.remain() > 0`.
786
+ * * the session ARRIVED second — the rendezvous completes it with
787
+ * `onCopyDone(COMPLETED)`, so a `"more"` verdict has to re-issue. The
788
+ * re-issue rides the loop below (one `await` apart), which is exactly
789
+ * `writeAll`'s re-offer shape and therefore inherits its ordering: the
790
+ * peer's pending event is delivered and its buffer reclaimed before we
791
+ * can rendezvous against it a second time.
792
+ */
793
+ const runDirectSession = async (side, session) => {
794
+ parked[side] = true;
795
+ direct[side] = session;
796
+ try {
797
+ for (;;) {
798
+ if (session.cancelled)
799
+ break;
800
+ const step = await new Promise((res, rej) => {
801
+ session.arm(res, rej);
802
+ session.pending = true;
803
+ const onCopy = (reclaim) => {
804
+ if (!session.ended)
805
+ return; // "more": stay parked
806
+ reclaim();
807
+ activity.notify();
808
+ session.finish();
809
+ };
810
+ const onCopyDone = (result) => {
811
+ session.pending = false;
812
+ settle(result);
813
+ // COMPLETED with the session still live == the arriving-side
814
+ // rendezvous above; anything else (DROPPED, CANCELLED, or the
815
+ // retraction path through `reset_and_notify_pending`) ends it.
816
+ if (result === CopyResult.COMPLETED && !session.ended) {
817
+ session.reissue();
818
+ }
819
+ else {
820
+ session.finish();
821
+ }
822
+ };
823
+ if (side === "write") {
824
+ shared.write(writeInst, session, onCopy, onCopyDone);
825
+ }
826
+ else {
827
+ shared.read(readInst, session, onCopy, onCopyDone);
828
+ }
829
+ activity.notify();
830
+ activity.pump();
831
+ });
832
+ if (step === "done")
833
+ break;
834
+ }
835
+ }
836
+ finally {
837
+ parked[side] = false;
838
+ direct[side] = null;
839
+ }
840
+ return session.total;
841
+ };
842
+ /** Shared tail of `cancelWrite`/`cancelRead` for a parked direct session. */
843
+ const cancelDirect = (session) => {
844
+ // A21: cancelling RETRACTS the session — it resolves with its running
845
+ // total (A8's indistinguishability caveats unchanged). `shared.cancel()`
846
+ // only when the session actually holds the pending slot: a session caught
847
+ // between two issuances holds nothing, and `SharedBase.cancel` asserts
848
+ // that something is pending.
849
+ session.cancelled = true;
850
+ if (session.pending)
851
+ shared.cancel();
852
+ else
853
+ session.finish();
854
+ activity.notify();
855
+ activity.pump();
856
+ };
519
857
  return {
520
858
  writable: {
521
859
  write(values) {
@@ -579,9 +917,29 @@ function mkStreamEnds(shared, activity) {
579
917
  }
580
918
  return sent;
581
919
  },
920
+ writeDirect(produce, info) {
921
+ // Same one-in-flight-per-end rule, same wording shape as `write`:
922
+ // `writeDirect` participates in it exactly as `write` does (A21).
923
+ if (parked.write) {
924
+ throw new TypeError("a write is already in flight on this stream's writable end; " +
925
+ "await it or cancelWrite() first");
926
+ }
927
+ requireU8Element(shared.t, "writeDirect");
928
+ const session = new DirectSession(shared.t, (scope) => produce(scope));
929
+ const p = runDirectSession("write", session);
930
+ if (info === undefined)
931
+ return p;
932
+ return p.then((n) => {
933
+ info.endedByVerdict = session.endedByVerdict;
934
+ return n;
935
+ });
936
+ },
582
937
  cancelWrite() {
583
938
  if (!parked.write)
584
939
  return;
940
+ const session = direct.write;
941
+ if (session !== null)
942
+ return cancelDirect(session);
585
943
  parked.write = false;
586
944
  shared.cancel();
587
945
  activity.notify();
@@ -622,6 +980,21 @@ function mkStreamEnds(shared, activity) {
622
980
  activity.pump();
623
981
  });
624
982
  },
983
+ readDirect(consume, info) {
984
+ if (parked.read) {
985
+ throw new TypeError("a read is already in flight on this stream's readable end; " +
986
+ "await it or cancelRead() first");
987
+ }
988
+ requireU8Element(shared.t, "readDirect");
989
+ const session = new DirectSession(shared.t, (scope) => consume(scope));
990
+ const p = runDirectSession("read", session);
991
+ if (info === undefined)
992
+ return p;
993
+ return p.then((n) => {
994
+ info.endedByVerdict = session.endedByVerdict;
995
+ return n;
996
+ });
997
+ },
625
998
  cancelRead() {
626
999
  // #97, DELIBERATE AND PINNED: cancelling resolves the in-flight
627
1000
  // `read` promise with whatever the buffer took so far — for a read
@@ -635,6 +1008,9 @@ function mkStreamEnds(shared, activity) {
635
1008
  // this state — a guest cannot cancel the host's read.
636
1009
  if (!parked.read)
637
1010
  return;
1011
+ const session = direct.read;
1012
+ if (session !== null)
1013
+ return cancelDirect(session);
638
1014
  parked.read = false;
639
1015
  shared.cancel();
640
1016
  activity.notify();
@@ -414,8 +414,11 @@ export function createSubtaskCancel(decl, inst, mode = "plain") {
414
414
  // rule (fact_calls.ts). A callee with a pending (undeliverable)
415
415
  // cancel sits parked non-cancellably, which is determinate, so the
416
416
  // genuine BLOCKED answer is still immediate. Host-import subtasks
417
- // carry no callee task: their onCancel is a no-op and their state
418
- // cannot be mid-hop, so the pre-jspi immediate answer stands.
417
+ // carry no callee task, and their state cannot be mid-hop: the
418
+ // default (A23) onCancel resolves them before this branch is ever
419
+ // reached, and a `deferCancel` import's no-op onCancel leaves them
420
+ // simply unresolved — either way the pre-jspi immediate answer
421
+ // stands.
419
422
  //
420
423
  // NAMED DIVERGENCE (docs/architecture.md §6, #92): this park makes
421
424
  // the async built-in non-atomic — other ready threads may run while
@@ -11,5 +11,10 @@
11
11
  // Layering: this module was import-free on purpose (jspi/ stays standalone);
12
12
  // A9 relaxes that to "imports `@polyengine/protocol` only" — the protocol package
13
13
  // is itself dependency-free, so jspi/ still pulls in no runtime machinery.
14
- // The embedder surface re-exports `suspending` from `@polyengine/runtime/embedder`.
15
- export { anySuspendingImport, isSuspending, suspending } from "@polyengine/protocol";
14
+ // A23 (`deferCancel`/`isDeferCancel`) and A24 (`abortable`/`isAbortable`)
15
+ // ride the same re-export: they are the other per-declaration host-import
16
+ // marks, they live in the same dependency-free package, and
17
+ // `exec/executor.ts` reads all three through `jspi/mod.ts`.
18
+ // (Host modules import the marks from `@polyengine/protocol` directly —
19
+ // the embedder surface stopped re-exporting the vocabulary at A22.)
20
+ export { abortable, anySuspendingImport, deferCancel, isAbortable, isDeferCancel, isSuspending, suspending, } from "@polyengine/protocol";
@@ -45,6 +45,7 @@
45
45
  // expressible.
46
46
  import { defineBrand, ERROR_CONTEXT } from "@polyengine/protocol";
47
47
  import { assert_, Trap, trapIf } from "../cabi/trap.js";
48
+ import { bytesOf } from "../cabi/memory.js";
48
49
  import { loadListFromValidRange } from "../cabi/load.js";
49
50
  import { storeListIntoValidRange } from "../cabi/store.js";
50
51
  import { alignment, alignTo, elemSize } from "../cabi/layout.js";
@@ -141,6 +142,69 @@ export class GuestBuffer {
141
142
  }
142
143
  this.progress += vs.length;
143
144
  }
145
+ // --- A21 direct-access byte edges (embedder-api amendment A21, #128) ---
146
+ //
147
+ // `ByteWindow`, implemented for the `stream<u8>` case only. The two methods
148
+ // together are the copy `read`/`write` would have done, split so that the
149
+ // *peer's* callback performs it: `byteView` hands out the range, and
150
+ // `advanceBytes` records the bytes that actually moved. They are role-blind
151
+ // (destination or source) because `this.ptr` already advances on BOTH
152
+ // `read` and `write` above, and `elemSize(u8) === 1`.
153
+ /**
154
+ * A fresh view over the next `n` bytes of this buffer's remaining range.
155
+ *
156
+ * Fresh on every call, via `bytesOf` (cabi/memory.ts:195) over the
157
+ * `LiveMemory` getters — so a `memory.grow` between two rendezvous of one
158
+ * parked direct session never yields a view onto the detached buffer.
159
+ */
160
+ byteView(n) {
161
+ assert_(this.t !== null && despecialize(this.t).kind === "u8", "direct byte window on a non-u8 buffer");
162
+ assert_(n <= this.remain(), "direct byte window beyond remaining");
163
+ const mem = this.cx.opts.memory;
164
+ assert_(mem !== null, "direct byte window requires a memory");
165
+ return bytesOf(mem, this.ptr, n);
166
+ }
167
+ /**
168
+ * Advance by `k` WITHOUT copying: the bytes already moved through the view
169
+ * `byteView` handed out. Called by the seam only after the direct callback
170
+ * returned cleanly, which is what makes marks acknowledge-on-clean-return.
171
+ */
172
+ advanceBytes(k) {
173
+ assert_(k >= 0 && k <= this.remain(), "direct advance beyond remaining");
174
+ this.ptr += k; // elemSize(u8) === 1
175
+ this.progress += k;
176
+ }
177
+ }
178
+ function isDirectBuffer(b) {
179
+ return b.direct === true;
180
+ }
181
+ /**
182
+ * The one copy site, shared by `SharedStreamImpl.read` and `.write`.
183
+ *
184
+ * Collapses to definitions.py's `dst_buffer.write(src_buffer.read(n))`
185
+ * whenever neither side is a direct session — which is every guest↔guest,
186
+ * guest↔host-chunk and host-chunk↔host-chunk rendezvous, i.e. everything
187
+ * that existed before A21.
188
+ */
189
+ function rendezvousCopy(src, dst, n) {
190
+ const srcDirect = isDirectBuffer(src);
191
+ const dstDirect = isDirectBuffer(dst);
192
+ if (!srcDirect && !dstDirect) {
193
+ dst.write(src.read(n));
194
+ return "chunk";
195
+ }
196
+ if (srcDirect && dstDirect)
197
+ return "both-direct";
198
+ return srcDirect
199
+ ? src.runDirect(dst, n)
200
+ : dst.runDirect(src, n);
201
+ }
202
+ /** The A21 rejection for a rendezvous of two direct sessions. */
203
+ function bothDirectError() {
204
+ return new TypeError("at least one side of a host-to-host rendezvous must use the chunk " +
205
+ "forms: two direct-access sessions cannot rendezvous with each other " +
206
+ "because neither side owns the memory the other would write into " +
207
+ "(embedder-api amendment A21, polyengine#128)");
144
208
  }
145
209
  /**
146
210
  * definitions.py `none_or_number_type` (line 1070). Guards the "temporary"
@@ -272,7 +336,19 @@ export class SharedStreamImpl {
272
336
  if (this.pendingBuffer.remain() > 0) {
273
337
  if (dstBuffer.remain() > 0) {
274
338
  const n = Math.min(dstBuffer.remain(), this.pendingBuffer.remain());
275
- dstBuffer.write(this.pendingBuffer.read(n));
339
+ // A21 seam (#128). `"chunk"` is the reference line verbatim.
340
+ const pendingIsDirect = isDirectBuffer(this.pendingBuffer);
341
+ const out = rendezvousCopy(this.pendingBuffer, dstBuffer, n);
342
+ if (out === "both-direct") {
343
+ // The ARRIVING side (here the reader) is the one refused; the
344
+ // parked session keeps the pending slot, undisturbed.
345
+ dstBuffer.failDirect(bothDirectError());
346
+ return;
347
+ }
348
+ if (out === "retracted" || out === "failed") {
349
+ this.#routeDirectNoCopy(out, pendingIsDirect, inst, dstBuffer, onCopy, onCopyDone);
350
+ return;
351
+ }
276
352
  this.pendingOnCopy(() => this.resetPending());
277
353
  }
278
354
  onCopyDone(CopyResult.COMPLETED);
@@ -298,7 +374,19 @@ export class SharedStreamImpl {
298
374
  if (this.pendingBuffer.remain() > 0) {
299
375
  if (srcBuffer.remain() > 0) {
300
376
  const n = Math.min(srcBuffer.remain(), this.pendingBuffer.remain());
301
- this.pendingBuffer.write(srcBuffer.read(n));
377
+ // A21 seam (#128). `"chunk"` is the reference line verbatim.
378
+ const pendingIsDirect = isDirectBuffer(this.pendingBuffer);
379
+ const out = rendezvousCopy(srcBuffer, this.pendingBuffer, n);
380
+ if (out === "both-direct") {
381
+ // The ARRIVING side (here the writer) is refused; the parked
382
+ // session keeps the pending slot.
383
+ srcBuffer.failDirect(bothDirectError());
384
+ return;
385
+ }
386
+ if (out === "retracted" || out === "failed") {
387
+ this.#routeDirectNoCopy(out, pendingIsDirect, inst, srcBuffer, onCopy, onCopyDone);
388
+ return;
389
+ }
302
390
  this.pendingOnCopy(() => this.resetPending());
303
391
  }
304
392
  onCopyDone(CopyResult.COMPLETED);
@@ -315,6 +403,43 @@ export class SharedStreamImpl {
315
403
  }
316
404
  }
317
405
  }
406
+ /**
407
+ * A21 (#128): route a rendezvous whose direct session did NOT copy.
408
+ *
409
+ * Two outcomes land here, and both share one invariant: the peer's parked
410
+ * operation survives, no event is delivered, and the stream is not dropped
411
+ * — a runtime never emits a zero-progress COMPLETED copy, which is
412
+ * unreachable in definitions.py for a nonzero-capacity operation.
413
+ *
414
+ * * `"retracted"` — `"done"` with zero marked. The session ends and
415
+ * resolves with its running total, through the ordinary
416
+ * `on_copy_done(COMPLETED)` channel.
417
+ * * `"failed"` — misuse or a throwing callback. The session has ALREADY
418
+ * rejected (`DirectSession.#fail`), so it must be retired silently:
419
+ * its rejection is its notification.
420
+ *
421
+ * Which side was the session decides where each goes, and both shapes are
422
+ * states definitions.py already produces:
423
+ *
424
+ * * PARKED session ⇒ the "the parked side had nothing left" branch
425
+ * (definitions.py:1043/1063): retire it and park the arriving
426
+ * operation, which gets no event either way.
427
+ * * ARRIVING session ⇒ the "arriving buffer of zero capacity" state
428
+ * (definitions.py:1041/1057): the pending side is left untouched with
429
+ * its `on_copy` unfired, and the arriving side completes.
430
+ */
431
+ #routeDirectNoCopy(out, pendingIsDirect, inst, arriving, onCopy, onCopyDone) {
432
+ if (pendingIsDirect) {
433
+ if (out === "retracted")
434
+ this.resetAndNotifyPending(CopyResult.COMPLETED);
435
+ else
436
+ this.resetPending();
437
+ this.setPending(inst, arriving, onCopy, onCopyDone);
438
+ return;
439
+ }
440
+ if (out === "retracted")
441
+ onCopyDone(CopyResult.COMPLETED);
442
+ }
318
443
  #assertSameElemType(b) {
319
444
  // Structural, not identity: definitions.py compares dataclass types with
320
445
  // `==`, and our `ValType`s are fresh objects per table (the plan's type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polyengine/runtime",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A WebAssembly Component Model host for JavaScript engines: plan executor, canonical ABI, 0.3 task scheduler, JSPI bridge, and embedder API.",
5
5
  "homepage": "https://github.com/polymorph-components/polyengine#readme",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
- "@polyengine/protocol": "^0.2.1"
58
+ "@polyengine/protocol": "^0.2.3"
59
59
  },
60
60
  "_generatedBy": "dnt@0.43.2"
61
61
  }
@@ -17,7 +17,7 @@ export declare const COPY_URL: string;
17
17
  * @internal — copy-identity constant for the A9 multi-copy diagnostics; not
18
18
  * host-facing.
19
19
  */
20
- export declare const RUNTIME_VERSION = "0.4.0";
20
+ export declare const RUNTIME_VERSION = "0.5.1";
21
21
  /**
22
22
  * Compose a cross-copy diagnostic: what was foreign, which copy is speaking,
23
23
  * the census of every copy in the graph, and the by-value remediation.
@@ -1,11 +1,14 @@
1
1
  export { COPY_URL, RUNTIME_VERSION } from "./copy.js";
2
- export { copyCensus, defineRealmLocal, DROPPED, ERROR_CONTEXT, fromCloneable, FUTURE, hasBrand, INVALID_HANDLE, isDroppedError, isInvalidHandleError, isPeerTrappedError, isRealmLocal, isStreamProducerError, isSuspending, isTrap, isComponentException, PEER_TRAPPED, PROTOCOL_GENERATION, REALM_LOCAL, registerRuntimeCopy, RESOURCE_STATE, type RuntimeCopy, runtimeCopies, STREAM, STREAM_PRODUCER, SUSPENDING, toCloneable, TRAP, COMPONENT_EXCEPTION, } from "@polyengine/protocol";
3
2
  export { artifactsFromEnvelope, type ComponentArtifacts, type EmbedderInstance, type EmbedderOptions, type InstantiateSource, type UntranslatedArtifacts, instantiate, instantiateEmbedder, resolveArtifacts, } from "./instantiate.js";
4
3
  export { type FuncSummary, type ImportLeaf, type PlanLike, requiredImports } from "./imports.js";
5
- export { DroppedError, InvalidHandleError, NameCollisionError, PeerTrappedError, Trap, ComponentException, } from "./errors.js";
6
- export { type Chunk, type ElemCodec, ErrorContext, Future, type FutureSource, Stream, StreamProducerError, type StreamSource, StreamWriter, } from "./streams.js";
4
+ export { NameCollisionError } from "./errors.js";
5
+ export { type ElemCodec } from "./streams.js";
6
+ import type { Stream as ProtocolStream, StreamWriter as ProtocolStreamWriter } from "@polyengine/protocol";
7
+ export declare function createStream<T>(): {
8
+ stream: ProtocolStream<T>;
9
+ writer: ProtocolStreamWriter<T>;
10
+ };
7
11
  export { GuestResource, HostResourceRegistry } from "./resources.js";
8
12
  export { camelCase, type LeafName, parseLeafName, pascalCase } from "./casing.js";
9
- export { suspending } from "../jspi/suspending.js";
10
13
  export { asTrackKeySpelling, compareSemver, ImportRegistrationError, ImportResolutionError, ImportResolver, type ParsedId, parseInterfaceId, parseSemver, type Semver, trackKey, } from "./version.js";
11
14
  export { type AdapterOptions, BorrowScope, fromHost, toHost, type ValueBridge, } from "./values.js";