@polyengine/runtime 0.5.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.
@@ -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.5.0";
32
+ export const RUNTIME_VERSION = "0.5.1";
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.
@@ -13,7 +13,7 @@ import { loadEnvelope, loadPlan, PlanError } from "../plan/loader.js";
13
13
  import { Trap } from "../cabi/trap.js";
14
14
  import { CONSTRUCTOR_SYNC_ENTRY, hostResourceType, instantiateComponent, } from "../exec/mod.js";
15
15
  import { camelCase, parseLeafName, pascalCase } from "./casing.js";
16
- import { isSuspending, suspending } from "../jspi/suspending.js";
16
+ import { abortable, deferCancel, isAbortable, isDeferCancel, isSuspending, suspending, } from "../jspi/suspending.js";
17
17
  import { Translator } from "../shim/mod.js";
18
18
  import { copyCensus, isTrap, isComponentException } from "@polyengine/protocol";
19
19
  import { NameCollisionError, ComponentException } from "./errors.js";
@@ -23,6 +23,27 @@ import { buildGuestResourceClass, HostResourceRegistry, invalidateWrapper, lendW
23
23
  import { BorrowScope, describe, fromHost, toHost, } from "./values.js";
24
24
  import { ImportResolver } from "./version.js";
25
25
  import { Future, Stream } from "./streams.js";
26
+ /**
27
+ * Relay the per-declaration host-import marks from the embedder's function
28
+ * onto the wrapper the executor will actually receive, and return the
29
+ * wrapper.
30
+ *
31
+ * Every `#dispatcher` arm re-wraps the embedder's function in a closure, so a
32
+ * brand left on the original is INVISIBLE to `buildLoweredImport` — for A1
33
+ * that surfaced as a `NeedsJspi`, for A23 (`deferCancel()`) it would be a
34
+ * silently discarded commit, which is precisely the failure the brand exists
35
+ * to prevent. Both marks are relayed by the same helper so a third one cannot
36
+ * be added to one arm and forgotten in the other three.
37
+ */
38
+ function relayMarks(from, to) {
39
+ if (isSuspending(from))
40
+ suspending(to);
41
+ if (isDeferCancel(from))
42
+ deferCancel(to);
43
+ if (isAbortable(from))
44
+ abortable(to);
45
+ return to;
46
+ }
26
47
  /** Per-element codec for a `future<T>` returned in function-result position. */
27
48
  function elementCodec(element, o) {
28
49
  return {
@@ -433,9 +454,10 @@ class Facade {
433
454
  }
434
455
  return impl(...raw);
435
456
  };
436
- // A1 brand relay, layer 2 of 2 (see #dispatcher): the executor reads the
437
- // brand off this wrapper, which is what lands in its hostImports record.
438
- return isSuspending(dispatch) ? suspending(wrapper) : wrapper;
457
+ // A1/A23 brand relay, layer 2 of 2 (see #dispatcher): the executor reads
458
+ // the brands off this wrapper, which is what lands in its hostImports
459
+ // record.
460
+ return relayMarks(dispatch, wrapper);
439
461
  }
440
462
  /** A host-implemented resource type: register the class, own the mapping. */
441
463
  #wrapResourceType(leaf, importIndex, provider) {
@@ -471,8 +493,9 @@ class Facade {
471
493
  throw new PlanError(`host import '${label(leaf)}' missing or not a function (got ` +
472
494
  `${describe(fn)}); expected '${camelCase(m.name)}'`);
473
495
  }
474
- // A1: the `suspending()` brand rides the dispatch closure so #wrapLeaf
475
- // can relay it onto the value the executor actually receives.
496
+ // A1/A23: the `suspending()` and `deferCancel()` brands ride the
497
+ // dispatch closure so #wrapLeaf can relay them onto the value the
498
+ // executor actually receives.
476
499
  //
477
500
  // A2 receiver rule: an interface member is invoked with its containing
478
501
  // object as receiver (matching the static arm's `apply(cls)`), so a
@@ -485,7 +508,7 @@ class Facade {
485
508
  // liberal-acceptance failure the contract forbids.)
486
509
  const receiver = leaf.path.length === 0 ? undefined : provider;
487
510
  const dispatch = (args) => fn.apply(receiver, args);
488
- return isSuspending(fn) ? suspending(dispatch) : dispatch;
511
+ return relayMarks(fn, dispatch);
489
512
  }
490
513
  const clsName = pascalCase(m.resource);
491
514
  // World-level member leaves resolved the class itself (`#provider`);
@@ -530,7 +553,7 @@ class Facade {
530
553
  }
531
554
  return fn.apply(self, rest);
532
555
  };
