Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions runtime/src/embedder/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -779,15 +783,23 @@ class Facade {
scope.end();
return ok(out);
}
return (out as PromiseLike<unknown>).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();
Expand Down
50 changes: 34 additions & 16 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
EventCode,
type EventTuple,
hasRealHostCall,
instancePoisonCause,
isInstancePoisoned,
NeedsJspi,
needsJspi,
Expand All @@ -54,6 +55,7 @@
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,
Expand Down Expand Up @@ -206,7 +208,7 @@
stringEncoding: opts.stringEncoding,
memory: opts.memory,
realloc: opts.realloc === null ? null : (o, os, a, n) => {
const realloc = require(opts.realloc, "realloc")!;

Check warning on line 211 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import

Check warning on line 211 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import
const p = callCore(realloc, [o, os, a, n]);
trapIf(p.length !== 1 || typeof p[0] !== "number", "realloc result");
return (p[0] as number) >>> 0;
Expand Down Expand Up @@ -1613,7 +1615,7 @@
task.return_(results);
// Post-return runs after the results were read out of guest memory,
// with may_leave cleared (reference canon_lift).
const postReturn = require(opts.postReturn, `${name} post-return`);

Check warning on line 1618 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import

Check warning on line 1618 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import
if (postReturn !== null) {
assert_(inst.mayLeave, "post-return with may_leave already false");
inst.mayLeave = false;
Expand Down Expand Up @@ -1642,7 +1644,7 @@
// Callback ABI waits between invocations without JSPI, but callbacks in
// JSPI mode need the same promising wrapper as the initial core entry.
const callback = enterWasm(
require(opts.callback, `${name} callback`)!,

Check warning on line 1647 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import

Check warning on line 1647 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import
input.mode,
);
const [packed] = normalizeCoreValues(
Expand Down Expand Up @@ -1790,12 +1792,26 @@
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
Expand All @@ -1812,11 +1828,11 @@
// 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);
Expand All @@ -1833,33 +1849,34 @@
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;
if (flatResults.length === 0) return undefined;
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;
}
Expand All @@ -1879,6 +1896,7 @@
// 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.
Expand Down
11 changes: 11 additions & 0 deletions runtime/src/exec/host_settlement.ts
Original file line number Diff line number Diff line change
@@ -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<HostSettlement>,
readonly convert: (settlement: HostSettlement) => unknown,
readonly endScope: () => void,
) {}
}
Binary file added runtime/tests/embedder/fact-settlement.wasm
Binary file not shown.
45 changes: 45 additions & 0 deletions runtime/tests/embedder/fact-settlement.wat
Original file line number Diff line number Diff line change
@@ -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")))
Binary file added runtime/tests/embedder/host-settlement.wasm
Binary file not shown.
100 changes: 100 additions & 0 deletions runtime/tests/embedder/host-settlement.wat
Original file line number Diff line number Diff line change
@@ -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"))))
Loading
Loading