diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 94037a4..8b5a791 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -37,6 +37,7 @@ import { copyCensus, isComponentException, isTrap } from "@polyengine/protocol"; import { ComponentException, NameCollisionError } from "./errors.ts"; import { type ImportLeaf, requiredImports } from "./imports.ts"; import { hostDtorCall } from "../exec/boundary.ts"; +import { DeferredHostResult } from "../exec/host_settlement.ts"; import { buildGuestResourceClass, type GuestResourceSpec, @@ -721,7 +722,7 @@ class Facade { } return fromHost(v, resultType, o); }; - const fail = (e: unknown, args: unknown[]): ComponentValue => { + const fail = (e: unknown): ComponentValue => { // Brand, not class (§"Module identity and @polyengine/protocol"): a `ComponentException` thrown by a host module // that resolved a DIFFERENT runtime copy — or hand-rolled with the // registry symbol — is the same value here (issue #83). @@ -732,9 +733,6 @@ class Facade { value: rt.error === null ? null : fromHost(e.payload, rt.error, o), }; } - // Trap paths abandon top-level async arguments transferred to the host. - // A normal result error does not: its implementation may retain them. - releaseAsyncArgs(args); if (isTrap(e)) throw e; if (isComponentException(e)) { throw new Trap( @@ -764,12 +762,18 @@ class Facade { // Extras beyond WIT params are runtime values, notably abortable()'s // AbortSignal. Forward without component-value conversion. for (let i = ft.params.length; i < raw.length; i++) args.push(raw[i]); + // Teardown belongs to rejection, even when delivery has been discarded. + // A fallible ComponentException may retain arguments like a normal return. + const onReject = (e: unknown): void => { + if (!(isComponentException(e) && isResult)) releaseAsyncArgs(args); + }; let out: unknown; try { out = dispatch(args); } catch (e) { scope.end(); - return fail(e, args); + onReject(e); + return fail(e); } if (isThenable(out)) { // A future-typed result is the source, not async call completion. @@ -779,15 +783,23 @@ class Facade { scope.end(); return ok(out); } - return (out as PromiseLike).then( - (v) => { - scope.end(); - return ok(v); - }, - (e) => { - scope.end(); - return fail(e, args); - }, + return new DeferredHostResult( + Promise.resolve(out).then( + (value) => { + scope.end(); + return { value }; + }, + (error) => { + scope.end(); + onReject(error); + return { error }; + }, + ), + (settlement) => + "error" in settlement + ? fail(settlement.error) + : ok(settlement.value), + () => scope.end(), ); } scope.end(); diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 6d908dc..91f73f1 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -35,6 +35,7 @@ import { EventCode, type EventTuple, hasRealHostCall, + instancePoisonCause, isInstancePoisoned, NeedsJspi, needsJspi, @@ -54,6 +55,7 @@ import { withActivation, } from "../task/mod.ts"; import { currentTask } from "../task/scheduler.ts"; +import { DeferredHostResult, type HostSettlement } from "./host_settlement.ts"; import { PlanError } from "../plan/loader.ts"; import { blockCurrentActivation, @@ -1790,12 +1792,26 @@ export function createLoweredImport(input: { const toResults = (v: unknown): ComponentValue[] => ft.results.length === 0 ? [] : [v as ComponentValue]; - if (isPromiseLike(raw)) { + if (raw instanceof DeferredHostResult || isPromiseLike(raw)) { + const deferred = raw instanceof DeferredHostResult ? raw : null; + const settlement = deferred?.promise ?? Promise.resolve(raw); + // Raw HostImports retain their single observing reaction: a boxing hop + // would let a queued cancellation overtake an already-settled result. + const fulfilled = (value: unknown): HostSettlement => + deferred !== null ? value as HostSettlement : { value }; + const convert = (done: HostSettlement): unknown => { + if (deferred !== null) return deferred.convert(done); + if ("error" in done) throw done.error; + return done.value; + }; if (!opts.async) { if (mode !== "jspi" || !suspendable) { // An unmarked import cannot suspend even in JSPI mode. This // non-poisoning capability exit must release onStart's lenders // (contracts/intrinsics.md, trap-unwind/lender-release obligation). + // Observe even refused raw HostImports; no conversion continuation. + void settlement.catch(() => {}); + deferred?.endScope(); subtask.unwindLenders(); needsJspi( suspendable @@ -1812,11 +1828,11 @@ export function createLoweredImport(input: { // waits for the caller's next cancellable point. Parking does not // release callback exclusivity. Record the host outcome here, but do // CABI lowering and lender delivery in produce at scheduler resume. - let outcome: { value: unknown } | { error: unknown } | undefined; - const promise = Promise.resolve(raw).then( - (v) => { + let outcome: HostSettlement | undefined; + const promise = settlement.then( + (done) => { store.pendingHostCalls.delete(promise); - outcome = { value: v }; + outcome = fulfilled(done); }, (e) => { store.pendingHostCalls.delete(promise); @@ -1833,13 +1849,11 @@ export function createLoweredImport(input: { readyFunc: () => outcome !== undefined, cancellable: false, produce: () => { - const done = outcome as { value: unknown } | { error: unknown }; - if ("error" in done) { - // Reject the import Promise to unwind the guest. The conventions - // layer has already converted fallible ComponentExceptions to values. - throw done.error; - } - onResolve(toResults(done.value)); + // Poisoning records a marker; it need not abandon this suspension. + // A FACT callee may differ from the still-healthy owning task. + // Preserve the original cause, including a thrown undefined. + if (isInstancePoisoned(inst)) throw instancePoisonCause(inst); + onResolve(toResults(convert(outcome!))); subtask.deliverResolve(); assert_(vi.done(), `${name}: unconsumed flat arguments`); const flatResults = subtask.flatResults; @@ -1847,19 +1861,22 @@ export function createLoweredImport(input: { if (flatResults.length === 1) return flatResults[0]; return flatResults; }, - onSettled: () => subtask.unwindLenders(), + onSettled: () => { + deferred?.endScope(); + subtask.unwindLenders(); + }, }); } // Async lowering runs on host settlement, not in a suspended caller's // produce step. Result-lowering failures use the host-failure channel. - const promise = Promise.resolve(raw).then( - (v) => { + const promise = settlement.then( + (done) => { store.pendingHostCalls.delete(promise); // Discard cancelled or poisoned recipients before lowering can // write guest memory or re-enter through realloc. if (subtask.resolved() || isInstancePoisoned(opts.instance)) return; try { - onResolve(toResults(v)); + onResolve(toResults(convert(fulfilled(done)))); } catch (e) { store.hostFailure = e; } @@ -1879,6 +1896,7 @@ export function createLoweredImport(input: { // discharge lenders. The null result path performs no realloc. subtask.onCancel = () => { store.pendingHostCalls.delete(promise); + deferred?.endScope(); onResolve(null); if (controller !== null) { // Defer host abort listeners until after the guest built-in returns. diff --git a/runtime/src/exec/host_settlement.ts b/runtime/src/exec/host_settlement.ts new file mode 100644 index 0000000..29c0025 --- /dev/null +++ b/runtime/src/exec/host_settlement.ts @@ -0,0 +1,11 @@ +// Private facade/boundary hand-off. Observing host completion and ending its +// borrow scope must not convert values that the boundary may discard. +export type HostSettlement = { value: unknown } | { error: unknown }; + +export class DeferredHostResult { + constructor( + readonly promise: Promise, + readonly convert: (settlement: HostSettlement) => unknown, + readonly endScope: () => void, + ) {} +} diff --git a/runtime/tests/embedder/fact-settlement.wasm b/runtime/tests/embedder/fact-settlement.wasm new file mode 100644 index 0000000..cf14cff Binary files /dev/null and b/runtime/tests/embedder/fact-settlement.wasm differ diff --git a/runtime/tests/embedder/fact-settlement.wat b/runtime/tests/embedder/fact-settlement.wat new file mode 100644 index 0000000..b6ab14f --- /dev/null +++ b/runtime/tests/embedder/fact-settlement.wat @@ -0,0 +1,45 @@ +;; A FACT sync callee can be poisoned while its caller's Task stays healthy. +;; Regenerate: wasm-tools parse fact-settlement.wat -o fact-settlement.wasm +(component + (import "r" (type $R (sub resource))) + (type $Result0 (result (own $R) (error (own $R)))) + (export $Result "make-result" (type $Result0)) + (import "make-sync" (func $sync (param "r" (borrow $R)) (result $Result))) + (component $Use + (import "r" (type $R (sub resource))) + (import "make-sync" (func $sync (param "r" (borrow $R)) (result (result (own $R) (error (own $R)))))) + (core module $Mem (memory (export "mem") 1)) + (core instance $mem (instantiate $Mem)) + (canon lower (func $sync) (memory $mem "mem") (core func $sync-lower)) + (canon resource.drop $R (core func $drop-r)) + (core module $M + (import "" "sync" (func $sync (param i32 i32))) + (import "" "drop-r" (func $drop-r (param i32))) + (func (export "sync") (param i32) + (call $sync (local.get 0) (i32.const 0)) + (call $drop-r (local.get 0))) + (func (export "trap") unreachable)) + (core instance $m (instantiate $M (with "" (instance + (export "sync" (func $sync-lower)) + (export "drop-r" (func $drop-r)))))) + (func (export "run-sync") (param "r" (borrow $R)) (canon lift (core func $m "sync"))) + (func (export "trap") (canon lift (core func $m "trap")))) + (instance $use (instantiate $Use (with "r" (type $R)) (with "make-sync" (func $sync)))) + ;; A real FACT sync caller keeps its own Task while parked in $Use. + (component $Caller + (import "r" (type $R (sub resource))) + (import "run" (func $run (param "r" (borrow $R)))) + (canon lower (func $run) (core func $run-lower)) + (canon resource.drop $R (core func $drop)) + (core module $M + (import "" "run" (func $run (param i32))) + (import "" "drop" (func $drop (param i32))) + (func (export "run") (param i32) (call $run (local.get 0)) (call $drop (local.get 0)))) + (core instance $m (instantiate $M (with "" (instance (export "run" (func $run-lower)) (export "drop" (func $drop)))))) + (func (export "run") (param "r" (borrow $R)) (canon lift (core func $m "run")))) + (alias export $use "run-sync" (func $run-sync)) + (instance $caller (instantiate $Caller (with "r" (type $R)) (with "run" (func $run-sync)))) + (export "r" (type $R)) + (export "run-sync" (func $run-sync)) + (export "run-fact" (func $caller "run")) + (export "trap" (func $use "trap"))) diff --git a/runtime/tests/embedder/host-settlement.wasm b/runtime/tests/embedder/host-settlement.wasm new file mode 100644 index 0000000..25c4058 Binary files /dev/null and b/runtime/tests/embedder/host-settlement.wasm differ diff --git a/runtime/tests/embedder/host-settlement.wat b/runtime/tests/embedder/host-settlement.wat new file mode 100644 index 0000000..df7d226 --- /dev/null +++ b/runtime/tests/embedder/host-settlement.wat @@ -0,0 +1,100 @@ +;; #328/#329: call settlement must precede ownership conversion. +;; Regenerate: wasm-tools parse host-settlement.wat -o host-settlement.wasm +(component + (import "r" (type $R (sub resource))) + (type $Result0 (result (own $R) (error (own $R)))) + (export $Result "make-result" (type $Result0)) + (type $Stream0 (stream u8)) + (export $Stream "byte-stream" (type $Stream0)) + (type $Future0 (future u32)) + (export $Future "number-future" (type $Future0)) + (import "make" (func $make async (result $Result))) + (import "make-sync" (func $sync (param "r" (borrow $R)) (result $Result))) + (import "producers" (instance $producers + (type $S (stream u8)) + (type $F (future u32)) + (type $Pair0 (record (field "stream" $S) (field "future" $F))) + (export "pair" (type $Pair (eq $Pair0))) + (export "sources" (func async (result $Pair))))) + (alias export $producers "sources" (func $sources)) + (import "consume" (func $consume async (param "s" $Stream) (param "f" $Future) (result u32))) + (core module $Mem (memory (export "mem") 1)) + (core instance $mem (instantiate $Mem)) + (canon lower (func $make) async (memory $mem "mem") (core func $make-lower)) + (canon lower (func $sync) (memory $mem "mem") (core func $sync-lower)) + (canon lower (func $sources) async (memory $mem "mem") (core func $sources-lower)) + (canon lower (func $consume) async (memory $mem "mem") (core func $consume-lower)) + (canon subtask.cancel async (core func $cancel)) + (canon subtask.drop (core func $drop)) + (canon resource.drop $R (core func $drop-r)) + (canon waitable-set.new (core func $new-set)) + (canon waitable.join (core func $join)) + (canon waitable-set.poll (memory $mem "mem") (core func $poll)) + (canon waitable-set.drop (core func $drop-set)) + (core module $M + (import "" "mem" (memory 1)) + (import "" "make" (func $make (param i32) (result i32))) + (import "" "sync" (func $sync (param i32 i32))) + (import "" "sources" (func $sources (param i32) (result i32))) + (import "" "consume" (func $consume (param i32 i32 i32) (result i32))) + (import "" "cancel" (func $cancel (param i32) (result i32))) + (import "" "drop" (func $drop (param i32))) + (import "" "drop-r" (func $drop-r (param i32))) + (import "" "new-set" (func $new-set (result i32))) + (import "" "join" (func $join (param i32 i32))) + (import "" "poll" (func $poll (param i32 i32) (result i32))) + (import "" "drop-set" (func $drop-set (param i32))) + (global $h (mut i32) (i32.const 0)) + (func $settle (param $packed i32) (param $mode i32) (result i32) + (local $state i32) + (global.set $h (i32.shr_u (local.get $packed) (i32.const 4))) + (if (i32.eq (local.get $mode) (i32.const 2)) (then unreachable)) + (if (result i32) (local.get $mode) + (then + (local.set $state (call $cancel (global.get $h))) + (if (i32.ne (local.get $state) (i32.const -1)) + (then (call $drop (global.get $h)))) + (local.get $state)) + (else (i32.and (local.get $packed) (i32.const 15))))) + (func (export "start") (param i32) (result i32) + (call $settle (call $make (i32.const 0)) (local.get 0))) + (func (export "sources") (param i32) (result i32) + (call $settle (call $sources (i32.const 0)) (local.get 0))) + (func (export "consume") (param i32 i32 i32) (result i32) + (call $settle (call $consume (local.get 0) (local.get 1) (i32.const 0)) (local.get 2))) + (func (export "sync") (param i32) (result i32) + (call $sync (local.get 0) (i32.const 0)) + (call $drop-r (local.get 0)) + (call $drop-r (i32.load (i32.const 4))) + (i32.load8_u (i32.const 0))) + (func (export "finish") (result i32) (local $set i32) + (local.set $set (call $new-set)) + (call $join (global.get $h) (local.get $set)) + (drop (call $poll (local.get $set) (i32.const 32))) + (call $join (global.get $h) (i32.const 0)) + (call $drop-set (local.get $set)) + (call $drop (global.get $h)) + (call $drop-r (i32.load (i32.const 4))) + (i32.load8_u (i32.const 0))) + (func (export "ping") (result i32) (i32.const 42)) + (func (export "trap") unreachable)) + (core instance $m (instantiate $M (with "" (instance + (export "mem" (memory $mem "mem")) + (export "make" (func $make-lower)) + (export "sync" (func $sync-lower)) + (export "sources" (func $sources-lower)) + (export "consume" (func $consume-lower)) + (export "cancel" (func $cancel)) + (export "drop" (func $drop)) + (export "drop-r" (func $drop-r)) + (export "new-set" (func $new-set)) + (export "join" (func $join)) + (export "poll" (func $poll)) + (export "drop-set" (func $drop-set)))))) + (func (export "start") (param "mode" u32) (result u32) (canon lift (core func $m "start"))) + (func (export "sources") (param "mode" u32) (result u32) (canon lift (core func $m "sources"))) + (func (export "consume") (param "s" $Stream) (param "f" $Future) (param "mode" u32) (result u32) (canon lift (core func $m "consume"))) + (func (export "run-sync") (param "r" (borrow $R)) (result u32) (canon lift (core func $m "sync"))) + (func (export "finish") (result u32) (canon lift (core func $m "finish"))) + (func (export "ping") (result u32) (canon lift (core func $m "ping"))) + (func (export "trap") (canon lift (core func $m "trap")))) diff --git a/runtime/tests/embedder/host_settlement_test.ts b/runtime/tests/embedder/host_settlement_test.ts new file mode 100644 index 0000000..78d723d --- /dev/null +++ b/runtime/tests/embedder/host_settlement_test.ts @@ -0,0 +1,278 @@ +// Real canonical lowers through the facade. Counters distinguish no conversion +// from converting and subsequently disposing a discarded result (#328/#329). +import { assertEq } from "../support/asserts.ts"; +import { caught, instantiateFixture } from "./support.ts"; +import { + ComponentException, + deferCancel, + suspending, + Trap, +} from "@polyengine/protocol"; +import { INTERNAL_HOST_REGISTRIES } from "../../src/embedder/instantiate.ts"; +import type { HostResourceRegistry } from "../../src/embedder/resources.ts"; +import { + type ComponentInstanceState, + currentTask, + isInstancePoisoned, + NeedsJspi, +} from "../../src/task/mod.ts"; +import { isSupported } from "../../src/jspi/mod.ts"; +import { Future, Stream } from "../../src/embedder/streams.ts"; +import { hostFuture } from "../../src/exec/host_streams.ts"; +import { sync } from "../../src/embedder/sync.ts"; + +const fixture = "runtime/tests/embedder/host-settlement.wasm"; +const factFixture = "runtime/tests/embedder/fact-settlement.wasm"; +const turn = () => new Promise((r) => setTimeout(r, 0)); +class R { + disposed = 0; + [Symbol.dispose]() { + this.disposed++; + } +} + +for (const rejection of [false, true]) { + Deno.test({ + name: `settlement: poisoned FACT sync recipient discards ${ + rejection ? "err" : "ok" + } own before produce`, + ignore: !isSupported(), + fn: async () => { + const p = deferred(); + const started = Promise.withResolvers(); + let caller!: ComponentInstanceState; + const c = await instantiateFixture(factFixture, { + r: R, + makeSync: suspending(() => { + caller = currentTask().inst; + started.resolve(); + return p.promise; + }), + }, { jspi: true }); + const call = caught(() => c.exports.runFact(new R())); + await started.promise; + await turn(); + const cause = await caught(() => sync(c.exports.trap)()); + assertEq(cause instanceof Trap, true); + assertEq( + isInstancePoisoned(c.handle.componentInstances[1]), + true, + "FACT callee poisoned", + ); + assertEq( + isInstancePoisoned(caller), + false, + "owning caller task still healthy", + ); + const value = new R(); + if (rejection) p.reject(new ComponentException(value)); + else p.resolve(value); + assertEq(await call instanceof Trap, true); + const registries = (c as unknown as { + [INTERNAL_HOST_REGISTRIES]: Map; + })[INTERNAL_HOST_REGISTRIES]; + assertEq(registries.get(0)!.liveCount, 0); + assertEq(value.disposed, 0); + }, + }); +} +function deferred() { + return Promise.withResolvers(); +} +async function setup(imports: Record = {}, jspi = false) { + const c = await instantiateFixture(fixture, { + r: R, + make: () => new R(), + makeSync: () => new R(), + producers: { + sources: () => { + throw new Error("unused sources"); + }, + }, + consume: () => 0, + ...imports, + }, { jspi }); + const registries = (c as unknown as { + [INTERNAL_HOST_REGISTRIES]: Map; + })[INTERNAL_HOST_REGISTRIES]; + return { c, registry: registries.get(0)! }; +} + +for (const marked of [false, true]) { + Deno.test(`settlement: refused ${marked ? "marked" : "unmarked"} immediate rejection is observed`, async () => { + const fn = () => Promise.reject(new Error("immediate refused failure")); + const { c } = await setup({ makeSync: marked ? suspending(fn) : fn }); + const unhandled: unknown[] = []; + const listener = (e: PromiseRejectionEvent) => { + e.preventDefault(); + unhandled.push(e.reason); + }; + globalThis.addEventListener("unhandledrejection", listener); + try { + assertEq( + await caught(() => c.exports.runSync(new R())) instanceof NeedsJspi, + true, + ); + await turn(); + assertEq(unhandled.length, 0); + assertEq(await c.exports.ping(), 42); + } finally { + globalThis.removeEventListener("unhandledrejection", listener); + } + }); + for (const rejection of [false, true]) { + Deno.test(`settlement: refused ${marked ? "marked" : "unmarked"} sync ${rejection ? "Error" : "own"} is observed without conversion`, async () => { + const p = deferred(); + const fn = () => p.promise; + const { c, registry } = await setup({ + makeSync: marked ? suspending(fn) : fn, + }); + const arg = new R(); + const value = new R(); + const unhandled: unknown[] = []; + const listener = (e: PromiseRejectionEvent) => { + e.preventDefault(); + unhandled.push(e.reason); + }; + globalThis.addEventListener("unhandledrejection", listener); + try { + assertEq( + await caught(() => c.exports.runSync(arg)) instanceof NeedsJspi, + true, + ); + assertEq( + registry.liveCount, + 0, + "borrow registration unwound on refusal", + ); + if (rejection) p.reject(new Error("late refused failure")); + else p.resolve(value); + await turn(); + assertEq(unhandled.length, 0, "refused rejection must be observed"); + assertEq(registry.liveCount, 0, "refused own must never be registered"); + assertEq(value.disposed, 0); + assertEq(arg.disposed, 0); + assertEq( + await c.exports.ping(), + 42, + "capability refusal does not poison", + ); + } finally { + globalThis.removeEventListener("unhandledrejection", listener); + } + }); + } +} + +for (const mode of [1, 2]) { + for (const rejection of [false, true]) { + Deno.test(`settlement: ${mode === 1 ? "cancelled" : "poisoned"} ${rejection ? "ComponentException own" : "own"} never registers`, async () => { + const p = deferred(); + const { c, registry } = await setup({ make: () => p.promise }); + if (mode === 1) assertEq(await c.exports.start(mode), 4); + else {assertEq( + await caught(() => c.exports.start(mode)) instanceof Trap, + true, + );} + const value = new R(); + if (rejection) p.reject(new ComponentException(value)); + else p.resolve(value); + await turn(); + assertEq( + registry.liveCount, + 0, + "discard before facade ownership conversion", + ); + assertEq(value.disposed, 0, "discard does not take ownership"); + assertEq(c.handle.componentInstances[0].store.hostFailure, undefined); + if (mode === 1) assertEq(await c.exports.ping(), 42); + }); + } + Deno.test(`settlement: ${mode === 1 ? "cancelled" : "poisoned"} stream and nested future producers never start`, async () => { + const p = deferred(); + const { c } = await setup({ producers: { sources: () => p.promise } }); + if (mode === 1) assertEq(await c.exports.sources(mode), 4); + else {assertEq( + await caught(() => c.exports.sources(mode)) instanceof Trap, + true, + );} + let pulls = 0; + let starts = 0; + p.resolve({ + stream: { + async *[Symbol.asyncIterator]() { + pulls++; + yield 1; + }, + }, + future: { + then(resolve: (v: number) => void) { + starts++; + resolve(7); + }, + }, + }); + await turn(); + assertEq([pulls, starts], [0, 0]); + assertEq(c.handle.componentInstances[0].store.hostFailure, undefined); + }); + + Deno.test(`settlement: late trapping rejection tears down ${mode === 1 ? "cancelled" : "poisoned"} async arguments`, async () => { + const p = deferred(); + const { c } = await setup({ consume: () => p.promise }); + const { stream, writer } = Stream.create(); + const fw = hostFuture({ kind: "u32" }); + const future = Future.fromHostFuture(fw, { + element: { kind: "u32" }, + toHost: (v) => v as number, + fromHost: (v: number) => v, + }); + const written = writer.writeAll(new Uint8Array([1, 2])); + // The host has taken both readable ends before cancellation/poisoning. + if (mode === 1) assertEq(await c.exports.consume(stream, future, mode), 4); + else {assertEq( + await caught(() => c.exports.consume(stream, future, mode)) instanceof + Trap, + true, + );} + p.reject(new Error("abandoned arguments")); + await turn(); + assertEq(await written, 0); + await fw.write(7); + assertEq(c.handle.componentInstances[0].store.hostFailure, undefined); + }); +} + +for (const rejection of [false, true]) { + Deno.test(`settlement: deferCancel delivers ${rejection ? "err" : "ok"} own exactly once`, async () => { + const p = deferred(); + const { c, registry } = await setup({ make: deferCancel(() => p.promise) }); + assertEq(await c.exports.start(1), 0xffffffff); + const value = new R(); + if (rejection) p.reject(new ComponentException(value)); + else p.resolve(value); + await turn(); + assertEq(registry.liveCount, 1); + assertEq(await c.exports.finish(), rejection ? 1 : 0); + assertEq(registry.liveCount, 0); + assertEq(value.disposed, 1); + }); + + Deno.test({ + name: `settlement: JSPI normal delivery of ${rejection ? "err" : "ok"} own`, + ignore: !isSupported(), + fn: async () => { + const p = deferred(); + const { c, registry } = await setup({ + makeSync: suspending(() => p.promise), + }, true); + const call = c.exports.runSync(new R()); + const value = new R(); + if (rejection) p.reject(new ComponentException(value)); + else p.resolve(value); + assertEq(await call, rejection ? 1 : 0); + assertEq(registry.liveCount, 0); + assertEq(value.disposed, 1); + }, + }); +} diff --git a/runtime/tests/host_import_cancel_test.ts b/runtime/tests/host_import_cancel_test.ts index 5b5b6bf..983d18c 100644 --- a/runtime/tests/host_import_cancel_test.ts +++ b/runtime/tests/host_import_cancel_test.ts @@ -47,6 +47,15 @@ import { unpackSubtaskResult, } from "../src/task/mod.ts"; import type { FuncType } from "../src/cabi/types.ts"; +import { DeferredHostResult } from "../src/exec/host_settlement.ts"; +import { BorrowScope } from "../src/embedder/values.ts"; +import { + GuestResource, + makeWrapper, + takeRep, + wrapperState, +} from "../src/embedder/resources.ts"; +import { ResourceTypeInfo } from "../src/cabi/types.ts"; import { BLOCKED, createSubtaskCancel, @@ -180,6 +189,53 @@ function inFlight(f: Fixture): { subtaski: number; subtask: Subtask } { const flush = () => new Promise((r) => setTimeout(r, 0)); +Deno.test("raw host settlement: resolve before queued cancel delivers RETURNED", async () => { + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski, subtask } = inFlight(f); + d.resolve(42); + const state = await Promise.resolve().then(() => + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)) + ); + assertEq(state, SubtaskState.RETURNED); + assertEq(subtask.resolveDelivered(), true); + assertEq(new DataView(f.memory.buffer).getUint32(64, true), 42); +}); + +Deno.test("deferred host settlement: cancellation ends borrowed wrapper before host settles", async () => { + const rt = new ResourceTypeInfo(null, null); + const borrowed = makeWrapper(GuestResource, 42, rt, false); + const scope = new BorrowScope(); + scope.add(() => borrowed.drop()); + const d = deferred(); + const f = mkFixture(() => + new DeferredHostResult( + d.promise.then((value) => { + scope.end(); + return { value }; + }), + () => { + throw new Error("cancelled result converted"); + }, + () => scope.end(), + ) + ); + const { subtaski } = inFlight(f); + assertEq(takeRep(borrowed, rt, false, "borrow"), 42); + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + assertEq(wrapperState(borrowed)?.valid, false); + let refused = false; + try { + takeRep(borrowed, rt, false, "retained borrow"); + } catch { + refused = true; + } + assertEq(refused, true); + d.resolve(42); + await flush(); + assertEq(f.store.hostFailure, undefined); +}); + Deno.test("cancellation discard: the async cancel form discards and answers CANCELLED_BEFORE_RETURNED", () => { // The headline: NOT BLOCKED. `canon_subtask_cancel` calls `on_cancel`, which // is now the prompt-cancel host, so `subtask.resolved()` is already true when