diff --git a/runtime/src/cabi/handles.ts b/runtime/src/cabi/handles.ts index b6818ca..b490d12 100644 --- a/runtime/src/cabi/handles.ts +++ b/runtime/src/cabi/handles.ts @@ -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 { @@ -170,10 +172,6 @@ interface ReentranceGate { enterFrom(caller: unknown): void; leaveTo(caller: unknown): void; handles: Iterable; - store?: { - pendingHostCalls: Set>; - hostFailure: unknown; - }; } function asGate(x: unknown): ReentranceGate | null { @@ -225,24 +223,23 @@ function isThenable(v: unknown): v is PromiseLike { * 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 @@ -250,7 +247,7 @@ export function callDtorGated( if (impl === null) { const r = dtorFn?.(rep) as unknown; trapIf( - !allowAsync && isThenable(r), + isThenable(r), "resource destructor did not complete synchronously", ); return; @@ -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); } diff --git a/runtime/src/cabi/types.ts b/runtime/src/cabi/types.ts index d1705f9..1ce09b2 100644 --- a/runtime/src/cabi/types.ts +++ b/runtime/src/cabi/types.ts @@ -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( diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 1db7143..0d6cbeb 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -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, @@ -452,7 +452,7 @@ class Facade { b.registry.dtor(rep); return; } - callDtorGated(t.rt, rep, null, true); + hostDtorCall(t.rt, rep); }, }; } diff --git a/runtime/src/embedder/resources.ts b/runtime/src/embedder/resources.ts index d4175a6..0e3eaa3 100644 --- a/runtime/src/embedder/resources.ts +++ b/runtime/src/embedder/resources.ts @@ -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"; @@ -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); } @@ -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); } /** diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index b0005ef..faaa3a9 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -21,6 +21,7 @@ import { MAX_FLAT_RESULTS, type MemInst, type PtrType, + ResourceTypeInfo, trap, trapIf, } from "../cabi/mod.ts"; @@ -662,11 +663,11 @@ export function whenStoreDriverIdle(store: Store): Promise { // // Every real `pendingHostCalls` entry is born during guest execution, i.e. // inside some driver, so arming at driver exit (`driveAsync`'s finally and -// `drive`'s synchronous completion) observes every registration. One known -// exception is documented rather than wired: a HOST-initiated async resource -// dtor (embedder `drop()` between calls, cabi/handles.ts `callDtorGated`) -// registers outside any driver; its settlement surfaces at the next drive -// exactly as before this pump existed. +// `drive`'s synchronous completion) observes every registration. A +// HOST-initiated resource dtor (embedder `drop()` between calls) is no +// exception since #160: it is a lifted call like any other, so it brings its +// own driver, and any host call its activation makes is registered inside +// that driver. // // STALE SNAPSHOTS: the keeper races the real host calls it saw when it // parked. A drive it performs can register NEW calls (the keep-alive ticker @@ -1181,6 +1182,22 @@ export function createLiftedFunction(input: { * `may_leave` when a trap unwinds out of a FACT adapter. */ allInstances?: () => Iterable<{ mayLeave: boolean }>; + /** + * Opt out of the reference's *synchronous* driving loop (`driveSyncLift`, + * definitions.py `canon_lift` line 2213) for a sync-typed lift whose caller + * does not need a synchronous answer — today only the host-initiated + * resource destructor (#160; `createDtorEntry` below, `drop(): void` is + * documented non-blocking). + * + * This is not a weakening of the deadlock trap: `drive` below enforces the + * same "no ready thread, no pending host call, nothing awaiting" trap, just + * asynchronously — which is exactly the substitution jspi mode already + * makes unconditionally (see the comment at the `driveSyncLift` call). + * It matters only when a *plain*-mode core returns a thenable, i.e. a + * host-supplied JS destructor: the sync loop sees a thread parked on a + * Promise, which it can never advance, and declares a bogus deadlock. + */ + allowAsyncCompletion?: boolean; }): (...args: ComponentValue[]) => unknown { const { name, @@ -1455,7 +1472,9 @@ export function createLiftedFunction(input: { // `store.awaiting`, still enforces the deadlock trap (no ready thread, // no pending host call, nothing awaiting), and returns a Promise, which // a jspi-mode lifted export returns anyway. - if (!ft.async && mode !== "jspi") driveSyncLift(task); + if (!ft.async && mode !== "jspi" && !input.allowAsyncCompletion) { + driveSyncLift(task); + } } catch (e) { unwind(); if (isCapabilitySignal(e)) leave(); @@ -1609,6 +1628,167 @@ async function awaitHopQuiescence(store: Store, inst: unknown): Promise { } } +// --------------------------------------------------------------------------- +// Host-initiated resource destructors (#160) +// --------------------------------------------------------------------------- + +/** + * The canonical function type of a destructor: definitions.py + * `canon_resource_drop` (line 2326) — `FuncType([U32Type()], [], async_ = False)`. + */ +const DTOR_FT: FuncType = { + params: [{ kind: "u32" }], + results: [], + async: false, +}; + +/** + * `CanonicalOptions(async_ = False)` (definitions.py line 2325): every field + * at its inert default. A dtor takes one flat `i32` and returns nothing, so + * no memory / realloc / post-return / callback is ever reached. + */ +function dtorOptions(instance: ComponentInstanceState): ResolvedOptions { + return { + stringEncoding: "utf8", + memory: null, + realloc: null, + postReturn: null, + callback: null, + async: false, + cancellable: false, + coreType: { params: ["i32"], results: [] }, + instance, + }; +} + +/** + * Build the host-callable entry for a resource destructor — a full canonical + * **lift**, exactly as definitions.py `canon_resource_drop` (line 2319) does: + * + * ```python + * opts = CanonicalOptions(async_ = False) + * ft = FuncType([U32Type()], [], async_ = False) + * dtor = rt.dtor or (lambda rep: []) + * callee = inst.store.lift(dtor, ft, opts, rt.impl) + * ``` + * + * Before #160 the host-initiated path (embedder `drop()`, the GC backstop, + * `dropOwn`) hand-rolled the bracket in cabi/handles.ts `callDtorGated`: a + * bare call to the dtor with `enterFrom(null)` HELD across the returned + * promise. Three defects followed from having no Task/Thread behind the + * activation: + * + * - **#160 itself**: the held bracket left the impl instance non-enterable, + * so `Store.tick`'s enterability filter (#155) could never resume a + * suspension point belonging to the dtor's own activation. The completion + * promise sat in `pendingHostCalls` looking like external work, and every + * driver parked on it forever. + * - it was the runtime's only `enterFrom(null)` bracket spanning an await — + * the macro-scale reachability window of the #156 class, through which a + * sibling instance looked non-enterable from the synthetic root. + * - built-ins reached inside the dtor had no ambient task (`currentTask()` + * → `PendingCapability`, or a foreign-task misattribution, the #24 class). + * + * Under the lift harness all three go away structurally: the activation has a + * real `Task` + implicit `Thread`, the entry bracket is released when the + * first segment parks (`leave()` before `drive`), and settled tails flow + * through `serviceSettled` like any other lifted sync call. + * + * The returned function takes the rep and returns either `undefined` (the + * activation completed synchronously — the overwhelmingly common case) or a + * Promise, exactly like any lifted sync export in jspi mode. + */ +export function createDtorEntry(input: { + /** Diagnostic name; appears in deadlock/trap messages. */ + name?: string; + /** + * The destructor's core function, unwrapped: `createLiftedFunction` applies + * `enterWasm` itself per `suspensionMode`. `null` is the reference's + * `rt.dtor or (lambda rep: [])` — the bracket still runs. + */ + dtor: CoreFn | null; + /** `rt.impl`, the implementing instance the lift enters. */ + instance: ComponentInstanceState; + suspensionMode?: SuspensionMode; + stats?: ExecutionStats; + trapState?: { pending: unknown }; + syncCallStack?: LenderScope[]; + allInstances?: () => Iterable<{ mayLeave: boolean }>; +}): (rep: number) => unknown { + const mode = input.suspensionMode ?? "plain"; + const raw: CoreFn = input.dtor ?? (() => undefined); + // A dtor's core type is `(i32) -> ()`, but the *host*-supplied dtors this + // helper also serves (embedder test doubles, `ResourceTypeInfo` built + // directly) are ordinary JS functions whose incidental return value would + // otherwise trip `normalizeCoreValues`' arity check. Discard it — except a + // thenable, which is the activation itself and must reach `awaitCore`'s + // park. Not applied in jspi mode: `WebAssembly.promising` only accepts a + // wasm callable, so the core must be passed through untouched there (and a + // real wasm dtor returns nothing by construction). + const core: CoreFn = mode === "jspi" ? raw : ((rep: number) => { + const r = raw(rep); + return isPromiseLike(r) ? r : undefined; + }); + const lifted = createLiftedFunction({ + name: input.name ?? "[resource-dtor]", + ft: DTOR_FT, + opts: dtorOptions(input.instance), + core, + stats: input.stats ?? newStats(), + suspensionMode: mode, + trapState: input.trapState, + syncCallStack: input.syncCallStack, + allInstances: input.allInstances, + // The host does not wait for a destructor: `drop(): void` is + // non-blocking, and an unfinished dtor's tail is driven by the store. + allowAsyncCompletion: true, + }); + return (rep: number) => lifted(rep); +} + +/** + * Run a host-initiated drop of a guest (or host-implemented) resource rep — + * the observable remainder of `canon_resource_drop` for an owning handle when + * the holder is the host (`caller = None`, `Store.invoke`). + * + * A failure that arrives asynchronously has no frame to propagate into, so it + * is parked on the store's host-failure channel (first failure wins), where + * the next driven call surfaces it. The completion promise is deliberately + * NOT registered in `store.pendingHostCalls`: that registration was #160's + * lie — it claims *external* work for a promise whose settlement may need + * this very scheduler. The dtor's genuine external dependencies (its host + * imports) register themselves when they park. Poisoning on a trap now + * happens inside the lift harness (`poison()` in `createLiftedFunction`). + */ +export function hostDtorCall(rt: ResourceTypeInfo, rep: number): void { + const impl = rt.impl; + // An imported (host-implemented) resource has `impl === null` by + // construction (executor `bindImportedResources`): there is no component + // instance to gate entry into, so the dtor is called directly, as before. + if (impl === null) { + rt.dtor?.(rep); + return; + } + if (rt.dtorHost === null) { + // The executor pre-wires `dtorHost` for every defined resource; this is + // the direct-construction path (embedder test doubles, and any token that + // reached the host without going through the `resource` initializer). + rt.dtorHost = createDtorEntry({ + dtor: rt.dtor, + instance: impl as unknown as ComponentInstanceState, + }); + } + const out = rt.dtorHost(rep); + if (isPromiseLike(out)) { + const store = (impl as unknown as { store?: Store }).store; + Promise.resolve(out as Promise).catch((e: unknown) => { + if (store !== undefined && store.hostFailure === undefined) { + store.hostFailure = e; + } + }); + } +} + /** * Call into wasm and hand back the result, awaiting it only if it is a * Promise. diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index e727c31..b4db321 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -17,7 +17,6 @@ import { assertModeConsistent, type SuspendingImport, chooseMode, - enterWasm, isSuspending, planNeedsSuspension, suspendingImport, @@ -43,6 +42,7 @@ import type { LoadedPlan, LoadedType } from "../plan/loader.ts"; import { CONSTRUCTOR_SYNC_ENTRY, type CoreFn, + createDtorEntry, createLiftedFunction, createLoweredImport, type ExecutionStats, @@ -696,35 +696,50 @@ class Executor { token.dtor = dtor === null ? null : (rep: number) => { dtor(rep); }; - // #85: the JS-initiated-drop variant. In jspi mode a dtor may - // legally reach a `Suspending` import (docs §7), which needs a - // `promising` entry — only this module holds the raw export. - // Wrapped ONLY when the dtor is suspension-capable - // (`suspendableFuncs`: its core instance imports a blocking - // trampoline): `promising` settles on a later microtask even - // for a non-suspending activation (jspi pin (j)), which would - // leave the impl instance entered for a turn after every - // drop — a synchronous drop-then-call sequence would trap. A - // non-suspendable dtor cannot legally suspend, so the sync - // path is exact for it. `WebAssembly.promising` also rejects - // non-wasm callables (a dtor CoreDef can resolve to a JS - // trampoline) with a TypeError; fall back to the sync closure - // — pre-#85 behavior, where a suspension is a deterministic - // frame-rule trap. Deliberately does NOT set `wrappedEntries`: - // `finish()`'s invariant inventories the two primary wrapping - // sites; this is an auxiliary entry. - if ( - dtor !== null && this.suspensionMode === "jspi" && - this.suspendableFuncs.has(dtor as unknown as object) - ) { - try { - token.dtorHost = enterWasm( - dtor as (rep: number) => unknown, - this.suspensionMode, - ); - } catch { - token.dtorHost = null; - } + // #85/#160: the host-initiated-drop entry. A host-initiated + // drop is a full canonical LIFT of the dtor (definitions.py + // `canon_resource_drop`, line 2319), so it is built here with + // the same harness every lifted export uses — that is what + // gives the dtor's activation a real Task/Thread, and what + // releases the impl instance's entry bracket at the first park + // instead of holding it across the whole activation (#160). + // + // The `promising` entry wrapping (docs §7: in jspi mode a dtor + // may legally reach a `Suspending` import) is applied INSIDE + // `createLiftedFunction` per `suspensionMode`, and only when + // the dtor is suspension-capable (`suspendableFuncs`: its core + // instance imports a blocking trampoline). A non-suspendable + // dtor cannot legally suspend, so the plain entry is exact for + // it and avoids `promising`'s unconditional microtask hop + // (jspi pin (j)). The hop no longer risks a drop-then-call + // trap either way — the bracket is released before the drive, + // and the hop-quiescence entry gate covers the sequence — but + // the plain path stays the cheaper and more deterministic one. + // + // `WebAssembly.promising` rejects non-wasm callables (a dtor + // CoreDef can resolve to a JS trampoline) with a TypeError; + // fall back to the plain entry, where `awaitCore` still parks + // on a returned Promise. Deliberately does NOT set + // `wrappedEntries`: `finish()`'s invariant inventories the two + // primary wrapping sites; this is an auxiliary entry. + const suspendable = dtor !== null && + this.suspensionMode === "jspi" && + this.suspendableFuncs.has(dtor as unknown as object); + const mkEntry = (mode: SuspensionMode) => + createDtorEntry({ + name: `[dtor] resource ${init.index}`, + dtor, + instance: inst, + suspensionMode: mode, + stats: this.stats, + trapState: this.trapState, + syncCallStack: this.syncCallStack, + allInstances: () => this.componentInstances.values(), + }); + try { + token.dtorHost = mkEntry(suspendable ? "jspi" : "plain"); + } catch { + token.dtorHost = mkEntry("plain"); } } }); diff --git a/runtime/tests/dtor_normalization_test.ts b/runtime/tests/dtor_normalization_test.ts new file mode 100644 index 0000000..486b0f3 --- /dev/null +++ b/runtime/tests/dtor_normalization_test.ts @@ -0,0 +1,110 @@ +// #160 — a host-initiated resource destructor is an ordinary lifted call. +// +// Authority: definitions.py `canon_resource_drop` (line 2319) builds the dtor +// into a function instance and calls it through `Store.lift` with +// `CanonicalOptions(async_ = False)` / `FuncType([U32Type()], [], async_ = +// False)`. Before #160 the host-initiated path called `rt.dtor` bare while +// HOLDING `enterFrom(null)` across the returned promise, which produced two +// observable defects pinned below: +// +// 1. the dtor's own suspension points were unresumable — `Store.tick`'s +// enterability filter (#155) skips a thread whose instance is not +// host-enterable, and the held bracket made the impl exactly that, so +// the completion promise (parked in `pendingHostCalls`, i.e. advertised +// as *external* work) never settled and every driver waited forever; +// 2. the held bracket also locked the synthetic per-instantiation root for +// the whole activation, so a SIBLING instance of the same component +// looked non-enterable from the host — the macro-scale window of the +// #156 class. +// +// Both are structural consequences of the missing Task/Thread, and both are +// gone now that the dtor runs through `createLiftedFunction`. + +import { ResourceTypeInfo } from "../src/cabi/mod.ts"; +import { + ComponentInstanceState, + Store, + storeQuiescent, +} from "../src/task/mod.ts"; +import { currentTask } from "../src/task/scheduler.ts"; +import { blockCurrentActivation } from "../src/jspi/mod.ts"; +import { driveStoreAsync, hostDtorCall } from "../src/exec/boundary.ts"; +import { assertEq } from "./support/asserts.ts"; + +Deno.test("#160: a dtor parked on a scheduler-resumable suspension point completes", async () => { + const store = new Store(); + const impl = new ComponentInstanceState(1, store); + let flag = false; + let finished = false; + + const rt = new ResourceTypeInfo( + impl, + ((rep: number) => { + // `currentTask()` resolves to the DTOR'S OWN task — that is the fix: + // under the old bare call the activation had no task at all, so a + // built-in reached here signalled `PendingCapability` (or, worse, + // attributed itself to whatever foreign task happened to be ambient — + // the #24 class). + const task = currentTask(); + assertEq(task !== null, true); + return blockCurrentActivation({ + store, + task, + readyFunc: () => flag, + cancellable: false, + produce: () => { + finished = true; + assertEq(rep, 77); + return undefined; + }, + }); + }) as unknown as (rep: number) => void, + ); + + hostDtorCall(rt, 77); + + // The park happened, and the entry bracket was RELEASED at it: the impl is + // host-enterable, which is precisely what lets `tick` resume the point + // below. Pre-#160 this was `false` and the store wedged here forever. + assertEq(finished, false); + assertEq(impl.mayEnterFrom(null), true); + assertEq(store.waiting.length >= 1, true); + // NOT advertised as external work: the settlement needs this scheduler. + assertEq(store.pendingHostCalls.size, 0); + + flag = true; + await driveStoreAsync(store, () => storeQuiescent(store), "#160 dtor drain"); + + assertEq(finished, true); + assertEq(store.waiting.length, 0); + assertEq(storeQuiescent(store), true); + assertEq(impl.mayEnterFrom(null), true); + assertEq(store.hostFailure, undefined); +}); + +Deno.test("#160/#156: a sibling instance stays enterable while a dtor is in flight", async () => { + const store = new Store(); + const impl = new ComponentInstanceState(1, store); + const sibling = new ComponentInstanceState(2, store); + let resolveDtor: () => void = () => {}; + + const rt = new ResourceTypeInfo( + impl, + (() => new Promise((r) => (resolveDtor = r))) as unknown as ( + rep: number, + ) => void, + ); + hostDtorCall(rt, 5); + + // Pre-#160 the held `enterFrom(null)` locked the synthetic root shared by + // the component's instances, so this was `false` for as long as the dtor + // ran — an unrelated export call on `sibling` would have trapped with + // "cannot enter component instance". + assertEq(sibling.mayEnterFrom(null), true); + assertEq(impl.mayEnterFrom(null), true); + + resolveDtor(); + await driveStoreAsync(store, () => storeQuiescent(store), "sibling drain"); + assertEq(sibling.mayEnterFrom(null), true); + assertEq(impl.mayEnterFrom(null), true); +}); diff --git a/runtime/tests/resource_lifetime_test.ts b/runtime/tests/resource_lifetime_test.ts index 9b3e0f9..6c64c84 100644 --- a/runtime/tests/resource_lifetime_test.ts +++ b/runtime/tests/resource_lifetime_test.ts @@ -7,12 +7,12 @@ // `canon_resource_drop` (lines 1508, 2325). import { - callDtorGated, canonResourceDrop, canonResourceNew, ResourceTypeInfo, } from "../src/cabi/mod.ts"; -import { ComponentInstanceState, Store } from "../src/task/mod.ts"; +import { ComponentInstanceState, Store, storeQuiescent } from "../src/task/mod.ts"; +import { driveStoreAsync, hostDtorCall } from "../src/exec/boundary.ts"; import { setOnInstancePoisoned } from "../src/task/scheduler.ts"; // Side-effecting import: registers `retireInstanceAsyncEnds` as the poisoning // hook (#66). Without it the seam is null and the poison walk is a no-op. @@ -182,7 +182,16 @@ Deno.test("#85: a guest-initiated dtor that does not finish synchronously traps" }); }); -Deno.test("#85: a host-initiated async dtor holds the gate until it settles", async () => { +Deno.test("#160: a host-initiated async dtor does NOT hold the gate", async () => { + // REVISED from the #85 pin "holds the gate until it settles". That + // behaviour was the bug: the held `enterFrom(null)` bracket made the impl + // instance non-enterable for the whole activation, so `Store.tick`'s + // enterability filter could never resume a suspension point belonging to + // the dtor itself (#160). A host-initiated dtor is now a full canonical + // lift (definitions.py `canon_resource_drop` line 2319), whose bracket is + // released at the first park — so the instance is host-enterable while the + // dtor is in flight, and the completion promise is NOT a `pendingHostCalls` + // entry (it is not external work). const { store, impl } = mkPair(); let resolveDtor: () => void = () => {}; const rt = new ResourceTypeInfo( @@ -191,18 +200,18 @@ Deno.test("#85: a host-initiated async dtor holds the gate until it settles", as rep: number, ) => void, ); - callDtorGated(rt, 11, null, true); - assertEq(impl.mayEnter, false); - assertEq(store.pendingHostCalls.size, 1); + hostDtorCall(rt, 11); + assertEq(impl.mayEnter, true); + assertEq(impl.mayEnterFrom(null), true); + assertEq(store.pendingHostCalls.size, 0); resolveDtor(); - await Promise.all([...store.pendingHostCalls]); - await Promise.resolve(); + await driveStoreAsync(store, () => storeQuiescent(store), "dtor drain"); assertEq(impl.mayEnter, true); assertEq(store.pendingHostCalls.size, 0); assertEq(store.hostFailure, undefined); }); -Deno.test("#85: a rejected host-initiated dtor poisons and lands on hostFailure", async () => { +Deno.test("#85/#160: a rejected host-initiated dtor poisons and lands on hostFailure", async () => { await withPoisonSpy(async (seen) => { const { store, impl } = mkPair(); const boom = new Error("async dtor trap"); @@ -210,9 +219,14 @@ Deno.test("#85: a rejected host-initiated dtor poisons and lands on hostFailure" impl, (() => Promise.reject(boom)) as unknown as (rep: number) => void, ); - callDtorGated(rt, 12, null, true); - const pending = [...store.pendingHostCalls]; - await Promise.all(pending); + // Substance unchanged by #160; only the timing (microtasks, not the + // `pendingHostCalls` promise) and the surfaced error identity move: the + // rejection now travels through `awaitCore`'s `mapCoreException`, which + // passes a non-`WebAssembly.RuntimeError` through unchanged — so it is + // still `boom` itself. + hostDtorCall(rt, 12); + await driveStoreAsync(store, () => storeQuiescent(store), "dtor drain") + .catch(() => {}); await Promise.resolve(); assertEq(store.hostFailure === boom, true); assertEq(impl.mayEnter, false);