@polyengine/runtime 0.4.0 → 0.5.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.
@@ -29,7 +29,7 @@ export const COPY_URL = import.meta.url;
29
29
  * @internal — copy-identity constant for the A9 multi-copy diagnostics; not
30
30
  * host-facing.
31
31
  */
32
- export const RUNTIME_VERSION = "0.4.0";
32
+ export const RUNTIME_VERSION = "0.5.0";
33
33
  /**
34
34
  * Compose a cross-copy diagnostic: what was foreign, which copy is speaking,
35
35
  * the census of every copy in the graph, and the by-value remediation.
@@ -22,19 +22,30 @@ registerRuntimeCopy({
22
22
  protocolGeneration: PROTOCOL_GENERATION,
23
23
  });
24
24
  export { COPY_URL, RUNTIME_VERSION } from "./copy.js";
25
- // The A9 vocabulary, re-exported unchanged: embedder code needs no import
26
- // change, and consumers that want the multi-copy-robust spellings get them
27
- // from the same module they already import.
28
- 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, runtimeCopies, STREAM, STREAM_PRODUCER, SUSPENDING, toCloneable, TRAP, COMPONENT_EXCEPTION, } from "@polyengine/protocol";
25
+ // Amendment A22 (contracts/embedder-api.md §"The host-ABI surface and its
26
+ // version"): the runtime's exported surface is application-only. The A9
27
+ // courtesy re-exports (error classes, predicates, brands, `suspending`,
28
+ // realm crossing, the copy registry) are removed host modules import that
29
+ // vocabulary from `@polyengine/protocol` directly. The runtime still
30
+ // registers its own copy on the census above; it just no longer hands out
31
+ // the registry API to callers of this module.
29
32
  export { artifactsFromEnvelope, instantiate, instantiateEmbedder, resolveArtifacts, } from "./instantiate.js";
30
33
  export { requiredImports } from "./imports.js";
31
- export { DroppedError, InvalidHandleError, NameCollisionError, PeerTrappedError, Trap, ComponentException, } from "./errors.js";
32
- export { ErrorContext, Future, Stream, StreamProducerError, StreamWriter, } from "./streams.js";
34
+ // `NameCollisionError` is the one error class that stays here: it's raised
35
+ // while building an instantiation facade, before any handle/value exists —
36
+ // application machinery, not host-ABI vocabulary (contracts/embedder-api.md
37
+ // §"The host-ABI surface and its version", amendment A22).
38
+ export { NameCollisionError } from "./errors.js";
39
+ // `createStream<T>()` — the A22 stream-pair factory (contracts/embedder-api.md
40
+ // §"The host-ABI surface and its version" / §"Streams and futures"): the
41
+ // `Stream.create()` static's application-surface spelling, since the
42
+ // concrete `Stream`/`StreamWriter` classes are no longer exported. Handle
43
+ // TYPES are spelled against `@polyengine/protocol`'s structural interfaces.
44
+ import { Stream as InternalStream } from "./streams.js";
45
+ export function createStream() {
46
+ return InternalStream.create();
47
+ }
33
48
  export { GuestResource, HostResourceRegistry } from "./resources.js";
34
49
  export { camelCase, parseLeafName, pascalCase } from "./casing.js";
35
- // Per-declaration suspendability (contracts/embedder-api.md §"Functions and
36
- // async", amendment A1): declares that a sync-typed host import may return a
37
- // Promise, parking the calling wasm frame (JSPI engines only).
38
- export { suspending } from "../jspi/suspending.js";
39
50
  export { asTrackKeySpelling, compareSemver, ImportRegistrationError, ImportResolutionError, ImportResolver, parseInterfaceId, parseSemver, trackKey, } from "./version.js";
40
51
  export { BorrowScope, fromHost, toHost, } from "./values.js";
@@ -10,7 +10,7 @@
10
10
  import { despecialize } from "../cabi/types.js";
11
11
  import { hostFuture, hostFutureFor, hostStream, hostStreamFor, } from "../exec/host_streams.js";
12
12
  import { CopyResult, dropSharedForTeardown, poisonFailureOf, } from "../task/mod.js";
13
- import { defineBrand, defineRealmLocal, ERROR_CONTEXT, FUTURE, hasBrand, isStreamProducerError, STREAM, StreamProducerError, } from "@polyengine/protocol";
13
+ import { defineBrand, defineRealmLocal, ERROR_CONTEXT, FUTURE, hasBrand, isStreamProducerError, STREAM, StreamProducerError, STREAM_WRITER, } from "@polyengine/protocol";
14
14
  import { describeCrossCopy } from "./copy.js";
15
15
  import { DroppedError, PeerTrappedError } from "./errors.js";
16
16
  // `StreamProducerError`'s canonical definition moved to `@polyengine/protocol`
@@ -85,6 +85,16 @@ function throwIfPeerTrapped(value, where, progress) {
85
85
  export function isU8Element(element) {
86
86
  return element !== null && despecialize(element).kind === "u8";
87
87
  }
88
+ /**
89
+ * A21 (#128): the direct-access byte edges are `stream<u8>` only. A
90
+ * zero-width element type (`t === null`) is not u8 either.
91
+ */
92
+ function requireU8Direct(codec, who) {
93
+ if (codec === null || !isU8Element(codec.element)) {
94
+ throw new TypeError(`${who} is available on stream<u8> only (embedder-api amendment A21, ` +
95
+ `polyengine#128); use write()/read() for other element types`);
96
+ }
97
+ }
88
98
  /**
89
99
  * A stream handle.
90
100
  *
@@ -203,6 +213,45 @@ export class Stream {
203
213
  throwIfPeerTrapped(host.value, where);
204
214
  return this.#chunk(raw);
205
215
  }
216
+ /**
217
+ * Consume the writer's bytes in place, without an intermediate chunk
218
+ * (`stream<u8>` only — contracts/embedder-api.md amendment A21,
219
+ * polyengine#128).
220
+ *
221
+ * At every rendezvous with a writer of nonzero capacity, `consume` runs
222
+ * exactly once, synchronously, with a `DirectSource` over the writer's
223
+ * unread bytes — guest linear memory when the peer is a guest, so the
224
+ * consumer's own `set()`/`subarray` copy IS the canonical-ABI copy.
225
+ * `"more"` keeps the session parked for the next rendezvous; `"done"` ends
226
+ * it. Resolves with the session's total byte count. Marking a prefix is
227
+ * normal: the writer re-offers the rest on its own schedule.
228
+ *
229
+ * `"done"` with zero bytes marked *retracts*: the session ends and the
230
+ * writer's operation stays parked, with no event delivered. `"more"` with
231
+ * zero marked, and a throwing callback, reject — and in both cases the
232
+ * writer's parked operation survives and the stream stays alive.
233
+ *
234
+ * Refusals mirror `read`: an unbound `Stream.create()` handle and a handle
235
+ * already passed to a guest (the A15 transfer guard) both throw, as does a
236
+ * non-`u8` element type.
237
+ */
238
+ async readDirect(consume) {
239
+ const host = this.#require();
240
+ const where = this.#codec?.where ?? "stream read";
241
+ throwIfFailed(host.value, where);
242
+ requireU8Direct(this.#codec, "readDirect");
243
+ const info = { endedByVerdict: false };
244
+ const n = await host.readable.readDirect(consume, info);
245
+ // A7 precision, `read`'s rule adapted: a session the CONSUMER itself
246
+ // ended with `"done"` genuinely completed and keeps its resolution. Any
247
+ // other way out (the writer dropped, the session was cancelled, the
248
+ // retirement walk settled us) is a settle-path this consumer did not
249
+ // cause — so if the peer's instance trapped, reject with the delivered
250
+ // count rather than fake a clean end.
251
+ if (!info.endedByVerdict)
252
+ throwIfPeerTrapped(host.value, where, n);
253
+ return n;
254
+ }
206
255
  #chunk(raw) {
207
256
  const codec = this.#codec;
208
257
  if (isU8Element(codec.element)) {
@@ -327,6 +376,44 @@ export class StreamWriter {
327
376
  throwIfPeerTrapped(host.value, where, n);
328
377
  return n;
329
378
  }
379
+ /**
380
+ * Fill the reader's landing zone in place, without an intermediate chunk
381
+ * (`stream<u8>` only — contracts/embedder-api.md amendment A21,
382
+ * polyengine#128).
383
+ *
384
+ * At every rendezvous with a reader of nonzero capacity, `produce` runs
385
+ * exactly once, synchronously, with a `DirectDestination` over the reader's
386
+ * unfilled landing zone — guest linear memory when the peer is a guest, so
387
+ * the producer's own `set()` IS the canonical-ABI copy and an external byte
388
+ * mover (a websocket frame, a SAB ring segment, a transferred
389
+ * `ArrayBuffer`) never pays a second copy inside the runtime. `"more"`
390
+ * keeps the session parked for the next rendezvous; `"done"` ends it.
391
+ * Resolves with the session's total byte count.
392
+ *
393
+ * `"done"` with zero bytes marked *retracts* (the session ends, the
394
+ * reader's operation stays parked, no event — the speculative-park
395
+ * correction); `"more"` with zero marked, and a throwing callback, reject.
396
+ *
397
+ * Parks until the element type is known, exactly as `write` does — a
398
+ * `Stream.create()` writer has no element type until the lowering site
399
+ * binds one — and then requires `u8`.
400
+ */
401
+ async writeDirect(produce) {
402
+ await this.#stream.whenBound();
403
+ const host = hostOf(this.#stream);
404
+ const where = this.#stream.codec?.where ?? "stream write";
405
+ throwIfFailed(host.value, where);
406
+ requireU8Direct(this.#stream.codec, "writeDirect");
407
+ const info = { endedByVerdict: false };
408
+ const n = await host.writable.writeDirect(produce, info);
409
+ // A7 precision, `write`'s short-take rule adapted: a session the PRODUCER
410
+ // itself ended with `"done"` keeps its resolution; every other way out is
411
+ // a settle-path the producer did not cause, so a trapped peer rejects
412
+ // here carrying the delivered count.
413
+ if (!info.endedByVerdict)
414
+ throwIfPeerTrapped(host.value, where, n);
415
+ return n;
416
+ }
330
417
  /** Offer values until all are taken or the reader goes away. */
331
418
  async writeAll(values) {
332
419
  await this.#stream.whenBound();
@@ -544,11 +631,15 @@ export class ErrorContext {
544
631
  defineRealmLocal(this);
545
632
  }
546
633
  }
547
- // A9 brands (contracts/embedder-api.md §"Module identity"): the three
548
- // STATEFUL embedder-facing handle classes. Their machinery lives in the copy
549
- // that minted them, so the brand never makes a foreign handle usable — it
550
- // makes it DIAGNOSABLE, at the lowering sites below.
634
+ // A9 brands (contracts/embedder-api.md §"Module identity"): the STATEFUL
635
+ // embedder-facing handle classes. Their machinery lives in the copy that
636
+ // minted them, so the brand never makes a foreign handle usable — it makes
637
+ // it DIAGNOSABLE, at the lowering sites below. `StreamWriter` gains its
638
+ // brand with amendment A22 (§"The host-ABI surface and its version"):
639
+ // writers carried none before because nothing needed to recognize one, and
640
+ // `isStreamWriter` now does.
551
641
  defineBrand(Stream.prototype, STREAM);
642
+ defineBrand(StreamWriter.prototype, STREAM_WRITER);
552
643
  defineBrand(Future.prototype, FUTURE);
553
644
  defineBrand(ErrorContext.prototype, ERROR_CONTEXT);
554
645
  /**
@@ -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();
@@ -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.0",
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.2"
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.0";
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";
@@ -2,8 +2,8 @@ import type { ValType } from "../cabi/types.js";
2
2
  import type { ComponentValue } from "../cabi/types.js";
3
3
  import { type HostFuture, type HostStream } from "../exec/host_streams.js";
4
4
  import { ErrorContext as InternalErrorContext } from "../task/mod.js";
5
- /** `Chunk<u8>` is a `Uint8Array`; every other element type chunks as `T[]`. */
6
- export type Chunk<T> = T extends number ? Uint8Array | T[] : T[];
5
+ import { type Chunk, type DirectDestination, type DirectSource, type DirectVerdict, type ErrorContext as ProtocolErrorContext, type Future as ProtocolFuture, type Stream as ProtocolStream, type StreamWriter as ProtocolStreamWriter } from "@polyengine/protocol";
6
+ export type { Chunk } from "@polyengine/protocol";
7
7
  /**
8
8
  * Per-element adaptation, supplied by the value adapter.
9
9
  * @internal — supplied by the value adapter, never by a host.
@@ -26,13 +26,14 @@ export interface ElemCodec<T> {
26
26
  export { StreamProducerError } from "@polyengine/protocol";
27
27
  /** True for `stream<u8>` / `future<u8>`, whose chunks are `Uint8Array`. */
28
28
  export declare function isU8Element(element: ValType | null): boolean;
29
+ export type { DirectDestination, DirectSource, DirectVerdict } from "@polyengine/protocol";
29
30
  /**
30
31
  * A stream handle.
31
32
  *
32
33
  * `read` returning an empty chunk is end-of-stream, exactly as the contract
33
34
  * spells it; `readable()` and the async iterator are built on it.
34
35
  */
35
- export declare class Stream<T> {
36
+ export declare class Stream<T> implements ProtocolStream<T> {
36
37
  #private;
37
38
  private constructor();
38
39
  /** Wrap a stream value that was lifted out of a guest. */
@@ -66,6 +67,29 @@ export declare class Stream<T> {
66
67
  get codec(): ElemCodec<T> | null;
67
68
  /** Low-level read: up to `max` elements; an empty chunk means end-of-stream. */
68
69
  read(max: number): Promise<Chunk<T>>;
70
+ /**
71
+ * Consume the writer's bytes in place, without an intermediate chunk
72
+ * (`stream<u8>` only — contracts/embedder-api.md amendment A21,
73
+ * polyengine#128).
74
+ *
75
+ * At every rendezvous with a writer of nonzero capacity, `consume` runs
76
+ * exactly once, synchronously, with a `DirectSource` over the writer's
77
+ * unread bytes — guest linear memory when the peer is a guest, so the
78
+ * consumer's own `set()`/`subarray` copy IS the canonical-ABI copy.
79
+ * `"more"` keeps the session parked for the next rendezvous; `"done"` ends
80
+ * it. Resolves with the session's total byte count. Marking a prefix is
81
+ * normal: the writer re-offers the rest on its own schedule.
82
+ *
83
+ * `"done"` with zero bytes marked *retracts*: the session ends and the
84
+ * writer's operation stays parked, with no event delivered. `"more"` with
85
+ * zero marked, and a throwing callback, reject — and in both cases the
86
+ * writer's parked operation survives and the stream stays alive.
87
+ *
88
+ * Refusals mirror `read`: an unbound `Stream.create()` handle and a handle
89
+ * already passed to a guest (the A15 transfer guard) both throw, as does a
90
+ * non-`u8` element type.
91
+ */
92
+ readDirect(consume: (src: DirectSource) => DirectVerdict): Promise<number>;
69
93
  /**
70
94
  * Cancel an in-flight `read` (R-fix review advisory 1).
71
95
  *
@@ -103,7 +127,7 @@ export declare class Stream<T> {
103
127
  [Symbol.asyncIterator](): AsyncIterator<Chunk<T>>;
104
128
  }
105
129
  /** Writer half of `Stream.create()`. */
106
- export declare class StreamWriter<T> {
130
+ export declare class StreamWriter<T> implements ProtocolStreamWriter<T> {
107
131
  #private;
108
132
  constructor(stream: Stream<T>);
109
133
  /**
@@ -117,6 +141,29 @@ export declare class StreamWriter<T> {
117
141
  * window is misuse. Plain-array chunks are lowered (copied) up front.
118
142
  */
119
143
  write(values: Chunk<T>): Promise<number>;
144
+ /**
145
+ * Fill the reader's landing zone in place, without an intermediate chunk
146
+ * (`stream<u8>` only — contracts/embedder-api.md amendment A21,
147
+ * polyengine#128).
148
+ *
149
+ * At every rendezvous with a reader of nonzero capacity, `produce` runs
150
+ * exactly once, synchronously, with a `DirectDestination` over the reader's
151
+ * unfilled landing zone — guest linear memory when the peer is a guest, so
152
+ * the producer's own `set()` IS the canonical-ABI copy and an external byte
153
+ * mover (a websocket frame, a SAB ring segment, a transferred
154
+ * `ArrayBuffer`) never pays a second copy inside the runtime. `"more"`
155
+ * keeps the session parked for the next rendezvous; `"done"` ends it.
156
+ * Resolves with the session's total byte count.
157
+ *
158
+ * `"done"` with zero bytes marked *retracts* (the session ends, the
159
+ * reader's operation stays parked, no event — the speculative-park
160
+ * correction); `"more"` with zero marked, and a throwing callback, reject.
161
+ *
162
+ * Parks until the element type is known, exactly as `write` does — a
163
+ * `Stream.create()` writer has no element type until the lowering site
164
+ * binds one — and then requires `u8`.
165
+ */
166
+ writeDirect(produce: (dest: DirectDestination) => DirectVerdict): Promise<number>;
120
167
  /** Offer values until all are taken or the reader goes away. */
121
168
  writeAll(values: Chunk<T>): Promise<number>;
122
169
  cancelWrite(): void;
@@ -131,7 +178,7 @@ export declare function publishHostStream<T>(s: Stream<T>, h: HostStream<T>): vo
131
178
  * A future whose write end dropped without ever writing rejects with
132
179
  * `DroppedError` — not `undefined`, which `future<void>` legitimately yields.
133
180
  */
134
- export declare class Future<T> implements PromiseLike<T> {
181
+ export declare class Future<T> implements ProtocolFuture<T> {
135
182
  #private;
136
183
  private constructor();
137
184
  static fromLifted<T>(value: ComponentValue, codec: ElemCodec<T>): Future<T>;
@@ -182,7 +229,7 @@ export declare class Future<T> implements PromiseLike<T> {
182
229
  * The internal value is `task/streams.ts`'s `ErrorContext` (debug message
183
230
  * only, per definitions.py).
184
231
  */
185
- export declare class ErrorContext {
232
+ export declare class ErrorContext implements ProtocolErrorContext {
186
233
  readonly message: string;
187
234
  /** @internal — the internal value, preserved so it can be lowered back. */
188
235
  readonly internal: InternalErrorContext;
@@ -37,6 +37,49 @@ export declare class HostBuffer {
37
37
  * writer used.
38
38
  */
39
39
  taken(): PayloadChunk;
40
+ byteView(n: number): Uint8Array;
41
+ advanceBytes(k: number): void;
42
+ endWindow(): void;
43
+ }
44
+ /** The scoped landing zone handed to a `writeDirect` producer (A21, #128). */
45
+ export interface DirectDestination {
46
+ /**
47
+ * The reader's still-unfilled bytes. Re-derived on every call (a
48
+ * `memory.grow` between two rendezvous of one session never yields a stale
49
+ * view) and shrinking by whatever has been marked so far in THIS
50
+ * invocation. DEAD once the callback returns.
51
+ */
52
+ remaining(): Uint8Array;
53
+ /**
54
+ * Acknowledge bytes written into the view. Cumulative within the
55
+ * invocation; acknowledged only if the callback then returns cleanly.
56
+ */
57
+ markWritten(n: number): void;
58
+ }
59
+ /** The scoped view handed to a `readDirect` consumer (A21, #128). */
60
+ export interface DirectSource {
61
+ /**
62
+ * The writer's unread bytes; read-only by contract. Same scoping and
63
+ * re-derivation rules as `DirectDestination.remaining`.
64
+ */
65
+ remaining(): Uint8Array;
66
+ /** Acknowledge bytes consumed from the view. See `markWritten`. */
67
+ markRead(n: number): void;
68
+ }
69
+ /** The callback's poll cadence, spelled event-style (A21). */
70
+ export type DirectVerdict = "more" | "done";
71
+ /**
72
+ * Out-parameter of the low-level direct forms: `true` iff the session ended
73
+ * because the callback itself returned `"done"`, rather than because the peer
74
+ * dropped / the operation was cancelled / the peer's instance trapped.
75
+ *
76
+ * The conventions layer needs the distinction for A7 precision — a session
77
+ * the producer already completed keeps its resolution even if the peer then
78
+ * trapped — and `Promise<number>` is the contract's return shape, so it rides
79
+ * here rather than in the resolved value.
80
+ */
81
+ export interface DirectSessionInfo {
82
+ endedByVerdict: boolean;
40
83
  }
41
84
  /** Host end the embedder WRITES; the guest reads. */
42
85
  export interface HostWritableEnd<T> {
@@ -65,6 +108,24 @@ export interface HostWritableEnd<T> {
65
108
  * if the reader dropped.
66
109
  */
67
110
  writeAll(values: T[]): Promise<number>;
111
+ /**
112
+ * Park a **direct session** on this end (`stream<u8>` only — embedder-api
113
+ * amendment A21, polyengine#128).
114
+ *
115
+ * At every rendezvous with a reader of nonzero capacity, `produce` runs
116
+ * exactly once, synchronously, inside the rendezvous, with a
117
+ * `DirectDestination` over the reader's unfilled landing zone — guest linear
118
+ * memory when the peer is a guest, so the producer's own `set()` is the
119
+ * canonical-ABI copy. `"more"` keeps the session parked for the next
120
+ * rendezvous; `"done"` ends it. Resolves with the session's total.
121
+ *
122
+ * Marks acknowledge on clean return only. `"done"` with zero marked is
123
+ * *retraction* (the session ends, the reader's operation stays parked, no
124
+ * event); `"more"` with zero marked, and a throwing callback, reject.
125
+ *
126
+ * Participates in the one-in-flight-per-end rule exactly as `write` does.
127
+ */
128
+ writeDirect(produce: (dest: DirectDestination) => DirectVerdict, info?: DirectSessionInfo): Promise<number>;
68
129
  /**
69
130
  * Cancel an in-flight `write`/`writeAll` (definitions.py
70
131
  * `SharedStreamImpl.cancel` -> `CopyResult.CANCELLED`). No-op when nothing
@@ -92,6 +153,15 @@ export interface HostReadableEnd<T> {
92
153
  * array.
93
154
  */
94
155
  read(max: number): Promise<T[]>;
156
+ /**
157
+ * Park a **direct session** on this end (`stream<u8>` only — embedder-api
158
+ * amendment A21, polyengine#128). The mirror of
159
+ * `HostWritableEnd.writeDirect`: `consume` receives a `DirectSource` over
160
+ * the writer's unread bytes (a view of guest memory, or of the offered
161
+ * host chunk itself) and may take a prefix — a partial take is normal, and
162
+ * the writer re-offers on its own schedule.
163
+ */
164
+ readDirect(consume: (src: DirectSource) => DirectVerdict, info?: DirectSessionInfo): Promise<number>;
95
165
  /** Cancel an in-flight `read`; see `HostWritableEnd.cancelWrite`. */
96
166
  cancelRead(): void;
97
167
  drop(): void;
@@ -51,7 +51,90 @@ export declare class GuestBuffer {
51
51
  read(n: number): PayloadChunk;
52
52
  /** definitions.py `WritableBufferGuestImpl.write`. */
53
53
  write(vs: PayloadChunk): void;
54
+ /**
55
+ * A fresh view over the next `n` bytes of this buffer's remaining range.
56
+ *
57
+ * Fresh on every call, via `bytesOf` (cabi/memory.ts:195) over the
58
+ * `LiveMemory` getters — so a `memory.grow` between two rendezvous of one
59
+ * parked direct session never yields a view onto the detached buffer.
60
+ */
61
+ byteView(n: number): Uint8Array;
62
+ /**
63
+ * Advance by `k` WITHOUT copying: the bytes already moved through the view
64
+ * `byteView` handed out. Called by the seam only after the direct callback
65
+ * returned cleanly, which is what makes marks acknowledge-on-clean-return.
66
+ */
67
+ advanceBytes(k: number): void;
68
+ }
69
+ /**
70
+ * The buffer surface the rendezvous actually uses (definitions.py `Buffer`,
71
+ * line 918). `GuestBuffer` and the host layer's `HostBuffer` both satisfy it.
72
+ */
73
+ export interface RendezvousBuffer {
74
+ remain(): number;
75
+ isZeroLength(): boolean;
76
+ read(n: number): PayloadChunk;
77
+ write(vs: PayloadChunk): void;
78
+ }
79
+ /**
80
+ * A21: the peer half of a direct rendezvous — a buffer that can expose its
81
+ * remaining range as bytes and be advanced without a copy.
82
+ *
83
+ * Implemented by `GuestBuffer` (a view into guest linear memory: the
84
+ * embedder's own `set()` becomes the one ABI copy) and by `HostBuffer` (a
85
+ * view of the offered chunk when it is the source; a synthesized scratch that
86
+ * becomes the delivered chunk when it is the destination).
87
+ */
88
+ export interface ByteWindow {
89
+ /**
90
+ * A view over the next `n` bytes. May be called several times within one
91
+ * direct invocation (`remaining()` re-derives on every call); an
92
+ * implementation that *synthesizes* the window must return the same
93
+ * storage for the whole invocation and release it in `endWindow`.
94
+ */
95
+ byteView(n: number): Uint8Array;
96
+ /** Record `k` bytes as moved. Called only after a clean callback return. */
97
+ advanceBytes(k: number): void;
98
+ /** End of one direct invocation; drop any synthesized window. */
99
+ endWindow?(): void;
100
+ }
101
+ /**
102
+ * A21: the parked direct session, as the rendezvous sees it. It presents the
103
+ * ordinary buffer surface (so `remain()`/`isZeroLength()` keep the reference
104
+ * control flow working) but its `read`/`write` are never called — the seam
105
+ * routes it through `runDirect` instead.
106
+ */
107
+ export interface DirectBuffer extends RendezvousBuffer {
108
+ readonly direct: true;
109
+ /**
110
+ * Run this session's callback exactly once against the peer's window,
111
+ * with `n` bytes of capacity. Applies the acknowledged marks to `peer`
112
+ * itself, and settles the session on failure — the seam only routes the
113
+ * rendezvous state that follows.
114
+ */
115
+ runDirect(peer: ByteWindow, n: number): DirectOutcome;
116
+ /**
117
+ * Reject this session out-of-band (the two-direct-sessions rendezvous,
118
+ * where neither side owns memory).
119
+ */
120
+ failDirect(error: Error): void;
54
121
  }
122
+ /**
123
+ * What the seam did, and hence how the rendezvous must continue.
124
+ *
125
+ * * `"chunk"` — no direct session was involved: the reference copy ran.
126
+ * * `"copied"` — the callback acknowledged ≥ 1 byte; continue exactly as
127
+ * after a reference copy (fire the pending side's `on_copy`).
128
+ * * `"retracted"` — `"done"` with zero marked. Continue as if the direct
129
+ * side's buffer had had `remain() == 0` all along, which is a state
130
+ * definitions.py already routes.
131
+ * * `"failed"` — misuse or a throwing callback; the session has already
132
+ * rejected. No copy, no event, the peer's parked operation survives.
133
+ * * `"both-direct"` — neither side owns memory; the ARRIVING side is
134
+ * rejected by the caller and the parked side is left undisturbed.
135
+ */
136
+ export type DirectOutcome = "copied" | "retracted" | "failed";
137
+ export type RendezvousOutcome = DirectOutcome | "chunk" | "both-direct";
55
138
  /** Common shape of the object a `stream`/`future` *value* refers to. */
56
139
  export interface SharedBase {
57
140
  readonly t: ValType | null;