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
67 changes: 20 additions & 47 deletions runtime/src/cabi/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
// - canon_resource_drop routes the dtor through `callDtorGated` below,
// which reconstructs the reference's store.lift/store.lower bracket
// (may_enter gating + trap poisoning) around the destructor call (#85).
// Host-initiated drops do NOT come here: they run the dtor through the
// real lift harness (`hostDtorCall`, exec/boundary.ts) — see #160.

import { assert_, Trap, trap, trapIf } from "./trap.ts";
import {
Expand Down Expand Up @@ -170,10 +172,6 @@ interface ReentranceGate {
enterFrom(caller: unknown): void;
leaveTo(caller: unknown): void;
handles: Iterable<unknown>;
store?: {
pendingHostCalls: Set<Promise<unknown>>;
hostFailure: unknown;
};
}

function asGate(x: unknown): ReentranceGate | null {
Expand Down Expand Up @@ -225,32 +223,31 @@ function isThenable(v: unknown): v is PromiseLike<unknown> {
* Capability signals (`NeedsJspi`, `PendingCapability`) are not traps — see
* `isCapabilitySignal` in exec/boundary.ts — so they release the gate.
*
* `allowAsync` covers the host-initiated drop path (embedder/resources.ts):
* a dtor reached through a `promising` entry settles on a later turn, so the
* bracket is closed by the settle instead of synchronously. A *guest*-
* initiated drop must complete synchronously (the reference lifts the dtor
* with `async_ = False`), so a thenable there is a trap.
* SCOPE (#160): this is the **guest-initiated** path only. A guest-initiated
* drop must complete synchronously (the reference lifts the dtor with
* `async_ = False`), so a thenable here is a trap. The host-initiated path
* used to share this function with an `allowAsync` flag that held the entry
* bracket across the dtor's promise; it now goes through the full lift
* harness instead (`hostDtorCall` in exec/boundary.ts), which is what
* definitions.py actually does and what unwedges #160.
*/
export function callDtorGated(
rt: ResourceTypeInfo,
rep: number,
caller: unknown,
allowAsync = false,
): void {
const impl = asGate(rt.impl);
// A JS-initiated drop prefers the `promising`-wrapped entry when the
// executor wired one (#85: a dtor may legally reach a `Suspending` import
// on this path, so it needs a suspension-legal stack). Guest-initiated
// drops always take the raw synchronous dtor — see ResourceTypeInfo.
const dtorFn = allowAsync ? (rt.dtorHost ?? rt.dtor) : rt.dtor;
// Always the raw synchronous dtor: `dtorHost` is the host path's lifted
// entry, which is not callable from inside a guest activation.
const dtorFn = rt.dtor;
// No gate available: an imported (host-implemented) resource has
// `impl === null` by construction (executor.ts `bindImportedResources`),
// and there is no component instance to gate entry into. Test doubles that
// supply a bare `{handles, mayLeave}` instance land here too.
if (impl === null) {
const r = dtorFn?.(rep) as unknown;
trapIf(
!allowAsync && isThenable(r),
isThenable(r),
"resource destructor did not complete synchronously",
);
return;
Expand Down Expand Up @@ -288,38 +285,14 @@ export function callDtorGated(
throw e;
}
if (isThenable(out)) {
if (!allowAsync) {
// A guest-initiated drop is lifted with `async_ = False`: the dtor must
// resolve before `canon_resource_drop` returns. Reaching here means the
// dtor's activation escaped, which is a trap that poisons the impl.
const e = new Trap(
"resource destructor did not complete synchronously",
);
poison(e);
throw e;
}
// Host-initiated async dtor: the entry bracket stays held until the
// destructor's activation actually finishes, which is what `Store.lift`
// does for a callee that blocks. Registered in `pendingHostCalls` so the
// driver counts it as externally-wakeable work and teardown can see it.
const store = impl.store;
const promise = Promise.resolve(out).then(
() => {
store?.pendingHostCalls.delete(promise);
impl.leaveTo(callerInst);
},
(e) => {
store?.pendingHostCalls.delete(promise);
// The failure cannot propagate out of this microtask; the store's
// host-failure channel is where the driving call picks it up.
if (store !== undefined && store.hostFailure === undefined) {
store.hostFailure = e;
}
poison(e);
},
// A guest-initiated drop is lifted with `async_ = False`: the dtor must
// resolve before `canon_resource_drop` returns. Reaching here means the
// dtor's activation escaped, which is a trap that poisons the impl.
const e = new Trap(
"resource destructor did not complete synchronously",
);
store?.pendingHostCalls.add(promise);
return;
poison(e);
throw e;
}
impl.leaveTo(callerInst);
}
Expand Down
21 changes: 14 additions & 7 deletions runtime/src/cabi/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,20 @@ export interface InstanceLike {
* definitions.py `ResourceType`: identity + implementing instance + optional
* destructor. Compared by object identity everywhere.
*
* `dtorHost` is the JS-initiated-drop variant of `dtor` (#85): in jspi mode
* the executor wires it as the `promising`-wrapped raw export (docs §7 —
* a host-initiated drop may legally reach a `Suspending` import, so it needs
* a suspension-legal entry), and `callDtorGated(allowAsync=true)` prefers it.
* Guest-initiated drops always use `dtor` directly: they must complete
* synchronously (reference lifts the dtor with `async_ = False`), and a
* promising wrapper would turn every such call into a thenable.
* `dtorHost` is the **host-initiated**-drop entry (#85, reshaped by #160):
* the dtor built as a fully LIFTED sync function — definitions.py
* `canon_resource_drop` (line 2319) `inst.store.lift(dtor, ft, opts,
* rt.impl)` — so the destructor's activation gets a real Task/Thread, the
* reentrance bracket is released at its first park, and its suspension
* points are resumable by the scheduler. It is wired by exec/executor.ts
* (jspi-`promising` entry only when the dtor is suspension-capable, docs §7)
* and called through `hostDtorCall` (exec/boundary.ts), which also fills it
* in lazily for tokens built directly. It returns `undefined` or a Promise,
* so it is NOT callable from inside a guest activation.
*
* Guest-initiated drops (`callDtorGated`) always use `dtor` directly: they
* must complete synchronously (reference lifts the dtor with
* `async_ = False`), and any thenable there is a trap.
*/
export class ResourceTypeInfo {
constructor(
Expand Down
4 changes: 2 additions & 2 deletions runtime/src/embedder/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { Translator } from "../shim/mod.ts";
import { copyCensus, isTrap, isComponentException } from "@deltic/protocol";
import { NameCollisionError, ComponentException } from "./errors.ts";
import { type ImportLeaf, requiredImports } from "./imports.ts";
import { callDtorGated } from "../cabi/handles.ts";
import { hostDtorCall } from "../exec/boundary.ts";
import {
buildGuestResourceClass,
type GuestResourceSpec,
Expand Down Expand Up @@ -452,7 +452,7 @@ class Facade {
b.registry.dtor(rep);
return;
}
callDtorGated(t.rt, rep, null, true);
hostDtorCall(t.rt, rep);
},
};
}
Expand Down
34 changes: 17 additions & 17 deletions runtime/src/embedder/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import type { ResourceTypeInfo, ValType } from "../cabi/types.ts";
import { RESOURCE_STATE } from "@deltic/protocol";
import { callDtorGated } from "../cabi/handles.ts";
import { hostDtorCall } from "../exec/boundary.ts";
import { COPY_URL, describeCrossCopy } from "./copy.ts";
import { InvalidHandleError } from "./errors.ts";
import { camelCase, pascalCase } from "./casing.ts";
Expand Down Expand Up @@ -126,22 +126,22 @@ export function simulateFinalizationForTest(w: object): void {
*
* The host holds a rep, never a table index, so there is nothing to remove
* from a handle table: the observable remainder of definitions.py
* `canon_resource_drop` for an owning handle is the gated dtor call
* (`callDtorGated`, cabi/handles.ts), with `caller = None` — a host-initiated
* `canon_resource_drop` for an owning handle is the lifted dtor call
* (`hostDtorCall`, exec/boundary.ts), with `caller = None` — a host-initiated
* call, `Store.invoke`'s `caller = None`.
*
* Never throws: the two callers are `drop()`/`[Symbol.dispose]()` — where a
* trap *is* reportable, so it propagates — and the FinalizationRegistry
* callback, where a throw would be swallowed by the engine with no
* diagnostic. `runHostDrop` is the latter's form: a trapping dtor poisons the
* implementing instance (which `callDtorGated` does) and is additionally
* implementing instance (which the lift harness does) and is additionally
* recorded on the store's host-failure channel, so the next driven call
* surfaces it instead of silently continuing on a half-destroyed instance
* (#86, second defect: the former `catch {}`).
*/
function runHostDrop(s: WrapperState): void {
try {
callDtorGated(s.rt, s.rep, null, true);
hostDtorCall(s.rt, s.rep);
} catch (e) {
recordHostFailure(s.rt, e);
}
Expand Down Expand Up @@ -228,19 +228,19 @@ function dropWrapper(w: GuestResource): void {
s.pendingDrop = true;
return;
}
// The dtor is entered through `callDtorGated`, which is also where a dtor
// that returns a Promise (a `promising`-entered dtor calling a `Suspending`
// import, docs/architecture.md §7) is tracked: the entry bracket is held
// until it settles and the promise is registered in the store's
// `pendingHostCalls`, so `drop()` itself never blocks.
// The dtor runs as an ordinary LIFTED sync call (`hostDtorCall`, #160):
// definitions.py `canon_resource_drop` (line 2319) lifts it with
// `CanonicalOptions(async_ = False)` rather than calling it bare, and that
// is what gives the activation a Task/Thread. A dtor that suspends (a
// `promising`-entered dtor calling a `Suspending` import,
// docs/architecture.md §7) therefore releases the implementing instance's
// entry bracket at its first park, so the scheduler can resume it — the
// old held-bracket form wedged exactly there (#160).
//
// The `promising` entry itself is wired by exec/executor.ts (the `resource`
// initializer sets `ResourceTypeInfo.dtorHost` from the raw wasm export in
// jspi mode); `callDtorGated(allowAsync=true)` prefers it. In non-JSPI mode
// — or when the dtor resolved to a non-wasm callable, which `promising`
// rejects — this is a direct call, where a `Suspending` import was already
// unreachable from a dtor.
callDtorGated(s.rt, s.rep, null, true);
// `drop(): void` stays non-blocking: an unfinished dtor's tail is driven
// by the store like any other parked activation, and a failure that has no
// frame to return into is parked on `store.hostFailure`.
hostDtorCall(s.rt, s.rep);
}

/**
Expand Down
Loading
Loading