diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 089fd60..8c3ee40 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -304,15 +304,23 @@ leaks (docs/architecture.md §7). **Host-implemented** (guest holds handles): the host supplies a class implementing the bindgen interface (camelCase methods, statics as static members, the WIT constructor as the JS constructor). The runtime owns the -instance↔rep mapping; when the guest drops its last own handle the runtime calls -`instance[Symbol.dispose]?.()`. Method `self` is the instance. - -Overlapping host-originated borrows retain the mapping until the last borrowing -call ends. A guest drop during that interval defers disposal until the final -borrow ends; the pending-drop instance cannot be passed as own again. A deferred -disposal error is reported by the last borrowing call, after all its borrow -mappings are released. An existing call failure remains primary; results that -cannot be delivered because cleanup failed are released rather than abandoned. +rep→instance registrations; method `self` is the instance. Every time a plain +host object is passed as `own`, the runtime creates a fresh resource +registration. Registrations are independent even when they use the same JS +object as backing data: dropping each one calls `instance[Symbol.dispose]?.()`. +Sharing or deduplicating the backing data is the host implementation's +responsibility. + +Passing an existing guest-resource wrapper as `own` is different: it +transfers that one resource and invalidates the wrapper; it does not create an +independent resource. Canonical handle lender rules continue to protect borrows +of such concrete resources from transfer or destruction while lent. + +Each host-originated `borrow` of a plain host object gets a fresh, +call-scoped registration, independent of other borrows and owns backed by that +object. Ending that scope removes only its temporary registration and never +disposes the object. Cleanup still releases every temporary mapping after the +call, including failure paths. **Constructors are synchronous** (a JS constructor cannot await). A guest constructor that does not complete synchronously raises a named error rather @@ -325,7 +333,7 @@ deferred until demanded. | host receives `own` | new instance; host owns it (drop/`using`) | the host's own instance; the guest's handle is gone; no dispose call | | host receives `borrow` | valid only during the call (retention throws) | the host's own instance; scoping is guest-side bookkeeping | | host passes `own` | wrapper invalidated (transferred) | instance registered; guest owns the handle | -| host passes `borrow` | wrapper stays valid | an unregistered instance gets a rep for the call's duration | +| host passes `borrow` | wrapper stays valid | every borrow gets a fresh rep for that call's duration | ### Pattern (non-normative): binding platform classes directly diff --git a/protocol/deno.json b/protocol/deno.json index 4d80331..5477f4e 100644 --- a/protocol/deno.json +++ b/protocol/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/protocol", - "version": "0.3.2", + "version": "0.4.0", "exports": { ".": "./src/mod.ts" }, diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 0efc286..94037a4 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -15,8 +15,6 @@ import type { LoadedPlan } from "../plan/loader.ts"; import { loadEnvelope, loadPlan, PlanError } from "../plan/loader.ts"; import type { FuncType, ResourceTypeInfo, ValType } from "../cabi/types.ts"; import type { ComponentValue, VariantValue } from "../cabi/types.ts"; -import { despecialize } from "../cabi/types.ts"; -import { hostFutureFor, hostStreamFor } from "../exec/host_streams.ts"; import { Trap } from "../cabi/trap.ts"; import { type ComponentHandle, @@ -428,8 +426,10 @@ class Facade { lowerBorrow: (v, t) => { const b = this.#binding(t.rt.resource); if (b.kind === "host") { - // Each overlapping call retains the rep; the final borrow release - // removes only temporary mappings, never a guest-owned registration. + // A plain host object does not identify any existing resource. + // Each lowering therefore creates its own call-scoped borrow + // registration; canonical guest-handle lends are tracked below this + // facade (definitions.py:1794-1800). const { rep, release } = b.registry.borrowFor(v); if (this.#lowerScope === null) release(); else this.#lowerScope.push(release); @@ -596,8 +596,8 @@ class Facade { this.#pendingHostResources.push({ importIndex, registry, cls }); return hostResourceType({ name: leaf.leaf, - // The guest dropped its last own handle: run the destructor, which for - // a host-implemented resource is `instance[Symbol.dispose]?.()`. + // The guest dropped this owning resource registration: run its + // destructor, which is `instance[Symbol.dispose]?.()` for a host resource. dtor: (rep) => registry.dtor(rep), }); } @@ -978,8 +978,8 @@ class Facade { * Lower a call's arguments, collecting the releases for anything that was * allocated *for the duration of this call* (see `lowerBorrow`). * - * Save/restore the collection slot for reentrant lowering. Release every - * borrow once, even if another release throws, then report the first error. + * Save/restore the collection slot for reentrant lowering. Releases are + * non-throwing and each borrow is released once. */ #lowerParams( params: ValType[], @@ -991,17 +991,7 @@ class Facade { const release = () => { if (released) return; released = true; - let failed = false; - let error: unknown; - for (const r of scope) { - try { - r(); - } catch (e) { - if (!failed) error = e; - failed = true; - } - } - if (failed) throw error; + for (const r of scope) r(); }; const outer = this.#lowerScope; this.#lowerScope = scope; @@ -1009,12 +999,7 @@ class Facade { try { lowered = params.map((p, i) => fromHost(args[i], p, o)); } catch (e) { - try { - release(); - } catch { - // The original error wins; a secondary failure of the unwind is - // not the story. - } + release(); throw e; } finally { this.#lowerScope = outer; @@ -1022,62 +1007,6 @@ class Facade { return { lowered, release }; } - /** Cleanup cannot abandon a result already transferred out of the guest. */ - #finishCall( - release: () => void, - succeeded: boolean, - raw: unknown, - type: ValType | null, - ): void { - try { - release(); - } catch (e) { - if (!succeeded) return; // Preserve the original call failure. - if (type !== null) this.#dropResult(raw as ComponentValue, type); - throw e; - } - } - - #dropResult(raw: ComponentValue, type: ValType): void { - // Already failing cleanup: retire every owned leaf, preserving that error - // even when a result destructor also throws. - try { - const t = despecialize(type); - switch (t.kind) { - case "own": - this.#bridge.dropOwn(raw as number, t); - break; - case "future": - hostFutureFor(raw).drop(); - break; - case "stream": - hostStreamFor(raw).readable.drop(); - break; - case "list": - for (const v of raw as ComponentValue[]) { - this.#dropResult(v, t.element); - } - break; - case "record": - for (const f of t.fields) { - this.#dropResult( - (raw as Record)[f.label], - f.type, - ); - } - break; - case "variant": { - const v = raw as VariantValue; - const payload = t.cases.find((c) => c.label === v.kind)?.type; - if (payload != null) this.#dropResult(v.value, payload); - break; - } - } - } catch { - // The argument cleanup error remains primary. - } - } - /** * Wrap one lifted export. * @@ -1113,14 +1042,13 @@ class Facade { try { pending = Promise.resolve(fn(...lowered)) as Promise; } catch (e) { - this.#finishCall(release, false, undefined, resultType); + release(); throw e; } return Future.deferred( pending, elementCodec(element, o), - (succeeded, raw) => - this.#finishCall(release, succeeded, raw, resultType), + () => release(), ) as unknown as Promise; }; } else { @@ -1135,10 +1063,10 @@ class Facade { try { raw = await fn(...lowered); } catch (e) { - this.#finishCall(release, false, undefined, resultType); + release(); throw e; } - this.#finishCall(release, true, raw, resultType); + release(); if (resultType === null) return undefined; if (resultType.kind === "result") { // Internal result: `{kind: "ok"|"error", value}` (cabi/types.ts @@ -1217,10 +1145,10 @@ class Facade { try { raw = entry(...lowered); } catch (e) { - this.#finishCall(release, false, undefined, resultType); + release(); throw e; } - this.#finishCall(release, true, raw, resultType); + release(); if (isThenable(raw)) unreachableThenable(raw); return Future.fromLifted( raw as ComponentValue, @@ -1239,10 +1167,10 @@ class Facade { try { raw = entry(...lowered); } catch (e) { - this.#finishCall(release, false, undefined, resultType); + release(); throw e; } - this.#finishCall(release, true, raw, resultType); + release(); if (isThenable(raw)) unreachableThenable(raw); if (resultType === null) return undefined; if (resultType.kind === "result") { diff --git a/runtime/src/embedder/resources.ts b/runtime/src/embedder/resources.ts index 4704ac1..e786082 100644 --- a/runtime/src/embedder/resources.ts +++ b/runtime/src/embedder/resources.ts @@ -10,7 +10,7 @@ // | host receives own | new wrapper (host owns) | instance back, ownership released, no dispose | // | host receives borrow | wrapper valid for the call | instance, mapping kept | // | host passes own | wrapper invalidated | instance registered | -// | host passes borrow | wrapper stays valid | rep reused/allocated | +// | host passes borrow | wrapper stays valid | call-scoped registration | import type { ResourceTypeInfo, ValType } from "../cabi/types.ts"; import { defineRealmLocal, RESOURCE_STATE } from "@polyengine/protocol"; @@ -346,25 +346,8 @@ export function buildGuestResourceClass( let rep: unknown; try { rep = spec.ctor(...lowered); - } catch (e) { - try { - release(); - } catch { - // The original error wins; a secondary failure of the unwind is - // not the story. - } - throw e; - } - try { + } finally { release(); - } catch (e) { - try { - if (typeof rep === "number") hostDtorCall(rt, rep); - } catch { - // The original error wins; a secondary failure of the unwind is - // not the story. - } - throw e; } if (rep !== null && typeof rep === "object" && "then" in rep) { throw new TypeError( @@ -458,87 +441,54 @@ export function makeWrapper( // --------------------------------------------------------------------------- /** - * Runtime-owned instance <-> rep mapping for a host-implemented resource. + * Runtime-owned rep -> instance mapping for a host-implemented resource. * - * Strongly retain the instance while guest-owned or borrowed by any host- - * originated call. Returning own releases ownership, not outstanding borrows. - * A guest drop defers disposal until all borrows release. - * @internal — runtime-owned instance<->rep mapping; hosts supply a class, not - * a registry. + * Each host-originated own is a fresh resource registration, even when two + * registrations use the same JS object as backing data. Host-originated + * borrows are likewise fresh, call-scoped registrations: object identity + * cannot identify which (if any) independent own resource is being borrowed. + * + * Guest-originated borrows do carry the rep of a concrete own resource. Their + * lifetime is protected by the canonical handle's lender bookkeeping before + * this registry is consulted (definitions.py:1482-1496, 2295-2311). + * @internal — hosts supply a class, not a registry. */ export class HostResourceRegistry { - readonly #byRep = new Map< - number, - { instance: object; owns: boolean; borrows: number; pendingDrop: boolean } - >(); - readonly #byInstance = new WeakMap(); + readonly #byRep = new Map(); #next = 1; constructor(readonly className: string) {} - #repFor(instance: unknown): number { + #register(instance: unknown, owns: boolean): number { if (instance === null || typeof instance !== "object") { throw new TypeError( `${this.className}: expected a class instance, got ${typeof instance}`, ); } - const held = this.#byInstance.get(instance); - if (held !== undefined && this.#byRep.has(held)) return held; const rep = this.#next++; - this.#byRep.set(rep, { - instance, - owns: false, - borrows: 0, - pendingDrop: false, - }); - this.#byInstance.set(instance, rep); + this.#byRep.set(rep, { instance, owns }); return rep; } - /** The host is passing an own to the guest: retain until release or drop. */ + /** Register one fresh resource whose ownership is transferred to the guest. */ repFor(instance: unknown): number { - const rep = this.#repFor(instance); - const entry = this.#byRep.get(rep)!; - if (entry.pendingDrop) { - throw new InvalidHandleError( - `${this.className}: cannot transfer an instance pending drop as own`, - ); - } - entry.owns = true; - return rep; + return this.#register(instance, true); } - /** Retain a mapping for every overlapping call, independently of ownership. */ + /** Register a fresh mapping whose lifetime is exactly one lowering scope. */ borrowFor(instance: unknown): { rep: number; release: () => void } { - const rep = this.#repFor(instance); - const entry = this.#byRep.get(rep)!; - entry.borrows += 1; + const rep = this.#register(instance, false); let released = false; return { rep, release: () => { if (released) return; released = true; - entry.borrows -= 1; - if (entry.borrows === 0 && !entry.owns) { - this.#byRep.delete(rep); - if (entry.pendingDrop) { - entry.pendingDrop = false; - (entry.instance as { [Symbol.dispose]?: () => void }) - [Symbol.dispose]?.(); - } - } + this.#byRep.delete(rep); }, }; } - /** Is this instance already registered with a live rep? */ - hasInstance(instance: unknown): boolean { - if (instance === null || typeof instance !== "object") return false; - const held = this.#byInstance.get(instance); - return held !== undefined && this.#byRep.has(held); - } - /** Is `rep` live? Diagnostics and white-box tests. */ hasRep(rep: number): boolean { return this.#byRep.has(rep); @@ -556,35 +506,24 @@ export class HostResourceRegistry { } /** - * Return the host's instance without disposal. Keep its mapping while any - * host-originated borrow remains, even though guest ownership has ended. + * Return the host's instance without disposal, completing this registration's + * ownership transfer. Other registrations of the same object are unrelated. */ release(rep: number): object { const inst = this.lookup(rep); - const entry = this.#byRep.get(rep)!; - entry.owns = false; - if (entry.borrows === 0) this.#byRep.delete(rep); + this.#byRep.delete(rep); return inst; } - /** - * Guest drop: dispose now or after the last host-originated borrow. - * Pending disposal prevents re-transfer as own; the final release reports - * any disposal failure after removing the mapping. - */ + /** Guest drop: dispose exactly this owning registration. */ dtor(rep: number): void { const entry = this.#byRep.get(rep); if (entry === undefined || !entry.owns) return; - if (entry.borrows > 0) { - entry.owns = false; - entry.pendingDrop = true; - return; - } - const inst = this.release(rep); - (inst as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + this.#byRep.delete(rep); + (entry.instance as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); } - /** Retained mapping count, not handle count; diagnostics and tests. */ + /** Live registration count; diagnostics and tests. */ get liveCount(): number { return this.#byRep.size; } diff --git a/runtime/tests/conventions/resources_test.ts b/runtime/tests/conventions/resources_test.ts index 4a72682..a7b1d9c 100644 --- a/runtime/tests/conventions/resources_test.ts +++ b/runtime/tests/conventions/resources_test.ts @@ -4,7 +4,7 @@ // constructor, methods are camelCase members, statics are static members — and // the runtime owns the instance↔rep mapping. Method `self` IS the instance: no // reps, no side tables. When the guest drops its -// last own handle the runtime calls `instance[Symbol.dispose]?.()`. +// owning registration the runtime calls `instance[Symbol.dispose]?.()`. // // The transcript's load-bearing content is the ORDER of host-observable // effects: construct, method, static, dispose — and that dispose lands on the diff --git a/runtime/tests/embedder/future_result_test.ts b/runtime/tests/embedder/future_result_test.ts index 0ebb023..f6fd5e5 100644 --- a/runtime/tests/embedder/future_result_test.ts +++ b/runtime/tests/embedder/future_result_test.ts @@ -14,15 +14,15 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; import { - Future, + type Future, lowerFutureSource, type Stream, } from "../../src/embedder/streams.ts"; -import { hostFuture, hostFutureFor } from "../../src/exec/host_streams.ts"; +import { hostFutureFor } from "../../src/exec/host_streams.ts"; import type { HostResourceRegistry } from "../../src/embedder/resources.ts"; import { INTERNAL_HOST_REGISTRIES } from "../../src/embedder/instantiate.ts"; import { sync } from "../../src/embedder/sync.ts"; -import { StreamProducerError, Trap } from "@polyengine/protocol"; +import { StreamProducerError } from "@polyengine/protocol"; import type { SharedFutureImpl } from "../../src/task/mod.ts"; const turn = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -187,69 +187,6 @@ for ( const FIXTURE = guest("future-import"); const have = await haveFixture(FIXTURE); -const cleanupFixture = "runtime/tests/embedder/resource-overlap-future.wasm"; -const cleanupReady = await haveFixture(cleanupFixture); -for (const synchronous of [false, true]) { - for (const callFails of [false, true]) { - Deno.test({ - name: - `future result cleanup: sync=${synchronous}, callFails=${callFails}`, - ignore: !cleanupReady, - async fn() { - const cleanupError = new Error("borrow disposal failed"); - const primary = new Trap("primary future call failure"); - let disposals = 0; - class R { - [Symbol.dispose]() { - disposals++; - throw cleanupError; - } - } - const source = hostFuture({ kind: "u32" }); - let resultDrops = 0; - const drop = source.drop.bind(source); - source.drop = () => { - resultDrops++; - drop(); - }; - const future = Future.fromHostFuture(source, { - element: { kind: "u32" }, - where: "cleanup test", - toHost: (v) => v as number, - fromHost: (v) => v, - }); - // Forward reference: the closure runs later, after `registry`/`rep` - // below are filled in. - const c = await instantiateFixture(cleanupFixture, { - r: R, - next: () => future, - finish: () => { - registry.dtor(rep); - if (callFails) throw primary; - }, - }); - const registry = - (c as unknown as Record>)[ - INTERNAL_HOST_REGISTRIES - ].get(0)!; - const cell = new R(); - const rep = registry.repFor(cell); - let observed: unknown; - try { - await (synchronous ? sync(c.exports.run)(cell) : c.exports.run(cell)); - } catch (e) { - observed = e; - } - assertEq(observed, callFails ? primary : cleanupError); - assertEq(disposals, 1); - assertEq(registry.liveCount, 0); - assertEq(resultDrops, callFails ? 0 : 1); - if (callFails) source.drop(); - }, - }); - } -} - Deno.test({ name: "futures: a sync import returning future accepts a plain Promise", ignore: !have, diff --git a/runtime/tests/embedder/host-borrow.wat b/runtime/tests/embedder/host-borrow.wat index 44fcf6b..4ba4221 100644 --- a/runtime/tests/embedder/host-borrow.wat +++ b/runtime/tests/embedder/host-borrow.wat @@ -1,6 +1,6 @@ ;; Host-passes-borrow fixture for the embedder conventions layer ;; (contracts/embedder-api.md §"Resources", 2x4 table, bottom-right cell: -;; "a never-registered instance gets a rep allocated for the call's duration"). +;; every host-originated borrow gets a fresh rep for the call's duration). ;; ;; No corpus component takes a `borrow` of an *imported* (host-implemented) ;; resource as an export parameter, which is the only position from which the diff --git a/runtime/tests/embedder/host_imports_test.ts b/runtime/tests/embedder/host_imports_test.ts index f3e61ac..c11dbd5 100644 --- a/runtime/tests/embedder/host_imports_test.ts +++ b/runtime/tests/embedder/host_imports_test.ts @@ -17,7 +17,6 @@ import { import { ComponentException, suspending, Trap } from "@polyengine/protocol"; import { INTERNAL_HOST_REGISTRIES } from "../../src/embedder/instantiate.ts"; import { HostResourceRegistry } from "../../src/embedder/resources.ts"; -import { sync } from "../../src/embedder/sync.ts"; const ready = await haveFixture(testdata("imports")); @@ -127,7 +126,7 @@ Deno.test({ assertEq( Cell.disposed, [7], - "the guest dropping its last own handle runs [Symbol.dispose]", + "the guest dropping its owning registration runs [Symbol.dispose]", ); // A handle the guest keeps is not disposed until it says so. @@ -344,258 +343,119 @@ const borrowReady = await haveFixture( const overlapFixture = "runtime/tests/embedder/resource-overlap.wasm"; const overlapReady = await haveFixture(overlapFixture); -for (const mode of ["constructor", "promise", "sync"] as const) { - Deno.test({ - name: - `host resources: ${mode} successful own result is dropped when cleanup throws`, - ignore: !overlapReady, - fn: async () => { - const boom = new Error("borrow cleanup failed"); - let drops = 0; - class R { - [Symbol.dispose]() { - drops++; - throw boom; - } - } - // Forward reference: the closure runs later, after `registry`/`rep` - // below are filled in. - const c = await instantiateFixture(overlapFixture, { - "host:api/res": { - R, - value: () => { - registry.dtor(rep); - return 7; - }, - }, - }); - const registry = - (c as unknown as Record>)[ - INTERNAL_HOST_REGISTRIES - ].get(0)!; - const cell = new R(); - const rep = registry.repFor(cell); - const e = await caught(() => - mode === "constructor" - ? new c.exports.Ticket(cell) - : mode === "sync" - ? sync(c.exports.makeTicket)(cell) - : c.exports.makeTicket(cell) - ); - assertEq(e, boom); - assertEq(drops, 1); - assertEq(registry.liveCount, 0); - assertEq( - await c.exports.ticketDrops(), - 1, - "undeliverable result dropped once", - ); - }, - }); -} - -for (const mode of ["constructor", "promise", "sync"] as const) { - Deno.test({ - name: `host resources: ${mode} original failure survives throwing cleanup`, - ignore: !overlapReady, - fn: async () => { - const primary = new Trap("guest call failed"); - class R { - [Symbol.dispose]() { - throw new Error("secondary disposal"); - } - } - // Forward reference: the closure runs later, after `registry`/`rep` - // below are filled in. - const c = await instantiateFixture(overlapFixture, { - "host:api/res": { - R, - value: () => { - registry.dtor(rep); - throw primary; - }, - }, - }); - const registry = - (c as unknown as Record>)[ - INTERNAL_HOST_REGISTRIES - ].get(0)!; - const cell = new R(); - const rep = registry.repFor(cell); - const e = await caught(() => - mode === "constructor" - ? new c.exports.Ticket(cell) - : mode === "sync" - ? sync(c.exports.makeTicket)(cell) - : c.exports.makeTicket(cell) - ); - assertEq(e, primary); - assertEq(registry.liveCount, 0); - }, - }); -} - -Deno.test("host resources: borrow releases retain overlapping and owned mappings", () => { - for (const ownAt of ["never", "before", "during"] as const) { - const registry = new HostResourceRegistry("Cell"); - const cell = new Cell(7); - if (ownAt === "before") registry.repFor(cell); - const first = registry.borrowFor(cell); - const second = registry.borrowFor(cell); - assertEq(first.rep, second.rep); - if (ownAt === "during") assertEq(registry.repFor(cell), first.rep); - first.release(); - first.release(); - assertEq(registry.lookup(second.rep) === cell, true); - second.release(); - assertEq(registry.liveCount, ownAt === "never" ? 0 : 1); - if (ownAt !== "never") { - assertEq(registry.release(first.rep) === cell, true); - assertEq(registry.liveCount, 0); - } - } -}); - -Deno.test("host resources: returning an own does not remove an overlapping borrow", () => { +Deno.test("host resources: registrations do not use backing-object identity", () => { const registry = new HostResourceRegistry("Cell"); + Cell.disposed = []; const cell = new Cell(7); - const rep = registry.repFor(cell); - const borrow = registry.borrowFor(cell); - assertEq(registry.release(rep) === cell, true); - assertEq(registry.lookup(rep) === cell, true); - borrow.release(); + const first = registry.repFor(cell); + const second = registry.repFor(cell); + const borrowA = registry.borrowFor(cell); + const borrowB = registry.borrowFor(cell); + assertEq(new Set([first, second, borrowA.rep, borrowB.rep]).size, 4); + registry.dtor(first); + assertEq(registry.lookup(second), cell); + assertEq(registry.lookup(borrowA.rep), cell); + assertEq(Cell.disposed, [7]); + borrowA.release(); + borrowA.release(); + assertEq(registry.lookup(borrowB.rep), cell); + assertEq(registry.release(second), cell); + borrowB.release(); assertEq(registry.liveCount, 0); }); -for (const reverse of [false, true]) { - for (const throwing of [false, true]) { - Deno.test({ - name: - `host resources: deferred drop, reverse=${reverse}, throwing=${throwing}`, - ignore: !overlapReady, - fn: async () => { - const boom = new Error("host destructor failed"); - class Droppable { - drops = 0; - [Symbol.dispose]() { - this.drops++; - if (throwing) throw boom; - } - } - const resolvers: (() => void)[] = []; - const bothEntered = Promise.withResolvers(); - const c = await instantiateFixture(overlapFixture, { - "host:api/res": { - R: Droppable, - value: suspending((r: Droppable) => { - assertEq(r.drops, 0, "no lookup reaches a disposed object"); - if (resolvers.length >= 2) return 7; - return new Promise((resolve) => { - resolvers.push(() => resolve(7)); - if (resolvers.length === 2) bothEntered.resolve(); - }); - }), - }, - }, { jspi: true }); - const registry = (c as unknown as Record< - symbol, - Map - >)[INTERNAL_HOST_REGISTRIES].get(0)!; - const cell = new Droppable(); - const other = new Droppable(); - const persistent = new Droppable(); - const held = await c.exports.hold(cell); - const persistentHeld = await c.exports.hold(persistent); - // The second argument needs cleanup even if the first's final release - // runs a throwing destructor. It is temporary in both overlapping calls. - const calls = [ - c.exports.peekTwo(cell, other), - c.exports.peekTwo(cell, other), - ]; - const outcomes = calls.map((p) => caught(() => p)); - await bothEntered.promise; - await c.exports.dropHeld(held); - assertEq(cell.drops, 0); - const reacquire = await caught(() => c.exports.hold(cell)); - assertEq(String(reacquire).includes("pending drop"), true); - const first = reverse ? 1 : 0; - resolvers[first](); - assertEq(await outcomes[first], undefined); - assertEq(cell.drops, 0, "one remaining borrow still protects disposal"); - assertEq(registry.hasInstance(cell), true); - resolvers[1 - first](); - assertEq(await outcomes[1 - first], throwing ? boom : undefined); - assertEq(cell.drops, 1); - assertEq(registry.hasInstance(cell), false); - assertEq( - registry.hasInstance(other), - false, - "later releases still run", - ); - assertEq(registry.hasInstance(persistent), true); - assertEq(registry.liveCount, 1); - // With no borrow, disposal still happens in the guest call itself. - const drop = c.exports.dropHeld(persistentHeld); - assertEq(persistent.drops, 1); - const dropError = await caught(() => drop); - assertEq(dropError === undefined, !throwing); - assertEq(registry.liveCount, 0); - }, +Deno.test({ + name: "host resources: same backing object creates independent guest owns", + ignore: !overlapReady, + fn: async () => { + const c = await instantiateFixture(overlapFixture, { + "host:api/res": { R: Cell, value: (r: Cell) => r.v }, }); - } -} + Cell.disposed = []; + const cell = new Cell(7); + const first = await c.exports.hold(cell); + const second = await c.exports.hold(cell); + assertEq( + first === second, + false, + "guest handles are independently allocated", + ); + await c.exports.dropHeld(first); + assertEq(Cell.disposed, [7]); + assertEq(await c.exports.heldValue(second), 7); + await c.exports.dropHeld(second); + assertEq(Cell.disposed, [7, 7]); + }, +}); -for (const ownAt of ["never", "before", "during"] as const) { - Deno.test({ - name: - `host resources: overlapping JSPI borrows preserve ${ownAt}-owned mapping`, - ignore: !overlapReady, - fn: async () => { - const resolvers: (() => void)[] = []; - const bothEntered = Promise.withResolvers(); - const c = await instantiateFixture(overlapFixture, { - "host:api/res": { - R: Cell, - value: suspending((r: Cell) => { - if (resolvers.length >= 2) return r.v; - return new Promise((resolve) => { - resolvers.push(() => resolve(r.v)); - if (resolvers.length === 2) bothEntered.resolve(); - }); - }), - }, - }, { jspi: true }); - const registry = (c as unknown as Record< - symbol, - Map - >)[INTERNAL_HOST_REGISTRIES].get(0)!; - Cell.disposed = []; - const cell = new Cell(7); - let held: number | undefined; - if (ownAt === "before") held = await c.exports.hold(cell); - const first = c.exports.peek(cell); - const second = c.exports.peek(cell); - const secondOutcome = caught(() => second); - await bothEntered.promise; - if (ownAt === "during") held = await c.exports.hold(cell); - resolvers[0](); - assertEq(await first, 7); - const liveDuringSecond = registry.hasInstance(cell); - resolvers[1](); - const error = await secondOutcome; - assertEq(liveDuringSecond, true, "the second borrow keeps its mapping"); - assertEq(error, undefined); - assertEq(await second, 7, "the second guest lookup succeeds"); - assertEq(registry.liveCount, ownAt === "never" ? 0 : 1); - assertEq(Cell.disposed, [], "ending a borrow never disposes"); - if (held !== undefined) { - await c.exports.dropHeld(held); - assertEq(registry.liveCount, 0); - assertEq(Cell.disposed, [7]); - } - }, - }); -} +Deno.test({ + name: + "host resources: returning one own preserves a same-object registration", + ignore: !overlapReady, + fn: async () => { + const c = await instantiateFixture(overlapFixture, { + "host:api/res": { R: Cell, value: (r: Cell) => r.v }, + }); + Cell.disposed = []; + const cell = new Cell(7); + const returnedHandle = await c.exports.hold(cell); + const remainingHandle = await c.exports.hold(cell); + assertEq(await c.exports.returnHeld(returnedHandle), cell); + assertEq(await c.exports.heldValue(remainingHandle), 7); + assertEq(Cell.disposed, []); + await c.exports.dropHeld(remainingHandle); + assertEq(Cell.disposed, [7]); + }, +}); + +Deno.test({ + name: "host resources: overlapping borrows and own creation are independent", + ignore: !overlapReady, + fn: async () => { + const resolvers: (() => void)[] = []; + const bothEntered = Promise.withResolvers(); + const c = await instantiateFixture(overlapFixture, { + "host:api/res": { + R: Cell, + value: suspending((r: Cell) => { + if (resolvers.length >= 2) return r.v; + return new Promise((resolve) => { + resolvers.push(() => resolve(r.v)); + if (resolvers.length === 2) bothEntered.resolve(); + }); + }), + }, + }, { jspi: true }); + const registry = (c as unknown as Record< + symbol, + Map + >)[INTERNAL_HOST_REGISTRIES].get(0)!; + Cell.disposed = []; + const cell = new Cell(7); + const first = c.exports.peek(cell); + const second = c.exports.peek(cell); + const secondOutcome = caught(() => second); + await bothEntered.promise; + assertEq(registry.liveCount, 2, "each call owns one temporary mapping"); + const held = await c.exports.hold(cell); + assertEq( + registry.liveCount, + 3, + "own registration is separate from borrows", + ); + resolvers[0](); + assertEq(await first, 7); + assertEq(registry.liveCount, 2); + resolvers[1](); + assertEq(await secondOutcome, undefined); + assertEq(await second, 7); + assertEq(registry.liveCount, 1); + assertEq(Cell.disposed, [], "borrow cleanup never disposes backing data"); + await c.exports.dropHeld(held); + assertEq(registry.liveCount, 0); + assertEq(Cell.disposed, [7]); + }, +}); Deno.test({ name: @@ -623,6 +483,37 @@ Deno.test({ }, }); +Deno.test({ + name: "host resources: temporary registrations release on call failure", + ignore: !overlapReady, + fn: async () => { + const c = await instantiateFixture(overlapFixture, { + "host:api/res": { R: Cell, value: (r: Cell) => r.v }, + }); + const registry = (c as unknown as Record< + symbol, + Map + >)[INTERNAL_HOST_REGISTRIES].get(0)!; + const cell = new Cell(7); + + const conversionError = await caught(() => c.exports.acceptTwo(cell, 0)); + assertEq(conversionError instanceof TypeError, true); + assertEq( + registry.liveCount, + 0, + "earlier arguments unwind on conversion failure", + ); + + const guestError = await caught(() => c.exports.fail(cell)); + assertEq(guestError instanceof Trap, true); + assertEq( + registry.liveCount, + 0, + "arguments unwind when guest execution fails", + ); + }, +}); + Deno.test({ name: "host resources: a borrow-allocated rep is released when the call returns", @@ -666,8 +557,8 @@ Deno.test({ "host resources: an own-registered instance survives the call, as owned", ignore: !borrowReady, fn: async () => { - // The other half of the rule: only a rep minted *for* the borrow is - // call-scoped. An instance the guest already owns keeps its rep. + // A host-originated borrow is always call-scoped. It neither reuses nor + // removes an independent own registration backed by the same object. const c = await instantiateFixture( "runtime/tests/embedder/host-borrow.wasm", { "host:api/res": { R: Cell, value: (r: Cell) => r.v } }, diff --git a/runtime/tests/embedder/resource-overlap-future.wasm b/runtime/tests/embedder/resource-overlap-future.wasm deleted file mode 100644 index 4006a1d..0000000 Binary files a/runtime/tests/embedder/resource-overlap-future.wasm and /dev/null differ diff --git a/runtime/tests/embedder/resource-overlap-future.wat b/runtime/tests/embedder/resource-overlap-future.wat deleted file mode 100644 index cac321b..0000000 --- a/runtime/tests/embedder/resource-overlap-future.wat +++ /dev/null @@ -1,23 +0,0 @@ -(component - (import "r" (type $R (sub resource))) - (import "next" (func $next (result (future u32)))) - (import "finish" (func $finish (param "r" (borrow $R)))) - (canon lower (func $next) (core func $next')) - (canon lower (func $finish) (core func $finish')) - (canon resource.drop $R (core func $drop)) - (core module $M - (import "" "next" (func $next (result i32))) - (import "" "finish" (func $finish (param i32))) - (import "" "drop" (func $drop (param i32))) - (func (export "run") (param $r i32) (result i32) - (local $f i32) - (call $finish (local.get $r)) - (local.set $f (call $next)) - (call $drop (local.get $r)) - (local.get $f))) - (core instance $m (instantiate $M (with "" (instance - (export "next" (func $next')) - (export "finish" (func $finish')) - (export "drop" (func $drop)))))) - (func (export "run") (param "r" (borrow $R)) (result (future u32)) - (canon lift (core func $m "run")))) diff --git a/runtime/tests/embedder/resource-overlap.wasm b/runtime/tests/embedder/resource-overlap.wasm index c972c44..86bbdf0 100644 Binary files a/runtime/tests/embedder/resource-overlap.wasm and b/runtime/tests/embedder/resource-overlap.wasm differ diff --git a/runtime/tests/embedder/resource-overlap.wat b/runtime/tests/embedder/resource-overlap.wat index 1b0bfc0..7f8ca6f 100644 --- a/runtime/tests/embedder/resource-overlap.wat +++ b/runtime/tests/embedder/resource-overlap.wat @@ -8,10 +8,7 @@ (canon lower (func $value) (core func $value')) (canon resource.drop $R (core func $drop)) (core module $D - (global $drops (mut i32) (i32.const 0)) - (func (export "drop") (param i32) - (global.set $drops (i32.add (global.get $drops) (i32.const 1)))) - (func (export "drops") (result i32) (global.get $drops))) + (func (export "drop") (param i32))) (core instance $d (instantiate $D)) (type $ticket (resource (rep i32) (dtor (func $d "drop")))) (canon resource.new $ticket (core func $new)) @@ -25,10 +22,16 @@ (local.set $out (call $value (local.get $h))) (call $drop (local.get $h)) (local.get $out)) - (func (export "peek-two") (param $a i32) (param $b i32) (result i32) - (call $peek (local.get $a)) + (func (export "accept-two") (param $a i32) (param $b i32) + (call $drop (local.get $a)) (call $drop (local.get $b))) + (func (export "fail") (param $h i32) + (call $drop (local.get $h)) + unreachable) (func (export "hold") (param i32) (result i32) (local.get 0)) + (func (export "held-value") (param $h i32) (result i32) + (call $value (local.get $h))) + (func (export "return-held") (param i32) (result i32) (local.get 0)) (export "drop-held" (func $drop)) (func (export "ticket") (param $h i32) (result i32) (local $out i32) @@ -42,14 +45,16 @@ (canon lift (core func $i "peek"))) (func (export "hold") (param "r" (own $R)) (result u32) (canon lift (core func $i "hold"))) - (func (export "peek-two") (param "a" (borrow $R)) (param "b" (borrow $R)) (result u32) - (canon lift (core func $i "peek-two"))) + (func (export "held-value") (param "h" u32) (result u32) + (canon lift (core func $i "held-value"))) + (func (export "return-held") (param "h" u32) (result (own $R)) + (canon lift (core func $i "return-held"))) + (func (export "accept-two") (param "a" (borrow $R)) (param "b" (borrow $R)) + (canon lift (core func $i "accept-two"))) + (func (export "fail") (param "r" (borrow $R)) + (canon lift (core func $i "fail"))) (func (export "drop-held") (param "h" u32) (canon lift (core func $i "drop-held"))) (export $Ticket "ticket" (type $ticket)) (func (export "[constructor]ticket") (param "r" (borrow $R)) (result (own $Ticket)) - (canon lift (core func $i "ticket"))) - (func (export "make-ticket") (param "r" (borrow $R)) (result (own $Ticket)) - (canon lift (core func $i "ticket"))) - (func (export "ticket-drops") (result u32) - (canon lift (core func $d "drops")))) + (canon lift (core func $i "ticket"))))