533
- return isSuspending(protoFn) ? suspending(dispatch) : dispatch;
556
+ return relayMarks(protoFn, dispatch);
534
557
  }
535
558
  case "static": {
536
559
  const fn = cls[camelCase(m.member)];
@@ -542,7 +565,7 @@ class Facade {
542
565
  // static-method decorator marks the function value), readable here
543
566
  // at wrap time.
544
567
  const dispatch = (args) => fn.apply(cls, args);
545
- return isSuspending(fn) ? suspending(dispatch) : dispatch;
568
+ return relayMarks(fn, dispatch);
546
569
  }
547
570
  }
548
571
  }
@@ -615,6 +638,17 @@ class Facade {
615
638
  return (...raw) => {
616
639
  const scope = new BorrowScope();
617
640
  const args = ft.params.map((p, i) => toHost(raw[i], p, o, scope));
641
+ // CONTRACT (A24): anything the executor appended PAST the WIT-declared
642
+ // params is a runtime-minted extra, not a component value — today
643
+ // exactly the `abortable()` signal `createLoweredImport` adds for a
644
+ // marked import. It is forwarded verbatim (no `toHost` conversion: it
645
+ // has no `ValType` and must reach the host as the platform object it
646
+ // is). Without this the facade would silently drop the signal and a
647
+ // marked import's `signal` parameter would be forever `undefined` —
648
+ // the failure the mark exists to prevent. The slice is empty for every
649
+ // unmarked import, so no existing path changes shape.
650
+ for (let i = ft.params.length; i < raw.length; i++)
651
+ args.push(raw[i]);
618
652
  let out;
619
653
  try {
620
654
  out = dispatch(args);
@@ -385,7 +385,9 @@ export async function driveStoreAsync(store, done, what) {
385
385
  * have always produced concurrent loops, and the host-stream pump's stand-down
386
386
  * below is cooperative, so a *bounded overlap window* remains by construction
387
387
  * (an export call can start while the pump is parked mid-`await`; the pump
388
- * only notices at its next `done()` evaluation). The invariant is:
388
+ * notices at its next `done()` evaluation, which the driver-arrival one-shot
389
+ * below now makes prompt — before issue #239 it was "whenever the host happens
390
+ * to answer", i.e. not bounded at all). The invariant is:
389
391
  *
390
392
  * **no activation is resumed twice for one settlement, and no activation is
391
393
  * resumed with a value from a settlement it has already consumed.**
@@ -455,6 +457,51 @@ export function whenStoreDriverIdle(store) {
455
457
  return w.p;
456
458
  }
457
459
  // ---------------------------------------------------------------------------
460
+ // Driver arrival: closing the overlap window (issue #239)
461
+ // ---------------------------------------------------------------------------
462
+ //
463
+ // The stand-down above ("the pumps are *fallback* drivers") is evaluated only
464
+ // at a driver's next `done()`, so the doc's "bounded overlap window" is really
465
+ // bounded by whatever the incumbent driver is parked on — and its longest park
466
+ // is `Promise.race([...parked tags, ...pendingHostCalls])`, i.e. HOST-CONTROLLED
467
+ // time. That is a stall in its own right, and it is fatal in combination with
468
+ // the SPECULATIVE resume entry the race holds: `Store.pendingResumptions` is a
469
+ // store-wide scheduling gate, so a second driver on the same store spins at
470
+ // `driveAsync`'s top and dies at the 10,000-hop internal-bug assert in ~311ms
471
+ // (issue #239 — the same-store half of the cross-store stall #210 fixed; see
472
+ // `tests/cross_store_driver_test.ts`, whose header describes this gate being
473
+ // "held for the entire duration of a guest's wait on a slow host import").
474
+ //
475
+ // So drivers announce themselves: every `driveAsync` that finds itself the
476
+ // second (or later) loop on a store fires this one-shot, which every driver
477
+ // races alongside its parked tags. The incumbent wakes within a microtask,
478
+ // drops the speculative entry on its way out of the race, and re-evaluates
479
+ // `done()` — which is exactly the stand-down the pumps were always supposed to
480
+ // perform, now prompt instead of "whenever the host happens to answer".
481
+ const driverArrivals = new WeakMap();
482
+ /** A one-shot that resolves (to `null`, the race's "nothing settled" value)
483
+ * when another driver starts on `store`. */
484
+ function armDriverArrival(store) {
485
+ let n = driverArrivals.get(store);
486
+ if (n === undefined) {
487
+ let r;
488
+ const p = new Promise((res) => (r = () => res(null)));
489
+ n = { p, r };
490
+ driverArrivals.set(store, n);
491
+ }
492
+ return n.p;
493
+ }
494
+ function fireDriverArrival(store) {
495
+ const n = driverArrivals.get(store);
496
+ if (n === undefined)
497
+ return;
498
+ // Deleted before resolving so the next `armDriverArrival` mints a fresh,
499
+ // unresolved one-shot: a driver that wakes on this and re-parks must not
500
+ // pick the settled promise back up and spin.
501
+ driverArrivals.delete(store);
502
+ n.r();
503
+ }
504
+ // ---------------------------------------------------------------------------
458
505
  // The settlement pump: liveness between export calls
459
506
  // ---------------------------------------------------------------------------
460
507
  //
@@ -599,7 +646,14 @@ async function settlementPumpLoop(store) {
599
646
  }
600
647
  }
601
648
  async function driveAsync(store, done, what) {
602
- driverDepth.set(store, storeDriverDepth(store) + 1);
649
+ const depth = storeDriverDepth(store) + 1;
650
+ driverDepth.set(store, depth);
651
+ // An incumbent driver may be parked in the awaiting-race holding the
652
+ // speculative resume entry — a store-wide gate this loop would otherwise
653
+ // spin on until the 10,000-hop assert (issue #239). Announce ourselves so it
654
+ // stands down within a microtask.
655
+ if (depth > 1)
656
+ fireDriverArrival(store);
603
657
  try {
604
658
  let claimHops = 0;
605
659
  for (;;) {
@@ -831,9 +885,14 @@ async function driveAsync(store, done, what) {
831
885
  // Every awaiting thread's settle is deferred on a non-enterable
832
886
  // instance. The way out is the lock holder finishing, and the only
833
887
  // await-spanning host-entry lock is the async-dtor bracket, which
834
- // registers in `pendingHostCalls` — so park on those.
888
+ // registers in `pendingHostCalls` — so park on those, plus the
889
+ // driver-arrival one-shot: every park in this loop races it, so the
890
+ // stand-down below is prompt wherever we happen to be waiting.
835
891
  if (store.pendingHostCalls.size > 0) {
836
- await Promise.race([...store.pendingHostCalls]).catch(() => { });
892
+ await Promise.race([
893
+ ...store.pendingHostCalls,
894
+ armDriverArrival(store),
895
+ ]).catch(() => { });
837
896
  continue;
838
897
  }
839
898
  // Per the issue #156 analysis this is unreachable (a spanning lock
@@ -863,13 +922,49 @@ async function driveAsync(store, done, what) {
863
922
  // await — which takes a fresh entry of its own — had that entry
864
923
  // clobbered early, re-opening the window it exists to close. With a set
865
924
  // we can name exactly what we added.
866
- store.addPendingResumption(chosen);
925
+ //
926
+ // SOLE DRIVER ONLY, AND ONLY UNTIL ONE ARRIVES (issue #239). The entry
927
+ // is a claim over a window this loop cannot bound: the race settles when
928
+ // the HOST answers, which may be never. As a store-wide scheduling gate
929
+ // (`Store.tick` refuses; every driver yields at its top) that is a wedge
930
+ // the moment a second driver exists — it spins at the top of its own
931
+ // loop and dies at the 10,000-hop assert in ~311ms, an internal-bug
932
+ // detector firing on a perfectly ordinary suspended guest. Two concurrent
933
+ // export calls with one slow suspending import were enough; the reported
934
+ // shape was a detached guest task cancelling an in-flight import, which
935
+ // parks mid-frame with no export call outstanding and leaves the
936
+ // settlement pump holding this entry.
937
+ //
938
+ // What the entry protects — "the engine may run `chosen`'s wasm during
939
+ // this await" — it protects by refusing OTHER `Store.tick` callers, and
940
+ // this loop is not one of them while it awaits. The tick callers that
941
+ // can reach a store mid-race are another `driveAsync` loop and
942
+ // `HostActivity.pump`'s synchronous drain (exec/host_streams.ts) — the
943
+ // latter is not gated by driver depth, so scoping the entry to "sole
944
+ // driver" does hand it a window the entry used to close at depth >= 2.
945
+ // What holds regardless is the invariant the `driverDepth` note names:
946
+ // a genuine resumption is preceded by `SuspensionPoint.resume`'s OWN
947
+ // entry (jspi/bridge.ts, minted before the settle), and every
948
+ // resumption site here re-checks membership, promise identity and
949
+ // `dispatchableTail` synchronously — mechanisms (a) and (b), which is
950
+ // where that note already puts the weight.
951
+ const sole = storeDriverDepth(store) === 1;
952
+ if (sole)
953
+ store.addPendingResumption(chosen);
867
954
  let winner;
868
955
  try {
869
- winner = await Promise.race([chosenTag, ...others]);
956
+ // `armDriverArrival` rides the race for every driver, not just the one
957
+ // holding the entry: waking on a new arrival is also how a fallback
958
+ // pump reaches its next `done()` — i.e. its stand-down — promptly.
959
+ winner = await Promise.race([
960
+ chosenTag,
961
+ ...others,
962
+ armDriverArrival(store),
963
+ ]);
870
964
  }
871
965
  finally {
872
- store.removePendingResumption(chosen);
966
+ if (sole)
967
+ store.removePendingResumption(chosen);
873
968
  }
874
969
  // Resume whichever thread actually settled -- not necessarily the one we
875
970
  // claimed. Resuming only the claimed thread would spin: its promise may
@@ -906,7 +1001,18 @@ async function driveAsync(store, done, what) {
906
1001
  // not ours — this is genuine, unavoidable nondeterminism at the boundary
907
1002
  // (the reference has the same freedom in `Store.tick`). Everything
908
1003
  // *inside* the component stays deterministic per scheduler.ts.
909
- await Promise.race([...store.pendingHostCalls]).catch(() => { });
1004
+ //
1005
+ // The driver-arrival one-shot rides here too. This is the routine park of
1006
+ // a quiet guest with a real host call outstanding — no speculative entry
1007
+ // is held, so there is no wedge to break, but a fallback pump parked here
1008
+ // would otherwise not reach its `done()` (i.e. its stand-down) until the
1009
+ // HOST answered, leaving two loops interleaving `serviceSettled`/`tick`
1010
+ // for that whole window. That interleaving is what the `driverDepth` note
1011
+ // above calls out as bad for throughput and blame.
1012
+ await Promise.race([
1013
+ ...store.pendingHostCalls,
1014
+ armDriverArrival(store),
1015
+ ]).catch(() => { });
910
1016
  }
911
1017
  }
912
1018
  finally {
@@ -1626,7 +1732,7 @@ function* liftBody(input) {
1626
1732
  * (`thread.wait_until(subtask.resolved)`, line 2286), so: `needsJspi`.
1627
1733
  */
1628
1734
  export function createLoweredImport(input) {
1629
- const { name, ft, opts, hostFn, stats, mode, suspendable } = input;
1735
+ const { name, ft, opts, hostFn, stats, mode, suspendable, deferCancel, abortable, } = input;
1630
1736
  const inst = opts.instance;
1631
1737
  const store = inst.store;
1632
1738
  const computed = flattenFunctype(cabiOptions(opts), ft, "lower");
@@ -1677,22 +1783,42 @@ export function createLoweredImport(input) {
1677
1783
  // definitions.py assigns the callee's `OnCancel` here:
1678
1784
  // `subtask.on_cancel = callee(on_start, on_resolve, caller = ...)`
1679
1785
  //
1680
- // A host import is a plain JS function and offers no cancellation
1681
- // channel there is nothing to forward a request to. The faithful model
1682
- // is therefore a handler that *accepts and ignores* the request, which is
1683
- // exactly what the reference permits: `canon_subtask_cancel` (line 2469)
1684
- // calls `on_cancel` and then re-checks `subtask.resolved()`; a callee that
1685
- // declines to cancel promptly leaves the subtask unresolved, and the async
1686
- // form returns BLOCKED while the sync form waits. The subtask still
1687
- // resolves normally when the promise settles cancellation is a request,
1688
- // not a guarantee.
1786
+ // The `OnCancel` is the CALLEE's to supply: `Store.invoke` takes it back
1787
+ // from the callee it invoked (`on_cancel = f(on_start, on_resolve, caller
1788
+ // = None)`, definitions.py line 572), i.e. the reference expects the
1789
+ // embedding to hand back the cancellation behaviour of whatever it is
1790
+ // hosting. A wasmtime host gets a real one for free — dropping a Rust
1791
+ // future IS cancellation. A JS Promise has no such channel, so polyengine
1792
+ // answers on the host's behalf; amendment A23 makes the DEFAULT answer the
1793
+ // reference's prompt-cancel host (`on_cancel = () => on_resolve(None)`),
1794
+ // installed by the async arm below.
1795
+ //
1796
+ // The no-op assigned HERE is only the placeholder for paths where
1797
+ // `subtask.cancel` is unreachable, so no answer can ever be demanded of
1798
+ // it: an eagerly-resolving callee never mints a subtask handle (the
1799
+ // fast-path return below is a bare state), and a sync-typed import's A1
1800
+ // park never mints one either. It is also the FINAL handler for a
1801
+ // `deferCancel()`-branded import — accept and ignore, the pre-A23
1802
+ // behaviour, now per-declaration.
1689
1803
  //
1690
1804
  // Leaving `on_cancel` null instead made a *legal* `subtask.cancel` crash
1691
1805
  // with an internal AssertionError, which is neither reference behaviour
1692
1806
  // nor a sanctioned incompleteness signal.
1693
1807
  subtask.onCancel = () => { };
1808
+ // A24 (contracts/embedder-api.md §"Functions and async"): a marked import
1809
+ // is handed a fresh `AbortSignal` after its WIT-declared parameters. The
1810
+ // mark controls the SIGNATURE UNCONDITIONALLY — a marked function receives
1811
+ // a signal on every call, including the paths where it can never fire
1812
+ // (sync-typed, eager resolve, `deferCancel`) — so the host's arity is a
1813
+ // property of its declaration, not of how a particular call happened to
1814
+ // go. `new AbortController()` is evaluated only for marked imports, which
1815
+ // keeps bare engine shells with no `AbortController` off this path for the
1816
+ // whole unmarked corpus.
1817
+ const controller = abortable ? new AbortController() : null;
1694
1818
  const args = onStart();
1695
- const raw = hostFn(...args);
1819
+ const raw = controller === null
1820
+ ? hostFn(...args)
1821
+ : hostFn(...args, controller.signal);
1696
1822
  const toResults = (v) => ft.results.length === 0 ? [] : [v];
1697
1823
  if (isPromiseLike(raw)) {
1698
1824
  if (!opts.async) {
@@ -1817,6 +1943,16 @@ export function createLoweredImport(input) {
1817
1943
  }
1818
1944
  const promise = Promise.resolve(raw).then((v) => {
1819
1945
  store.pendingHostCalls.delete(promise);
1946
+ // A23: the subtask may already be resolved when the host promise
1947
+ // settles — the discard `onCancel` below resolved it
1948
+ // CANCELLED_BEFORE_RETURNED (the only pre-settle resolver on this
1949
+ // arm). The value has no addressee, and `onResolve` would run
1950
+ // straight into its `state === STARTED` assert ("on_resolve on a
1951
+ // subtask that never started") and park that AssertionError on
1952
+ // `store.hostFailure`, poisoning whatever unrelated embedder call
1953
+ // came next.
1954
+ if (subtask.resolved())
1955
+ return;
1820
1956
  try {
1821
1957
  onResolve(toResults(v));
1822
1958
  }
@@ -1825,9 +1961,64 @@ export function createLoweredImport(input) {
1825
1961
  }
1826
1962
  }, (e) => {
1827
1963
  store.pendingHostCalls.delete(promise);
1964
+ // Same guard, different reason: a rejection of a RENOUNCED call is
1965
+ // not a host failure. The guest cancelled and was told so; surfacing
1966
+ // the rejection would fail an unrelated later call with the error of
1967
+ // an operation nobody is waiting for.
1968
+ if (subtask.resolved())
1969
+ return;
1828
1970
  store.hostFailure = e;
1829
1971
  });
1830
1972
  store.pendingHostCalls.add(promise);
1973
+ if (!deferCancel) {
1974
+ // A23 DISCARD (contracts/embedder-api.md §"Functions and async";
1975
+ // polyengine#241) — the reference's prompt-cancel host,
1976
+ // `on_cancel = () => on_resolve(None)` (definitions.py canon_lower's
1977
+ // null branch, line ~2267).
1978
+ //
1979
+ // This runs synchronously inside `canon_subtask_cancel`, which already
1980
+ // set `cancellationRequested` before calling us (the assert in
1981
+ // `onResolve`'s null branch relies on that ordering). `onResolve(null)`
1982
+ // arms the SUBTASK event — a delivery-time thunk — and resolves
1983
+ // CANCELLED_BEFORE_RETURNED, so the built-in's `finish()` tail consumes
1984
+ // the event, `deliverResolve` releases the lenders (the #106 class,
1985
+ // discharged exactly as a RETURNED delivery would), and BOTH cancel
1986
+ // forms return the state without blocking. The null path lowers
1987
+ // nothing, so there is no realloc re-entry from inside a built-in.
1988
+ //
1989
+ // The renounced call can no longer wake the guest, so it must stop
1990
+ // counting as externally-wakeable for the driver's deadlock probe:
1991
+ // deregister it NOW. (The settle continuation above also deletes;
1992
+ // `Set.delete` is idempotent.)
1993
+ subtask.onCancel = () => {
1994
+ store.pendingHostCalls.delete(promise);
1995
+ onResolve(null);
1996
+ if (controller !== null) {
1997
+ // A24: tell the host its result was discarded, so it can stop the
1998
+ // underlying operation — clear a timer, abort a fetch, close a
1999
+ // dial. Reachable only from this arm by construction: a
2000
+ // `deferCancel()` import never discards, so its signal never
2001
+ // fires.
2002
+ //
2003
+ // Deferred one microtask. This closure runs SYNCHRONOUSLY inside
2004
+ // `canon_subtask_cancel`, i.e. inside a live guest activation, and
2005
+ // host abort listeners must not execute there — that is the
2006
+ // issue-#24 attribution class, plus arbitrary re-entrancy into a
2007
+ // guest mid-built-in. `Promise.resolve().then`, not
2008
+ // `queueMicrotask`: the latter does not exist in bare engine
2009
+ // shells (see jspi/bridge.ts's SENTINEL_TICK note).
2010
+ //
2011
+ // The resulting order is: the guest observes
2012
+ // CANCELLED_BEFORE_RETURNED first, the host observes the abort a
2013
+ // tick later. Any settlement the abort provokes (typically an
2014
+ // `AbortError` rejection) arrives at the settle continuation above
2015
+ // with the subtask already resolved, so it lands on the A23
2016
+ // resolved-subtask guards and is discarded like any other late
2017
+ // settlement — never a `store.hostFailure`.
2018
+ Promise.resolve().then(() => controller.abort());
2019
+ }
2020
+ };
2021
+ }
1831
2022
  }
1832
2023
  else {
1833
2024
  onResolve(toResults(raw));
@@ -10,7 +10,7 @@
10
10
  // - component hash verification against plan.component
11
11
  import { Trap } from "../cabi/trap.js";
12
12
  import { ComponentInstanceState, Store } from "../task/mod.js";
13
- import { anySuspendingImport, assertModeConsistent, chooseMode, isSuspending, planNeedsSuspension, suspendingImport, trampolineCanBlock, trampolineNeedsSuspension, } from "../jspi/mod.js";
13
+ import { anySuspendingImport, assertModeConsistent, chooseMode, isAbortable, isDeferCancel, isSuspending, planNeedsSuspension, suspendingImport, trampolineCanBlock, trampolineNeedsSuspension, } from "../jspi/mod.js";
14
14
  import { loadPlan, PlanError, resourceIndexOfDefined, } from "../plan/loader.js";
15
15
  import { CONSTRUCTOR_SYNC_ENTRY, createDtorEntry, createLiftedFunction, createLoweredImport, LiveMemory, newStats, } from "./boundary.js";
16
16
  import { createTrampoline, createUnsafeIntrinsic, TranscodeMemory, } from "../intrinsics/mod.js";
@@ -931,6 +931,19 @@ class Executor {
931
931
  const ft = this.funcType(decl.type, `import '${label}'`);
932
932
  const opts = this.resolveOptions(decl.options);
933
933
  const suspendable = isSuspending(value);
934
+ // A23 (contracts/embedder-api.md §"Functions and async"): does this import
935
+ // opt out of cancel-discard? Unlike `suspendable` above, this needs no
936
+ // executor-state detour — the brand is consumed by `createLoweredImport`
937
+ // itself (it only decides which `onCancel` the lowered import installs, not
938
+ // whether the CoreFn gets wrapped), so nothing downstream has to read a
939
+ // brand off a replaced function identity.
940
+ const deferCancel = isDeferCancel(value);
941
+ // A24 (same section): does this import want a per-call `AbortSignal`?
942
+ // Read exactly like `deferCancel` above and for the same reason — the
943
+ // brand is consumed inside `createLoweredImport`, which mints the
944
+ // controller and appends the signal itself, so no function identity is
945
+ // replaced downstream of the read.
946
+ const abortable_ = isAbortable(value);
934
947
  // The Suspending-wrap decision is taken in `importValue`, which sees the
935
948
  // trampoline only AFTER `createTrampoline`'s trap-recording wrapper has
936
949
  // replaced this function's identity — a brand on the CoreFn would die
@@ -948,6 +961,8 @@ class Executor {
948
961
  stats: this.stats,
949
962
  mode: this.suspensionMode,
950
963
  suspendable,
964
+ deferCancel,
965
+ abortable: abortable_,
951
966
  });
952
967
  }
953
968
  /**
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polyengine/runtime",
3
- "version": "0.5.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.2"
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.5.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.
@@ -336,6 +336,19 @@ export declare function createLoweredImport(input: {
336
336
  mode: SuspensionMode;
337
337
  /** Host fn carries the `suspending()` brand (embedder-api.md A1). */
338
338
  suspendable: boolean;
339
+ /**
340
+ * Host fn carries the `deferCancel()` brand (embedder-api.md A23): the
341
+ * import must run to completion, so a cancellation is accepted and ignored
342
+ * instead of taking the default discard.
343
+ */
344
+ deferCancel: boolean;
345
+ /**
346
+ * Host fn carries the `abortable()` brand (embedder-api.md A24): every call
347
+ * receives a fresh `AbortSignal` appended after the WIT-declared params, and
348
+ * the runtime aborts it when — and only when — the call is discarded by a
349
+ * guest cancellation.
350
+ */
351
+ abortable: boolean;
339
352
  }): CoreFn;
340
353
  /**
341
354
  * The callback-ABI dispatch loop of `canon_lift` (definitions.py lines
@@ -1 +1 @@
1
- export { anySuspendingImport, isSuspending, suspending } from "@polyengine/protocol";
1
+ export { abortable, anySuspendingImport, deferCancel, isAbortable, isDeferCancel, isSuspending, suspending, } from "@polyengine/protocol";