From 6f85e2e61ed2d362e1c95f960a6af01ba7b32f2c Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Thu, 20 Aug 2026 15:33:48 -0400 Subject: [PATCH] resources: host-initiated dtors run as full canonical lifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #160. definitions.py canon_resource_drop (line 2319) invokes the dtor through Store.lift with CanonicalOptions(async_ = False) — a full canonical lift. The host-initiated drop path instead called the dtor bare while HOLDING enterFrom(null) across its returned promise (callDtorGated allowAsync, the #85 shape), with no Task/Thread behind the activation. Three structural defects followed: * #160: the held bracket made the impl non-host-enterable, so tick's enterability filter (#155) could never resume a suspension point of the dtor's own activation, while the completion promise sat in pendingHostCalls advertising external work — every driver parked on it forever. (Between calls the empty ambient made currentTask() signal PendingCapability before the park — the loud presentation; the silent wedge needed a foreign ambient, the #24 class.) * it was the runtime's only enterFrom(null) bracket spanning an await: the macro-time window of the #156 class, locking every sibling instance through the synthetic root for the dtor's whole activation. * built-ins inside the dtor had no ambient task at all. Now: createDtorEntry builds the dtor's entry with createLiftedFunction (ft = [u32] -> (), inert options, coreType (i32)->()), and hostDtorCall routes every host-initiated drop through it (executor pre-wires dtorHost; direct-construction tokens get it lazily). The activation has a real Task/implicit Thread, the bracket releases at the first park, suspension points resume through tick, settled tails flow through serviceSettled, and traps poison through the lift harness's own path. Async failures park on store.hostFailure (first failure wins); the completion promise is deliberately NOT a pendingHostCalls entry — that registration was the "external work" lie, and it had no other consumer. callDtorGated keeps only the guest-initiated synchronous discipline. One additive harness parameter, allowAsyncCompletion: a sync-typed lift whose caller needs no synchronous answer skips driveSyncLift so a plain-mode thenable-returning (host JS) dtor parks instead of tripping the sync loop's bogus deadlock; the real deadlock trap is still enforced by drive, the same substitution jspi mode makes. Revises the #85 pins (the gate-hold behavior WAS the bug) and adds regression pins: a dtor parked on a scheduler-resumable suspension point completes, and siblings stay enterable while a dtor is in flight. Gates: just gates (full pass; conformance 1257/0, no expectation changes; sched-seeds clean). Embedder surface unchanged (drop() still non-blocking, same failure channel); lockstep version untouched. --- runtime/src/cabi/handles.ts | 67 +++----- runtime/src/cabi/types.ts | 21 ++- runtime/src/embedder/instantiate.ts | 4 +- runtime/src/embedder/resources.ts | 34 ++-- runtime/src/exec/boundary.ts | 192 ++++++++++++++++++++++- runtime/src/exec/executor.ts | 75 +++++---- runtime/tests/dtor_normalization_test.ts | 110 +++++++++++++ runtime/tests/resource_lifetime_test.ts | 38 +++-- 8 files changed, 420 insertions(+), 121 deletions(-) create mode 100644 runtime/tests/dtor_normalization_test.ts 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);