@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.
- package/esm/embedder/copy.js +1 -1
- package/esm/embedder/instantiate.js +43 -9
- package/esm/embedder/mod.js +21 -10
- package/esm/embedder/streams.js +96 -5
- package/esm/exec/boundary.js +210 -19
- package/esm/exec/executor.js +16 -1
- package/esm/exec/host_streams.js +376 -0
- package/esm/intrinsics/async_builtins.js +5 -2
- package/esm/jspi/suspending.js +7 -2
- package/esm/task/streams.js +127 -2
- package/package.json +2 -2
- package/types/embedder/copy.d.ts +1 -1
- package/types/embedder/mod.d.ts +7 -4
- package/types/embedder/streams.d.ts +53 -6
- package/types/exec/boundary.d.ts +13 -0
- package/types/exec/host_streams.d.ts +70 -0
- package/types/jspi/suspending.d.ts +1 -1
- package/types/task/streams.d.ts +83 -0
package/esm/embedder/copy.js
CHANGED
|
@@ -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.
|
|
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
|
|
437
|
-
//
|
|
438
|
-
|
|
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()`
|
|
475
|
-
// can relay
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/esm/embedder/mod.js
CHANGED
|
@@ -22,19 +22,30 @@ registerRuntimeCopy({
|
|
|
22
22
|
protocolGeneration: PROTOCOL_GENERATION,
|
|
23
23
|
});
|
|
24
24
|
export { COPY_URL, RUNTIME_VERSION } from "./copy.js";
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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";
|
package/esm/embedder/streams.js
CHANGED
|
@@ -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
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
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
|
/**
|
package/esm/exec/boundary.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
//
|
|
1685
|
-
//
|
|
1686
|
-
//
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
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 =
|
|
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));
|
package/esm/exec/executor.js
CHANGED
|
@@ -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
|
/**
|