diff --git a/contracts/descriptor-ir.md b/contracts/descriptor-ir.md index 7f14e63..459ea6f 100644 --- a/contracts/descriptor-ir.md +++ b/contracts/descriptor-ir.md @@ -98,9 +98,22 @@ fixtures. ## Resource-type identity -The shim emits `resource` indices into the plan's `resourceTables`; the runtime -builds identity tokens (`ResourceTypeInfo`) at plan-load time. **Tokens must be -fresh per instantiation** (the executor re-runs plan loading per instantiate), -so resource-type identity never leaks across instances. Within one -instantiation, concrete tables naming the same resource share a token; table -indices are aliases, not distinct types. +The shim emits `resource` indices into the plan's `resourceTables`. Preserve two +identities when loading these descriptors: + +- **Underlying resource identity**, keyed by concrete `ResourceIndex`, owns + implementation/destructor metadata in `ResourceTypeInfo`. Legitimate + cross-component aliases and host resource wrappers compare this identity. +- **Local handle type identity**, keyed by `TypeResourceTableIndex`, governs + guest handle access through `ResourceTableInfo`, which references its + underlying resource. Distinct abstract imports can share an underlying + resource and a component instance while retaining different local identities. + +Both are scoped to a runtime instantiation of the plan, not just its reusable +JSON object. Guest-defined identities are fresh on every instantiation. +Transferring a handle validates the source local type and tags the destination +handle with its destination local type; it does not change the resource origin. +Stream/future endpoints likewise retain their local element descriptors for +guest access checks, while rendezvous compatibility uses underlying identities. +Sharing origin metadata must not erase the child's abstract type distinctions +(pinned Explainer, type imports and substitution). diff --git a/contracts/intrinsics.md b/contracts/intrinsics.md index 683eedd..9b03d45 100644 --- a/contracts/intrinsics.md +++ b/contracts/intrinsics.md @@ -51,6 +51,12 @@ directly. `modules[].intrinsics` records import names and resolved categories. - **Resource borrows:** transfer registers a lender on the current call scope, including re-lending an already borrowed handle and same-instance rep fast paths. A lend prevents own transfer or drop until its scope ends. +- **Resource handle types:** validate against the source resource-table + identity, then create the destination handle with its destination table + identity. Tables may share resource origin and destructor metadata without + being interchangeable inside the guest. Stream/future operations check their + endpoint's local element type; boundary transfers compare underlying origins + and retag the endpoint for the receiving component. - **Unwind:** a failed call releases the lenders it registered, including non-poisoning capability failures. The host boundary restores `may_leave` according to entry identity: it excludes the host entry's own instance and diff --git a/contracts/plan-format.md b/contracts/plan-format.md index c8e13ec..87ccfa5 100644 --- a/contracts/plan-format.md +++ b/contracts/plan-format.md @@ -265,8 +265,11 @@ Notes on specific entries: resource type can be reachable through several distinct table indices — e.g. a type export pointing at table 1 while the functions' handles use table 0, both resolving to the same `ResourceIndex` via `resourceTables[n].resource`. - Consumers keying per-resource state must key by the resolved `ResourceIndex`, - treating table indices as aliases. + Implementation/destructor state is keyed by the resolved `ResourceIndex`. + Guest handle checks must retain the table index: two abstract types inside one + nested instance may resolve to the same origin without being interchangeable + there. `TypeResourceTableIndex` is not reconstructible from the pair + `(ResourceIndex, instance)`; preserve the emitted table entries. - **Module exports**: the executor surfaces the export as the platform's compiled-module value — `WebAssembly.Module` in the JS runtime — reusing the compilation the instantiation path already performs. Module exports are diff --git a/docs/architecture.md b/docs/architecture.md index e756b37..66d835d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -356,6 +356,15 @@ failure is reported through the host-failure channel, not swallowed. Backstop-versus-teardown policy remains tracked in [#10](https://github.com/polymorph-components/polyengine/issues/10). +Resource origin and guest-local handle type are separate identities. The plan's +`ResourceIndex` identifies shared implementation/destructor metadata; +`TypeResourceTableIndex` preserves the importing component's abstract type. +Distinct tables can have the same origin and component instance. Guest handle +access compares the local type; transfers validate the source and tag the +destination. Host wrappers and cross-component payload compatibility retain +underlying origin identity. Both identities are scoped to an instantiation of +the plan; see [descriptor IR](../contracts/descriptor-ir.md#resource-type-identity). + **Destructors.** `canon_resource_drop` lifts a core `[rep] -> []` destructor with synchronous canonical options. The destructor may not Component-Model-block, though the spec permits spawning an explicit diff --git a/runtime/src/cabi/async_values.ts b/runtime/src/cabi/async_values.ts index c43219e..527b75a 100644 --- a/runtime/src/cabi/async_values.ts +++ b/runtime/src/cabi/async_values.ts @@ -11,7 +11,7 @@ import { copyCensus, ERROR_CONTEXT, hasBrand } from "@polyengine/protocol"; import { assert_, trapIf } from "./trap.ts"; import type { LiftLowerContext } from "./context.ts"; import type { ValType } from "./types.ts"; -import { contains, fmtValType } from "./types.ts"; +import { contains, fmtValType, valTypeEqual } from "./types.ts"; import { CopyState, ErrorContext, @@ -74,11 +74,12 @@ function liftAsyncValue( trapIf(!(e instanceof EndT), `${what} lift: handle is not a ${what} end`); const end = e as { shared: SharedBase; + elem: ValType | null; state: CopyState; inWaitableSet(): boolean; }; trapIf( - !sameElemType(end.shared.t, elem), + !valTypeEqual(end.elem, elem), `${what} lift: element type mismatch`, ); trapIf( @@ -163,7 +164,7 @@ export function lowerStream( (v as { boundStore?: unknown }).boundStore ??= (inst as unknown as { store?: unknown }).store; (v as { onLowered?: ((i: unknown) => void) | null }).onLowered?.(inst); - return inst!.handles.add(new ReadableStreamEnd(v)); + return inst!.handles.add(new ReadableStreamEnd(v, declared)); } /** definitions.py `lower_future`. */ @@ -190,7 +191,7 @@ export function lowerFuture( (v as { boundStore?: unknown }).boundStore ??= (inst as unknown as { store?: unknown }).store; (v as { onLowered?: ((i: unknown) => void) | null }).onLowered?.(inst); - return inst!.handles.add(new ReadableFutureEnd(v)); + return inst!.handles.add(new ReadableFutureEnd(v, declared)); } /** definitions.py `lift_error_context`. Does not remove the handle. */ diff --git a/runtime/src/cabi/handles.ts b/runtime/src/cabi/handles.ts index 0cd8a92..cceaaa7 100644 --- a/runtime/src/cabi/handles.ts +++ b/runtime/src/cabi/handles.ts @@ -17,7 +17,12 @@ import type { SubtaskBorrowScope, TaskBorrowScope, } from "./context.ts"; -import type { BorrowType, OwnType, ResourceTypeInfo } from "./types.ts"; +import type { + BorrowType, + OwnType, + ResourceTableInfo, + ResourceTypeInfo, +} from "./types.ts"; export class Table { static readonly MAX_LENGTH = 2 ** 28 - 1; @@ -65,7 +70,7 @@ export class ResourceHandle { numLends = 0; constructor( - public rt: ResourceTypeInfo, + public rt: ResourceTableInfo, public rep: number, public own: boolean, public borrowScope: TaskBorrowScope | null = null, @@ -133,7 +138,7 @@ export function lowerBorrow( scope !== null && typeof scope.numBorrows === "number", "lowering a borrow requires a task borrow scope", ); - if (cx.inst !== null && cx.inst === (t.rt.impl as unknown)) { + if (cx.inst !== null && cx.inst === (t.rt.resource.impl as unknown)) { return rep; } const h = new ResourceHandle(t.rt, rep, false, scope); @@ -148,7 +153,7 @@ export function lowerBorrow( export function canonResourceNew( inst: ComponentInstanceLike, - rt: ResourceTypeInfo, + rt: ResourceTableInfo, rep: number, ): number { trapIf(!inst.mayLeave, "may_leave violation"); @@ -221,7 +226,7 @@ export function callDtorGated( export function canonResourceDrop( inst: ComponentInstanceLike, - rt: ResourceTypeInfo, + rt: ResourceTableInfo, i: number, ): void { trapIf(!inst.mayLeave, "may_leave violation"); @@ -233,7 +238,7 @@ export function canonResourceDrop( if (rh.own) { assert_(rh.borrowScope === null); // Enter a fresh synchronous dtor task, not the dropping task's ambient. - callDtorGated(rt, rh.rep, inst); + callDtorGated(rt.resource, rh.rep, inst); } else { assert_(rh.borrowScope !== null); rh.borrowScope!.numBorrows -= 1; @@ -243,7 +248,7 @@ export function canonResourceDrop( export function canonResourceRep( inst: ComponentInstanceLike, - rt: ResourceTypeInfo, + rt: ResourceTableInfo, i: number, ): number { const h = inst.handles.get(i); diff --git a/runtime/src/cabi/types.ts b/runtime/src/cabi/types.ts index e3f25e7..67bd61f 100644 --- a/runtime/src/cabi/types.ts +++ b/runtime/src/cabi/types.ts @@ -39,7 +39,7 @@ export interface InstanceLike { /** * definitions.py `ResourceType`: identity + implementing instance + optional - * destructor. Compared by object identity everywhere. + * destructor. Shared origin identity across component-local resource tables. * * `dtorHost` is the host-initiated drop entry, wired by exec/executor.ts or * lazily by `hostDtorCall` in exec/boundary.ts. It lifts the dtor with a fresh @@ -58,6 +58,11 @@ export class ResourceTypeInfo { ) {} } +/** Component-local handle identity; distinct wire tables never share a wrapper. */ +export class ResourceTableInfo { + constructor(readonly resource: ResourceTypeInfo) {} +} + // --------------------------------------------------------------------------- // Value types (definitions.py ValType hierarchy) // --------------------------------------------------------------------------- @@ -133,11 +138,11 @@ export interface FlagsType { } export interface OwnType { kind: "own"; - rt: ResourceTypeInfo; + rt: ResourceTableInfo; } export interface BorrowType { kind: "borrow"; - rt: ResourceTypeInfo; + rt: ResourceTableInfo; } export interface StreamType { kind: "stream"; @@ -418,34 +423,41 @@ export function contains( // --------------------------------------------------------------------------- /** - * Structural equality except for resource types, which compare by token - * identity. Do not serialize or recurse into ResourceTypeInfo: its instance + * Structural equality with local resource-table identity by default. Shared + * async payload compatibility explicitly selects underlying origin identity. + * Do not serialize or recurse into ResourceTypeInfo: its instance * points back to live handle tables and can form cycles. */ export function valTypesEqual(a: ValType[], b: ValType[]): boolean { return a.length === b.length && a.every((t, i) => valTypeEqual(t, b[i])); } -export function valTypeEqual(a: ValType, b: ValType): boolean { +export function valTypeEqual( + a: ValType | null, + b: ValType | null, + identity: "local" | "underlying" = "local", +): boolean { if (a === b) return true; + if (a === null || b === null) return false; if (a.kind !== b.kind) return false; switch (a.kind) { case "list": { const bb = b as typeof a; - return a.length === bb.length && valTypeEqual(a.element, bb.element); + return a.length === bb.length && + valTypeEqual(a.element, bb.element, identity); } case "record": { const bb = b as typeof a; return a.fields.length === bb.fields.length && a.fields.every((f, i) => f.label === bb.fields[i].label && - valTypeEqual(f.type, bb.fields[i].type) + valTypeEqual(f.type, bb.fields[i].type, identity) ); } case "tuple": { const bb = b as typeof a; return a.elements.length === bb.elements.length && - a.elements.every((e, i) => valTypeEqual(e, bb.elements[i])); + a.elements.every((e, i) => valTypeEqual(e, bb.elements[i], identity)); } case "variant": { const bb = b as typeof a; @@ -456,7 +468,7 @@ export function valTypeEqual(a: ValType, b: ValType): boolean { if (c.type === null || other.type === null) { return c.type === other.type; } - return valTypeEqual(c.type, other.type); + return valTypeEqual(c.type, other.type, identity); }); } case "enum": @@ -467,30 +479,33 @@ export function valTypeEqual(a: ValType, b: ValType): boolean { } case "option": { const bb = b as typeof a; - return valTypeEqual(a.type, bb.type); + return valTypeEqual(a.type, bb.type, identity); } case "result": { const bb = b as typeof a; if ((a.ok === null) !== (bb.ok === null)) return false; if ((a.error === null) !== (bb.error === null)) return false; - return (a.ok === null || valTypeEqual(a.ok, bb.ok!)) && - (a.error === null || valTypeEqual(a.error, bb.error!)); + return (a.ok === null || valTypeEqual(a.ok, bb.ok!, identity)) && + (a.error === null || valTypeEqual(a.error, bb.error!, identity)); } case "map": { const bb = b as typeof a; - return valTypeEqual(a.key, bb.key) && valTypeEqual(a.value, bb.value); + return valTypeEqual(a.key, bb.key, identity) && + valTypeEqual(a.value, bb.value, identity); } case "own": case "borrow": { const bb = b as typeof a; - // Object-identity type (documented invariant): reference equality only. - return a.rt === bb.rt; + return identity === "local" + ? a.rt === bb.rt + : a.rt.resource === bb.rt.resource; } case "stream": case "future": { const bb = b as typeof a; if ((a.element === null) !== (bb.element === null)) return false; - return a.element === null || valTypeEqual(a.element, bb.element!); + return a.element === null || + valTypeEqual(a.element, bb.element!, identity); } case "error-context": return true; diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 41d2fb3..c8befee 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -276,11 +276,11 @@ class Facade { this.#resolver = new ImportResolver(providers); this.loaded = loadPlan(artifacts.plan); // Resolve identity before core start functions can call imports. Concrete - // tables naming one ResourceIndex share a token; table indices are aliases + // tables naming one ResourceIndex share an origin, not local handle identity // (plan-format.md "Type exports index into `resourceTables`"). artifacts.plan.resourceTables.forEach((table, i) => { if (table.kind !== "concrete") return; - const token = this.loaded.resourceTokens[i]; + const token = this.loaded.resourceTokens[i]?.resource; if (token !== undefined) this.#tokenIndex.set(token, table.resource); }); this.leaves = requiredImports(this.loaded); @@ -407,28 +407,28 @@ class Facade { const self = this; return { liftOwn(rep, t) { - const b = self.#binding(t.rt); + const b = self.#binding(t.rt.resource); // Host-implemented R: "the host's own instance back; the guest's // handle is gone; no dispose call" (contract 2x4 table). if (b.kind === "host") return b.registry.release(rep); - return makeWrapper(self.#guestClass(b), rep, t.rt, true); + return makeWrapper(self.#guestClass(b), rep, t.rt.resource, true); }, liftBorrow(rep, t, scope) { - const b = self.#binding(t.rt); + const b = self.#binding(t.rt.resource); // Host-implemented R: "the host's own instance; borrow scoping is // guest-side bookkeeping" — the mapping is kept. if (b.kind === "host") return b.registry.lookup(rep); - const w = makeWrapper(self.#guestClass(b), rep, t.rt, false); + const w = makeWrapper(self.#guestClass(b), rep, t.rt.resource, false); scope.add(() => invalidateWrapper(w)); return w; }, lowerOwn(v, t) { - const b = self.#binding(t.rt); + const b = self.#binding(t.rt.resource); if (b.kind === "host") return b.registry.repFor(v); - return takeRep(v, t.rt, true, `own<${b.name}>`); + return takeRep(v, t.rt.resource, true, `own<${b.name}>`); }, lowerBorrow(v, t) { - const b = self.#binding(t.rt); + const b = self.#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. @@ -439,7 +439,7 @@ class Facade { } // Retain the rep until this call ends; explicit/GC drop must not // destroy it while borrowed (lift_borrow -> Subtask.add_lender). - const rep = takeRep(v, t.rt, false, `borrow<${b.name}>`); + const rep = takeRep(v, t.rt.resource, false, `borrow<${b.name}>`); const release = lendWrapper(v as object); if (self.#lowerScope === null) { // No enclosing lowering scope (a raw/one-off lowering): the lend @@ -456,12 +456,12 @@ class Facade { // host-implemented R runs the instance's [Symbol.dispose] through // the registry; guest-implemented R runs the guest dtor via the // gated path (a host-initiated drop, `caller = None`). - const b = self.#binding(t.rt); + const b = self.#binding(t.rt.resource); if (b.kind === "host") { b.registry.dtor(rep); return; } - hostDtorCall(t.rt, rep); + hostDtorCall(t.rt.resource, rep); }, }; } @@ -865,7 +865,7 @@ class Facade { // from the resource TABLE it points at (the wire field is a table // index, like `own`/`borrow`). if (exp.type.kind === "resource") { - const token = this.loaded.resourceTokens[exp.type.resource]; + const token = this.loaded.resourceTokens[exp.type.resource]?.resource; if (token !== undefined && this.#tokenIndex.has(token)) { const index = this.#tokenIndex.get(token)!; const held = this.#bindings.get(index); @@ -1274,7 +1274,7 @@ function rtOf( name: string, ): void { if (t === undefined) return; - if (t.kind === "own" || t.kind === "borrow") into.set(name, t.rt); + if (t.kind === "own" || t.kind === "borrow") into.set(name, t.rt.resource); } function label(leaf: ImportLeaf): string { diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index f11b364..c8fa674 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -499,7 +499,7 @@ class Executor { } // A type-only import may have no concrete table at all; that is fine, // there is simply no runtime state to bind. - const token = this.loaded.resourceTokens[tableIndex]; + const token = this.loaded.resourceTokens[tableIndex].resource; token.impl = null; token.dtor = dtor === undefined ? null : (rep: number) => dtor(rep); }); @@ -674,7 +674,7 @@ class Executor { const resourceIndex = resourceIndexOfDefined(this.loaded, init.index); this.wire.resourceTables.forEach((table, tableIndex) => { if (table.kind === "concrete" && table.resource === resourceIndex) { - const token = this.loaded.resourceTokens[tableIndex]; + const token = this.loaded.resourceTokens[tableIndex].resource; token.impl = inst; token.dtor = dtor; // Canonical lifted destructor: createDtorEntry supplies its diff --git a/runtime/src/intrinsics/mod.ts b/runtime/src/intrinsics/mod.ts index a52c5f7..0774834 100644 --- a/runtime/src/intrinsics/mod.ts +++ b/runtime/src/intrinsics/mod.ts @@ -12,7 +12,7 @@ import { ResourceHandle } from "../cabi/handles.ts"; import { removeHandleWithUnwind } from "../task/scheduler.ts"; import { trapIf } from "../cabi/trap.ts"; import { assert_ } from "../cabi/trap.ts"; -import type { ResourceTypeInfo } from "../cabi/types.ts"; +import type { ResourceTableInfo } from "../cabi/types.ts"; import type { ComponentInstanceState } from "../task/mod.ts"; import { dbgId, @@ -209,7 +209,7 @@ export interface FactStartScope { /** Executor services a trampoline body needs (provided by executor.ts). */ export interface TrampolineContext { componentInstance(index: number): ComponentInstanceState; - resourceToken(index: number): ResourceTypeInfo; + resourceToken(index: number): ResourceTableInfo; /** * The component instance that *owns* resource table `index` * (`TypeResourceTable::Concrete.instance`), i.e. whose handle table the @@ -788,7 +788,9 @@ function transferBorrow( // definitions.py `lower_borrow`: `if inst is t.rt.impl: return rep` — a // component that implements the resource is handed the rep directly and // gets no handle (and therefore no `num_borrows` obligation). - if (dstRt.impl !== null && (dstRt.impl as unknown) === dst) return rh.rep; + if ( + dstRt.resource.impl !== null && (dstRt.resource.impl as unknown) === dst + ) return rh.rep; const borrowScope = fact !== undefined ? fact.taskScope : scope!; borrowScope.numBorrows += 1; return dst.handles.add(new ResourceHandle(dstRt, rh.rep, false, borrowScope)); diff --git a/runtime/src/intrinsics/stream_builtins.ts b/runtime/src/intrinsics/stream_builtins.ts index f2d593a..06e453f 100644 --- a/runtime/src/intrinsics/stream_builtins.ts +++ b/runtime/src/intrinsics/stream_builtins.ts @@ -23,6 +23,7 @@ import { errorContextTrapMessage } from "../cabi/async_values.ts"; import { LiftLowerContext } from "../cabi/context.ts"; import { loadStringFromRange, storeString } from "../cabi/strings.ts"; import type { ValType } from "../cabi/types.ts"; +import { valTypeEqual } from "../cabi/types.ts"; import { abandonReasonOf, BUFFER_MAX_LENGTH, @@ -97,8 +98,8 @@ export function createStreamNew( return () => { trapIf(!inst.mayLeave, "stream.new: cannot leave component instance"); const shared = new SharedStreamImpl(ctx.streamElem(decl.streamTable)); - const ri = inst.handles.add(new ReadableStreamEnd(shared)); - const wi = inst.handles.add(new WritableStreamEnd(shared)); + const ri = inst.handles.add(new ReadableStreamEnd(shared, shared.t)); + const wi = inst.handles.add(new WritableStreamEnd(shared, shared.t)); return packEnds(ri, wi); }; } @@ -111,8 +112,8 @@ export function createFutureNew( return () => { trapIf(!inst.mayLeave, "future.new: cannot leave component instance"); const shared = new SharedFutureImpl(ctx.futureElem(decl.futureTable)); - const ri = inst.handles.add(new ReadableFutureEnd(shared)); - const wi = inst.handles.add(new WritableFutureEnd(shared)); + const ri = inst.handles.add(new ReadableFutureEnd(shared, shared.t)); + const wi = inst.handles.add(new WritableFutureEnd(shared, shared.t)); return packEnds(ri, wi); }; } @@ -147,7 +148,7 @@ function streamCopy(input: { const e = inst.handles.get(i); trapIf(!(e instanceof EndT), "stream copy: wrong end type for this handle"); const end = e as ReadableStreamEnd | WritableStreamEnd; - trapIf(!sameElem(end.shared.t, elem), "stream copy: element type mismatch"); + trapIf(!valTypeEqual(end.elem, elem), "stream copy: element type mismatch"); // wasmtime distinguishes the two non-IDLE states in its message, and the // suite asserts the exact text: DONE means the other end has gone away (or // this end's single-shot operation already finished), COPYING means the @@ -231,7 +232,7 @@ function futureCopy(input: { const e = inst.handles.get(i); trapIf(!(e instanceof EndT), "future copy: wrong end type for this handle"); const end = e as ReadableFutureEnd | WritableFutureEnd; - trapIf(!sameElem(end.shared.t, elem), "future copy: element type mismatch"); + trapIf(!valTypeEqual(end.elem, elem), "future copy: element type mismatch"); // Writable DONE covers either a completed write or a dropped readable end. trapIf( end.state === CopyState.DONE, @@ -411,7 +412,7 @@ function cancelCopy(input: { const e = inst.handles.get(i); trapIf(!(e instanceof EndT), `${what}: wrong end type for this handle`); const end = e as CopyEnd; - trapIf(!sameElem(end.shared.t, elem), `${what}: element type mismatch`); + trapIf(!valTypeEqual(end.elem, elem), `${what}: element type mismatch`); trapIf( end.state !== CopyState.COPYING || end.hasSyncWaiter, `${what}: end is not in a cancellable copy`, @@ -465,7 +466,7 @@ function dropEnd( removeHandleWithUnwind(inst, hi, (e) => { trapIf(!(e instanceof EndT), `${what}: wrong end type for this handle`); const end = e as CopyEnd; - trapIf(!sameElem(end.shared.t, elem), `${what}: element type mismatch`); + trapIf(!valTypeEqual(end.elem, elem), `${what}: element type mismatch`); end.drop(); }); } @@ -815,7 +816,7 @@ function transferAsyncEnd(input: { ); const end = e as CopyEnd; trapIf( - !sameElem(end.shared.t, srcElem), + !valTypeEqual(end.elem, srcElem), `${what}: source element mismatch`, ); trapIf( @@ -839,8 +840,11 @@ function transferAsyncEnd(input: { end.inWaitableSet(), `cannot lift ${what} while it's in a waitable set`, ); - const Ctor = EndT as unknown as new (shared: unknown) => CopyEnd; - return dstInst.handles.add(new Ctor(end.shared)); + const Ctor = EndT as unknown as new ( + shared: unknown, + elem: ValType | null, + ) => CopyEnd; + return dstInst.handles.add(new Ctor(end.shared, dstElem)); }); } diff --git a/runtime/src/plan/loader.ts b/runtime/src/plan/loader.ts index 0983bf2..39b9065 100644 --- a/runtime/src/plan/loader.ts +++ b/runtime/src/plan/loader.ts @@ -7,11 +7,12 @@ // - func params `{label, type}[]` (wire) -> unlabeled `ValType[]` // (types.ts drops ABI-irrelevant names; labels are preserved separately // for bindgen/digest use) -// - own/borrow `resource: ` (wire) -> `ResourceTypeInfo` -// identity tokens shared by concrete tables naming one resource +// - own/borrow `resource:
` (wire) -> `ResourceTableInfo` +// local identity tokens; only their underlying resource origin is shared import { type FuncType, + ResourceTableInfo, ResourceTypeInfo, type ValType, } from "../cabi/types.ts"; @@ -83,11 +84,11 @@ export interface LoadedPlan { /** Converted types table, index-aligned with `wire.types`. */ types: LoadedType[]; /** - * Index-aligned with wire.resourceTables. Concrete tables naming the same - * ResourceIndex share a token; abstract tables get distinct tokens. - * The executor fills implementation/destructor state during instantiation. + * Index-aligned with wire.resourceTables, with distinct local table tokens. + * Concrete tables naming one ResourceIndex share only their underlying resource. + * The executor fills that origin's implementation/destructor state. */ - resourceTokens: ResourceTypeInfo[]; + resourceTokens: ResourceTableInfo[]; /** * Number of imported resource types. `ResourceIndex = * numImportedResources + DefinedResourceIndex` @@ -183,18 +184,19 @@ export function loadPlan(wire: WirePlan): LoadedPlan { } } - // Nominal identity is per ResourceIndex, not table index. Alias concrete - // tables so resource-bearing type comparisons agree across component hops. - // Abstract tables have no ResourceIndex and retain per-table tokens. + // Preserve both identities: shared origin metadata per ResourceIndex, but + // a distinct local identity per table, even for equal resource/instance pairs. const tokenByResource = new Map(); const resourceTokens = wire.resourceTables.map((table) => { - if (table.kind !== "concrete") return new ResourceTypeInfo(null, null); + if (table.kind !== "concrete") { + return new ResourceTableInfo(new ResourceTypeInfo(null, null)); + } let token = tokenByResource.get(table.resource); if (token === undefined) { token = new ResourceTypeInfo(null, null); tokenByResource.set(table.resource, token); } - return token; + return new ResourceTableInfo(token); }); const types = wire.types.map((t, i) => loadTypeDecl(t, resourceTokens, `types[${i}]`) @@ -701,7 +703,7 @@ function validateTypeExport(t: unknown, where: string): void { function loadTypeDecl( t: WireTypeDecl, - resourceTokens: ResourceTypeInfo[], + resourceTokens: ResourceTableInfo[], where: string, ): LoadedType { if (t.kind === "func") { @@ -729,7 +731,7 @@ function loadTypeDecl( /** @internal */ export function loadValType( t: WireValType, - resourceTokens: ResourceTypeInfo[], + resourceTokens: ResourceTableInfo[], where: string, ): ValType { switch (t.kind) { diff --git a/runtime/src/task/streams.ts b/runtime/src/task/streams.ts index 07a501d..0a0d13c 100644 --- a/runtime/src/task/streams.ts +++ b/runtime/src/task/streams.ts @@ -20,11 +20,10 @@ import type { ComponentValue, ValType } from "../cabi/types.ts"; import { Waitable } from "./waitable.ts"; import { isInstancePoisoned, setOnInstancePoisoned } from "./scheduler.ts"; -/** Structural element-type equality, retaining nominal resource identity. +/** Cross-boundary structural equality, comparing resource origins, not local tables. * `null` denotes the zero-width payload. */ export function sameElemType(a: ValType | null, b: ValType | null): boolean { - if (a === null || b === null) return a === b; - return valTypeEqual(a, b); + return valTypeEqual(a, b, "underlying"); } /** definitions.py `Buffer.MAX_LENGTH`. */ @@ -719,7 +718,11 @@ export class SharedFutureImpl implements SharedBase { export abstract class CopyEnd extends Waitable { state: CopyState = CopyState.IDLE; - constructor(readonly shared: SharedBase) { + constructor( + readonly shared: SharedBase, + // Standalone ends inherit their shared descriptor; guest sites stamp locals. + readonly elem: ValType | null = shared.t, + ) { super(); } diff --git a/runtime/tests/dtor_guest_context_test.ts b/runtime/tests/dtor_guest_context_test.ts index d0cdc0c..efbc080 100644 --- a/runtime/tests/dtor_guest_context_test.ts +++ b/runtime/tests/dtor_guest_context_test.ts @@ -3,6 +3,7 @@ import { canonResourceDrop, canonResourceNew, + ResourceTableInfo, ResourceTypeInfo, Trap, } from "../src/cabi/mod.ts"; @@ -47,17 +48,19 @@ Deno.test("guest dtor isolates task, both context slots, and post-return attribu const caller = new ComponentInstanceState(0, store); const impl = new ComponentInstanceState(1, store); let outer: Thread; - const rt = new ResourceTypeInfo(impl, (rep) => { - assertEq(rep, 7); - assertEq(currentTask().inst === impl, true); - assertEq(currentTask().ft.async, false); - assertEq(currentTask().opts.async_, false); - const thread = currentThread(); - assertEq(thread === outer, false); - assertEq(thread.storage, [0, 0]); - thread.storage[0] = 99; - thread.storage[1] = 100; - }); + const rt = new ResourceTableInfo( + new ResourceTypeInfo(impl, (rep) => { + assertEq(rep, 7); + assertEq(currentTask().inst === impl, true); + assertEq(currentTask().ft.async, false); + assertEq(currentTask().opts.async_, false); + const thread = currentThread(); + assertEq(thread === outer, false); + assertEq(thread.storage, [0, 0]); + thread.storage[0] = 99; + thread.storage[1] = 100; + }), + ); const failure = new Trap("post-return trap"); const opts = options(caller); opts.postReturn = () => () => { @@ -193,14 +196,18 @@ Deno.test("async caller cannot give guest dtor async completion or a host-wide d core: () => { const outer = currentThread(); store.waiting.push(sibling as never); - const quick = new ResourceTypeInfo(impl, () => { - assertEq(currentTask().ft.async, false); - assertEq(currentTask().opts.async_, false); - }); + const quick = new ResourceTableInfo( + new ResourceTypeInfo(impl, () => { + assertEq(currentTask().ft.async, false); + assertEq(currentTask().opts.async_, false); + }), + ); canonResourceDrop(caller, quick, canonResourceNew(caller, quick, 0)); assertEq(ranSibling, false); store.waiting.pop(); - const slow = new ResourceTypeInfo(impl, () => Promise.resolve()); + const slow = new ResourceTableInfo( + new ResourceTypeInfo(impl, () => Promise.resolve()), + ); let caught: unknown; try { canonResourceDrop(caller, slow, canonResourceNew(caller, slow, 0)); diff --git a/runtime/tests/handles_test.ts b/runtime/tests/handles_test.ts index ec2c17c..18244c0 100644 --- a/runtime/tests/handles_test.ts +++ b/runtime/tests/handles_test.ts @@ -15,6 +15,7 @@ import { lowerBorrow, lowerOwn, type ResourceHandle, + ResourceTableInfo, ResourceTypeInfo, type SubtaskBorrowScope, Table, @@ -68,9 +69,11 @@ Deno.test("Table: traps on empty/out-of-range indices", () => { Deno.test("resource.new / resource.rep / resource.drop with dtor", () => { const inst = mkInst(); let dtorValue: number | null = null; - const rt = new ResourceTypeInfo(inst, (rep) => { - dtorValue = rep; - }); + const rt = new ResourceTableInfo( + new ResourceTypeInfo(inst, (rep) => { + dtorValue = rep; + }), + ); const h1 = canonResourceNew(inst, rt, 42); const h2 = canonResourceNew(inst, rt, 43); @@ -89,8 +92,8 @@ Deno.test("resource.new / resource.rep / resource.drop with dtor", () => { Deno.test("resource type identity is enforced", () => { const inst = mkInst(); - const rtA = new ResourceTypeInfo(inst); - const rtB = new ResourceTypeInfo(inst); + const rtA = new ResourceTableInfo(new ResourceTypeInfo(inst)); + const rtB = new ResourceTableInfo(rtA.resource); const h = canonResourceNew(inst, rtA, 1); assertTrap(() => canonResourceRep(inst, rtB, h), "rep with wrong rt"); assertTrap(() => canonResourceDrop(inst, rtB, h), "drop with wrong rt"); @@ -98,7 +101,7 @@ Deno.test("resource type identity is enforced", () => { Deno.test("may_leave gates resource.new and resource.drop", () => { const inst = mkInst(); - const rt = new ResourceTypeInfo(inst); + const rt = new ResourceTableInfo(new ResourceTypeInfo(inst)); const h = canonResourceNew(inst, rt, 5); inst.mayLeave = false; assertTrap(() => canonResourceNew(inst, rt, 6)); @@ -109,7 +112,7 @@ Deno.test("may_leave gates resource.new and resource.drop", () => { Deno.test("own lift/lower: transfer moves the handle out of the table", () => { const inst = mkInst(); - const rt = new ResourceTypeInfo(inst); + const rt = new ResourceTableInfo(new ResourceTypeInfo(inst)); const cx = new LiftLowerContext(mkOpts(), inst); const ownT = { kind: "own", rt } as const; @@ -122,8 +125,8 @@ Deno.test("own lift/lower: transfer moves the handle out of the table", () => { Deno.test("own lift traps: wrong type, borrowed handle, lent-out handle", () => { const inst = mkInst(); - const rt = new ResourceTypeInfo(inst); - const rt2 = new ResourceTypeInfo(inst); + const rt = new ResourceTableInfo(new ResourceTypeInfo(inst)); + const rt2 = new ResourceTableInfo(rt.resource); const cx = new LiftLowerContext(mkOpts(), inst); // NB: like the reference, lift_own removes the handle *before* the type @@ -135,6 +138,11 @@ Deno.test("own lift traps: wrong type, borrowed handle, lent-out handle", () => i = lowerOwn(cx, 42, { kind: "own", rt }); const subtask = new MockSubtask(); const cxBorrow = new LiftLowerContext(mkOpts(), inst, subtask); + assertTrap( + () => liftBorrow(cxBorrow, i, { kind: "borrow", rt: rt2 }), + "local borrow identity", + ); + assertEq((inst.handles.get(i) as ResourceHandle).numLends, 0); liftBorrow(cxBorrow, i, { kind: "borrow", rt }); assertTrap(() => liftOwn(cx, i, { kind: "own", rt }), "num_lends != 0"); subtask.deliverResolve(); @@ -145,7 +153,7 @@ Deno.test("own lift traps: wrong type, borrowed handle, lent-out handle", () => // a borrow handle cannot be lifted as own const task: TaskBorrowScope = { numBorrows: 0 }; const otherImpl = mkInst(); - const rtOther = new ResourceTypeInfo(otherImpl); + const rtOther = new ResourceTableInfo(new ResourceTypeInfo(otherImpl)); const cxLowerBorrow = new LiftLowerContext(mkOpts(), inst, task); const bi = lowerBorrow(cxLowerBorrow, 7, { kind: "borrow", rt: rtOther }); assertTrap( @@ -156,7 +164,7 @@ Deno.test("own lift traps: wrong type, borrowed handle, lent-out handle", () => Deno.test("borrow lift counts lends; drop of lent handle traps", () => { const inst = mkInst(); - const rt = new ResourceTypeInfo(inst); + const rt = new ResourceTableInfo(new ResourceTypeInfo(inst)); const subtask = new MockSubtask(); const cx = new LiftLowerContext(mkOpts(), inst, subtask); @@ -177,7 +185,7 @@ Deno.test("borrow lift counts lends; drop of lent handle traps", () => { Deno.test("borrow lower: self-instance passthrough vs cross-instance handle", () => { const implInst = mkInst(); - const rt = new ResourceTypeInfo(implInst); + const rt = new ResourceTableInfo(new ResourceTypeInfo(implInst)); const task: TaskBorrowScope = { numBorrows: 0 }; // lowering into the implementing instance passes the rep through diff --git a/runtime/tests/park_state_settle_test.ts b/runtime/tests/park_state_settle_test.ts index 48a948d..6d2679c 100644 --- a/runtime/tests/park_state_settle_test.ts +++ b/runtime/tests/park_state_settle_test.ts @@ -54,6 +54,7 @@ import { canonResourceDrop, canonResourceNew, ResourceHandle, + ResourceTableInfo, ResourceTypeInfo, } from "../src/cabi/mod.ts"; import type { FuncType } from "../src/cabi/types.ts"; @@ -274,7 +275,7 @@ function mkImportWorld(input: { suspendable: boolean; }) { const w = mkWorld(); - const rt = new ResourceTypeInfo(w.inst, () => {}); + const rt = new ResourceTableInfo(new ResourceTypeInfo(w.inst, () => {})); const handleIndex = canonResourceNew(w.inst, rt, 77); const handle = w.inst.handles.get(handleIndex) as ResourceHandle; const ft: FuncType = { diff --git a/runtime/tests/plan_loader_test.ts b/runtime/tests/plan_loader_test.ts index 1e8363e..571322a 100644 --- a/runtime/tests/plan_loader_test.ts +++ b/runtime/tests/plan_loader_test.ts @@ -13,7 +13,7 @@ import { TranslateError, } from "../src/plan/mod.ts"; import type { WirePlan, WireValType } from "../src/plan/mod.ts"; -import { ResourceTypeInfo } from "../src/cabi/mod.ts"; +import { ResourceTableInfo } from "../src/cabi/mod.ts"; function assertPlanError(fn: () => unknown, includes: string) { try { @@ -123,9 +123,9 @@ Deno.test("loader: own/borrow resolve resource-table tokens by identity", () => if (own.kind !== "value" || borrow.kind !== "value") { throw new Error("expected value entries"); } - const ownRt = (own.type as { rt: ResourceTypeInfo }).rt; - const borrowRt = (borrow.type as { rt: ResourceTypeInfo }).rt; - assertEq(ownRt instanceof ResourceTypeInfo, true); + const ownRt = (own.type as { rt: ResourceTableInfo }).rt; + const borrowRt = (borrow.type as { rt: ResourceTableInfo }).rt; + assertEq(ownRt instanceof ResourceTableInfo, true); assertEq(ownRt === borrowRt, true, "same table -> same identity token"); assertEq(ownRt === loaded.resourceTokens[0], true); diff --git a/runtime/tests/resource_identity.wasm b/runtime/tests/resource_identity.wasm new file mode 100644 index 0000000..f394d0f Binary files /dev/null and b/runtime/tests/resource_identity.wasm differ diff --git a/runtime/tests/resource_identity.wat b/runtime/tests/resource_identity.wat new file mode 100644 index 0000000..5a809bf --- /dev/null +++ b/runtime/tests/resource_identity.wat @@ -0,0 +1,44 @@ +;; Regenerate: wasm-tools parse runtime/tests/resource_identity.wat -o runtime/tests/resource_identity.wasm +;; The parent supplies one origin; the child's subtype-bound imports remain distinct. +(component + (component $child + (import "a" (type $a (sub resource))) + (import "alias-a" (type $alias-a (eq $a))) + (import "b" (type $b (sub resource))) + (core func $drop-a (canon resource.drop $alias-a)) + (core func $drop-b (canon resource.drop $b)) + (core module $m + (import "h" "drop-a" (func $drop-a (param i32))) + (import "h" "drop-b" (func $drop-b (param i32))) + (func (export "same") (param i32) (call $drop-a (local.get 0))) + (func (export "different") (param i32) (call $drop-b (local.get 0)))) + (core instance $m (instantiate $m (with "h" (instance + (export "drop-a" (func $drop-a)) (export "drop-b" (func $drop-b)))))) + (func (export "same") (param "value" (own $a)) + (canon lift (core func $m "same"))) + (func (export "different") (param "value" (own $a)) + (canon lift (core func $m "different")))) + (core module $state + (global $drops (mut i32) (i32.const 0)) + (func (export "dtor") (param i32) + (global.set $drops (i32.add (global.get $drops) (i32.const 1)))) + (func (export "count") (result i32) (global.get $drops))) + (core instance $state (instantiate $state)) + (type $r (resource (rep i32) (dtor (func $state "dtor")))) + (instance $child (instantiate $child (with "a" (type $r)) (with "alias-a" (type $r)) (with "b" (type $r)))) + (core func $same (canon lower (func $child "same"))) + (core func $different (canon lower (func $child "different"))) + (core func $new (canon resource.new $r)) + (core module $main + (import "h" "new" (func $new (param i32) (result i32))) + (import "h" "same" (func $same (param i32))) + (import "h" "different" (func $different (param i32))) + (func (export "same") (call $same (call $new (i32.const 7)))) + (func (export "different") (call $different (call $new (i32.const 7))))) + (core instance $main (instantiate $main (with "h" (instance + (export "new" (func $new)) (export "same" (func $same)) + (export "different" (func $different)))))) + (func (export "same") (canon lift (core func $main "same"))) + (func (export "different") (canon lift (core func $main "different"))) + (func (export "count") (result u32) (canon lift (core func $state "count"))) +) diff --git a/runtime/tests/resource_identity_test.ts b/runtime/tests/resource_identity_test.ts new file mode 100644 index 0000000..6842a27 --- /dev/null +++ b/runtime/tests/resource_identity_test.ts @@ -0,0 +1,347 @@ +// Defensive identity checks: parent substitution must not erase child abstraction. +import { assertEq, assertTrap } from "./support/asserts.ts"; +import { Translator } from "../src/shim/mod.ts"; +import { instantiateComponent } from "../src/exec/mod.ts"; +import { + canonResourceNew, + canonResourceRep, + LiftLowerContext, + ResourceHandle, + ResourceTableInfo, + ResourceTypeInfo, + Trap, + type ValType, + valTypeEqual, +} from "../src/cabi/mod.ts"; +import { + liftFuture, + liftStream, + lowerFuture, + lowerStream, +} from "../src/cabi/async_values.ts"; +import * as builtin from "../src/intrinsics/stream_builtins.ts"; +import { createTaskReturn } from "../src/intrinsics/async_builtins.ts"; +import { + createTrampoline, + SyncCallScope, + type TrampolineContext, +} from "../src/intrinsics/mod.ts"; +import { + cabiOptions, + LiveMemory, + type ResolvedOptions, +} from "../src/exec/boundary.ts"; +import { + ComponentInstanceState, + CopyEnd, + CopyState, + popCurrentThread, + pushCurrentThread, + Store, + Task, + Thread, +} from "../src/task/mod.ts"; + +function identityTrap(fn: () => unknown, message: string): void { + let error: unknown; + try { + fn(); + } catch (e) { + error = e; + } + assertEq( + error instanceof Trap && error.message.includes(message), + true, + String(error), + ); +} + +function fixture() { + const store = new Store(); + const src = new ComponentInstanceState(0, store); + const dst = new ComponentInstanceState(1, store); + const origin = new ResourceTypeInfo(null); + const tables = [ + new ResourceTableInfo(origin), + new ResourceTableInfo(origin), + new ResourceTableInfo(origin), + new ResourceTableInfo(new ResourceTypeInfo(null)), + ]; + const elems: ValType[] = tables.map((rt) => ({ + kind: "record", + fields: [ + { label: "value", type: { kind: "own", rt } }, + ], + })); + const wasmMemory = new WebAssembly.Memory({ initial: 1 }); + const memory = new LiveMemory(() => wasmMemory, "identity fixture"); + const opts: ResolvedOptions = { + instance: src, + memory, + stringEncoding: "utf8", + realloc: null, + postReturn: null, + callback: null, + async: true, + cancellable: false, + coreType: { params: ["i32"], results: [] }, + }; + const ctx = { + componentInstance: () => src, + options: () => opts, + streamElem: (i: number) => elems[i], + futureElem: (i: number) => elems[i], + streamTableInstance: (i: number) => i === 0 ? src : dst, + futureTableInstance: (i: number) => i === 0 ? src : dst, + }; + return { src, dst, tables, elems, memory, opts, ctx }; +} + +for (const kind of ["stream", "future"] as const) { + const api = kind === "stream" + ? { + new: builtin.createStreamNew, + read: builtin.createStreamRead, + write: builtin.createStreamWrite, + cancelRead: builtin.createStreamCancelRead, + cancelWrite: builtin.createStreamCancelWrite, + dropRead: builtin.createStreamDropReadable, + dropWrite: builtin.createStreamDropWritable, + transfer: builtin.createStreamTransfer, + } + : { + new: builtin.createFutureNew, + read: builtin.createFutureRead, + write: builtin.createFutureWrite, + cancelRead: builtin.createFutureCancelRead, + cancelWrite: builtin.createFutureCancelWrite, + dropRead: builtin.createFutureDropReadable, + dropWrite: builtin.createFutureDropWritable, + transfer: builtin.createFutureTransfer, + }; + const decl = (i: number) => ({ + streamTable: i, + futureTable: i, + options: 0, + async: true, + }); + for ( + const op of [ + "read", + "write", + "cancelRead", + "cancelWrite", + "dropRead", + "dropWrite", + "lift", + "transfer", + ] as const + ) { + Deno.test(`${kind} identity: ${op} rejects a different local payload with the same origin`, () => { + const f = fixture(); + const packed = api.new(decl(0), f.ctx, f.src)() as bigint; + const readable = Number(packed & 0xffff_ffffn); + const writable = Number(packed >> 32n); + const writing = op === "write" || op === "cancelWrite" || + op === "dropWrite"; + const index = writing ? writable : readable; + const end = f.src.handles.get(index) as CopyEnd; + assertEq(end.elem === f.elems[0], true); + if (op === "cancelRead" || op === "cancelWrite") { + end.state = CopyState.COPYING; + } + if (op === "dropWrite" && kind === "future") end.state = CopyState.DONE; + const cx = new LiftLowerContext(cabiOptions(f.opts), f.src); + let error: unknown; + try { + if (op === "lift") { + if (kind === "stream") { + liftStream(cx, index, { kind, element: f.elems[1] }); + } else liftFuture(cx, index, { kind, element: f.elems[1] }); + } else if (op === "transfer") { + // Wrong source descriptor, but the actual source instance is unchanged. + api.transfer({ + ...f.ctx, + streamTableInstance: () => f.src, + futureTableInstance: () => f.src, + })(index, 1, 2); + } else api[op](decl(1), f.ctx, f.src)(index, 0, 1); + } catch (e) { + error = e; + } + assertEq( + error instanceof Error && error.message.includes("element"), + true, + String(error), + ); + assertTrap(() => { + throw error; + }); + }); + } + + for (const boundary of ["lower", "transfer"] as const) { + Deno.test(`${kind} identity: ${boundary} stamps destination and preserves source during resource copy`, () => { + const f = fixture(); + const packed = api.new(decl(0), f.ctx, f.src)() as bigint; + const ri = Number(packed & 0xffff_ffffn); + const wi = Number(packed >> 32n); + const original = f.src.handles.get(ri) as CopyEnd; + const writer = f.src.handles.get(wi) as CopyEnd; + const srcCx = new LiftLowerContext(cabiOptions(f.opts), f.src); + const dstCx = new LiftLowerContext(cabiOptions(f.opts), f.dst); + let received: number; + if (boundary === "transfer") { + received = api.transfer(f.ctx)(ri, 0, 1) as number; + } else if (kind === "stream") { + received = lowerStream( + dstCx, + liftStream(srcCx, ri, { kind, element: f.elems[0] }), + { kind, element: f.elems[1] }, + ); + } else {received = lowerFuture( + dstCx, + liftFuture(srcCx, ri, { kind, element: f.elems[0] }), + { kind, element: f.elems[1] }, + );} + const reader = f.dst.handles.get(received) as CopyEnd; + assertEq(reader.shared === original.shared, true); + assertEq(reader.elem === f.elems[1], true); + assertEq(writer.elem === f.elems[0], true); + assertEq(original.shared.t === f.elems[0], true); + assertTrap(() => f.src.handles.get(ri)); + identityTrap( + () => api.read(decl(2), f.ctx, f.dst)(received, 4, 1), + "element type mismatch", + ); + const handle = canonResourceNew(f.src, f.tables[0], 73); + f.memory.view.setUint32(0, handle, true); + api.write(decl(0), f.ctx, f.src)(wi, 0, 1); + api.read(decl(1), f.ctx, f.dst)(received, 4, 1); + const output = f.memory.view.getUint32(4, true); + assertEq(canonResourceRep(f.dst, f.tables[1], output), 73); + assertTrap(() => canonResourceRep(f.dst, f.tables[2], output)); + assertTrap(() => f.src.handles.get(handle)); + assertEq(reader.shared.t === f.elems[0], true); + }); + } + + Deno.test(`${kind} identity: transfer rejects a different underlying origin`, () => { + const f = fixture(); + const packed = api.new(decl(0), f.ctx, f.src)() as bigint; + identityTrap( + () => api.transfer(f.ctx)(Number(packed & 0xffff_ffffn), 0, 3), + "destination element mismatch", + ); + }); +} + +Deno.test("FACT resource transfer validates local source and tags destination", () => { + const f = fixture(); + const scopes = [new SyncCallScope()]; + const ctx = { + resourceToken: (i: number) => f.tables[i], + resourceTableInstance: (i: number) => i < 2 ? f.src : f.dst, + syncCallStack: scopes, + factStartScopes: [], + trapState: { pending: null }, + } as unknown as TrampolineContext; + for ( + const kind of ["resource-transfer-own", "resource-transfer-borrow"] as const + ) { + const transfer = createTrampoline({ kind } as never, ctx); + const wrong = canonResourceNew(f.src, f.tables[0], 51); + identityTrap(() => transfer(wrong, 1, 2), "resource type mismatch"); + const good = canonResourceNew(f.src, f.tables[0], 52); + const out = transfer(good, 0, 2) as number; + const handle = f.dst.handles.get(out) as ResourceHandle; + assertEq(handle.rt === f.tables[2], true); + assertEq(handle.rep, 52); + } + scopes[0].releaseLenders(); +}); + +for (const fact of [false, true]) { + Deno.test(`task.return identity: nested local result equality (FACT=${fact})`, () => { + for (const matching of [false, true]) { + const f = fixture(); + const result: ValType = { kind: "future", element: f.elems[0] }; + const declared: ValType = { + kind: "future", + element: f.elems[matching ? 0 : 1], + }; + assertEq(valTypeEqual(result, declared), matching); + const packed = builtin.createFutureNew( + { futureTable: 0 }, + f.ctx, + f.src, + )() as bigint; + const ri = Number(packed & 0xffff_ffffn); + const task = new Task( + { params: [], results: [result], async: true }, + { + async_: true, + callback: false, + stringEncoding: "utf8", + memory: f.memory, + }, + f.src, + () => [], + () => {}, + ); + task.factPassthrough = fact; + task.factResultTypesKnown = true; + task.state = "started"; + const call = createTaskReturn({ results: 0, resultType: 0, options: 0 }, { + ...f.ctx, + resultTypes: () => [declared], + }); + const thread = new Thread(task, (function* () {})()); + pushCurrentThread(thread); + try { + if (matching) { + call(ri); + assertEq(task.state, "resolved"); + } else {identityTrap(() => + call(ri), "result type that is not the task's result type");} + } finally { + popCurrentThread(thread); + } + } + }); +} + +Deno.test("linked resource identity: same local type succeeds, distinct child import rejects", async () => { + const translator = await Translator.create( + await Deno.readFile( + new URL( + "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", + import.meta.url, + ), + ), + ); + const componentBytes = await Deno.readFile( + new URL("resource_identity.wasm", import.meta.url), + ); + const { plan, adapters } = translator.translate(componentBytes); + const tables = plan.resourceTables.filter((t) => t.kind === "concrete"); + assertEq( + tables.some((a, i) => + tables.some((b, j) => + i !== j && a.resource === b.resource && a.instance === b.instance + ) + ), + true, + "fixture must retain distinct local tables with equal origin and instance", + ); + const c = await instantiateComponent({ + plan, + adapters, + componentBytes, + jspi: false, + }); + const exports = c.exports as Record unknown>; + exports.same(); + assertEq(exports.count(), 1); + identityTrap(() => exports.different(), "resource type mismatch"); +}); diff --git a/runtime/tests/resource_lender_park_settle_test.ts b/runtime/tests/resource_lender_park_settle_test.ts index 64b7379..4cdf8b4 100644 --- a/runtime/tests/resource_lender_park_settle_test.ts +++ b/runtime/tests/resource_lender_park_settle_test.ts @@ -47,6 +47,7 @@ import { canonResourceDrop, canonResourceNew, ResourceHandle, + ResourceTableInfo, ResourceTypeInfo, } from "../src/cabi/mod.ts"; import type { CoreValue, ValType } from "../src/cabi/types.ts"; @@ -64,7 +65,7 @@ interface Harness { caller: ComponentInstanceState; callee: ComponentInstanceState; handle: ResourceHandle; - rt: ResourceTypeInfo; + rt: ResourceTableInfo; handleIndex: number; /** Run one prepare + start-call in jspi mode; returns what it returned. */ run(kind: "sync" | "async", calleeBody: () => CoreValue): unknown; @@ -76,7 +77,7 @@ function mkHarness(): Harness { const store = new Store(); const caller = new ComponentInstanceState(0, store); const callee = new ComponentInstanceState(1, store); - const rt = new ResourceTypeInfo(caller, () => {}); + const rt = new ResourceTableInfo(new ResourceTypeInfo(caller, () => {})); const handleIndex = canonResourceNew(caller, rt, 77); const handle = caller.handles.get(handleIndex) as ResourceHandle; diff --git a/runtime/tests/resource_lender_unwind_test.ts b/runtime/tests/resource_lender_unwind_test.ts index d2410f7..471714b 100644 --- a/runtime/tests/resource_lender_unwind_test.ts +++ b/runtime/tests/resource_lender_unwind_test.ts @@ -25,6 +25,7 @@ import { canonResourceDrop, canonResourceNew, ResourceHandle, + ResourceTableInfo, ResourceTypeInfo, } from "../src/cabi/mod.ts"; import type { CoreValue, ValType } from "../src/cabi/types.ts"; @@ -40,7 +41,7 @@ interface Harness { caller: ComponentInstanceState; callee: ComponentInstanceState; handle: ResourceHandle; - rt: ResourceTypeInfo; + rt: ResourceTableInfo; handleIndex: number; /** Run one prepare + start-call; returns whatever escaped, or null. */ run(kind: "sync" | "async", calleeBody: () => CoreValue): unknown; @@ -52,7 +53,7 @@ function mkHarness(postReturn: (() => void) | null = null): Harness { const callee = new ComponentInstanceState(1, store); // The resource is implemented by the CALLER, so dropping it later is the // same-instance (ungated) path — this test is about `num_lends`, not #85. - const rt = new ResourceTypeInfo(caller, () => {}); + const rt = new ResourceTableInfo(new ResourceTypeInfo(caller, () => {})); const handleIndex = canonResourceNew(caller, rt, 77); const handle = caller.handles.get(handleIndex) as ResourceHandle; diff --git a/runtime/tests/resource_lifetime_test.ts b/runtime/tests/resource_lifetime_test.ts index cc893bc..6c3e58f 100644 --- a/runtime/tests/resource_lifetime_test.ts +++ b/runtime/tests/resource_lifetime_test.ts @@ -10,6 +10,7 @@ import { canonResourceDrop, canonResourceNew, + ResourceTableInfo, ResourceTypeInfo, } from "../src/cabi/mod.ts"; import { @@ -97,9 +98,11 @@ Deno.test("#85/#173: dropping a cross-instance own while the impl is LIVE succee // mid-execution is valid and the dtor simply runs. const { caller, impl } = mkPair(); let ran = 0; - const rt = new ResourceTypeInfo(impl, () => { - ran += 1; - }); + const rt = new ResourceTableInfo( + new ResourceTypeInfo(impl, () => { + ran += 1; + }), + ); const h = canonResourceNew(caller, rt, 42); void impl; @@ -109,7 +112,7 @@ Deno.test("#85/#173: dropping a cross-instance own while the impl is LIVE succee Deno.test("#85/#173: a dtor-less drop into a live impl succeeds too", () => { const { caller, impl } = mkPair(); - const rt = new ResourceTypeInfo(impl, null); + const rt = new ResourceTableInfo(new ResourceTypeInfo(impl, null)); const h = canonResourceNew(caller, rt, 7); void impl; canonResourceDrop(caller, rt, h); @@ -119,7 +122,7 @@ Deno.test("#85: a POISONED impl still refuses the drop", () => { // The surviving refusal: polyengine's per-instance corpse divergence. withPoisonSpy(() => { const { caller, impl } = mkPair(); - const rt = new ResourceTypeInfo(impl, () => {}); + const rt = new ResourceTableInfo(new ResourceTypeInfo(impl, () => {})); const h = canonResourceNew(caller, rt, 43); notifyInstancePoisoned(impl, new Error("earlier boom")); assertTrap( @@ -135,9 +138,11 @@ Deno.test("#85: a same-instance drop is admissible even against its own marker", withPoisonSpy(() => { const { caller } = mkPair(); let ran = 0; - const rt = new ResourceTypeInfo(caller, () => { - ran += 1; - }); + const rt = new ResourceTableInfo( + new ResourceTypeInfo(caller, () => { + ran += 1; + }), + ); const h = canonResourceNew(caller, rt, 5); notifyInstancePoisoned(caller, new Error("earlier boom")); canonResourceDrop(caller, rt, h); @@ -149,9 +154,11 @@ Deno.test("#85: a trapping dtor poisons the impl instance and retires its ends", withPoisonSpy((seen) => { const { caller, impl } = mkPair(); const boom = new Error("dtor trap"); - const rt = new ResourceTypeInfo(impl, () => { - throw boom; - }); + const rt = new ResourceTableInfo( + new ResourceTypeInfo(impl, () => { + throw boom; + }), + ); const h = canonResourceNew(caller, rt, 3); let caught: unknown; try { @@ -172,9 +179,11 @@ Deno.test("#85: a trapping dtor poisons the impl instance and retires its ends", Deno.test("#85: a guest-initiated dtor that does not finish synchronously traps", () => { withPoisonSpy((seen) => { const { caller, impl } = mkPair(); - const rt = new ResourceTypeInfo( - impl, - (() => Promise.resolve()) as unknown as (rep: number) => void, + const rt = new ResourceTableInfo( + new ResourceTypeInfo( + impl, + (() => Promise.resolve()) as unknown as (rep: number) => void, + ), ); const h = canonResourceNew(caller, rt, 9); assertTrap( diff --git a/runtime/tests/support/typedsl.ts b/runtime/tests/support/typedsl.ts index ef1319b..1f52012 100644 --- a/runtime/tests/support/typedsl.ts +++ b/runtime/tests/support/typedsl.ts @@ -2,8 +2,8 @@ // Keep in lockstep with generate.py::build_type. import { - type ResourceTypeInfo, - ResourceTypeInfo as ResourceTypeInfoClass, + ResourceTableInfo, + ResourceTypeInfo, type ValType, } from "../../src/cabi/mod.ts"; @@ -25,8 +25,8 @@ const PRIMS = new Set([ // Shared dummy resource type: fixtures only exercise layout/flatten of // own/borrow, which ignore the resource identity. -export const dummyResourceType: ResourceTypeInfo = new ResourceTypeInfoClass( - null, +export const dummyResourceType = new ResourceTableInfo( + new ResourceTypeInfo(null), ); // deno-lint-ignore no-explicit-any diff --git a/runtime/tests/tls_smoke_pins_test.ts b/runtime/tests/tls_smoke_pins_test.ts index d213b13..be8739d 100644 --- a/runtime/tests/tls_smoke_pins_test.ts +++ b/runtime/tests/tls_smoke_pins_test.ts @@ -10,9 +10,9 @@ // evaluated diagnostic strings. // Pin 2 — one `ResourceTypeInfo` per component-wide ResourceIndex, aliased // across resource tables (plan-format.md "Type exports index into -// `resourceTables`" note). Per- -// table tokens made FACT stream/future transfers trap "destination -// element mismatch" in wac-composed components. +// `resourceTables`" note). Local table tokens remain distinct; treating +// them as origin identity made FACT stream/future transfers trap +// "destination element mismatch" in wac-composed components. // Pin 3 — `resource.transfer-borrow` inside a FACT `[async-start]` window // (prepare/start protocol, no enter/exit-sync-call bracket): // borrow bookkeeping attaches to the callee task + caller lender @@ -20,6 +20,7 @@ import { fmtValType, + ResourceTableInfo, ResourceTypeInfo, Table, type ValType, @@ -68,10 +69,10 @@ function minimalPlan(overrides: Partial = {}): WirePlan { /** A resource type whose identity token cycles back to a table that holds a * value referencing the type — the real shape: `rt.impl.handles` holds ends * whose `.shared.t` contains the own type. `JSON.stringify` throws on it. */ -function cyclicResourceType(): { rt: ResourceTypeInfo; t: ValType } { +function cyclicResourceType(): { rt: ResourceTableInfo; t: ValType } { const handles = new Table(); const impl = { handles, mayLeave: true }; - const rt = new ResourceTypeInfo(impl, null); + const rt = new ResourceTableInfo(new ResourceTypeInfo(impl, null)); const t: ValType = { kind: "future", element: { kind: "result", ok: null, error: { kind: "own", rt } }, @@ -98,7 +99,26 @@ Deno.test("pin: sameElemType survives resource-bearing (cyclic) element types", assertEq(sameElemType(t, same), true, "same rt -> equal"); assertEq(valTypeEqual(t, same), true); - const otherRt = new ResourceTypeInfo(null, null); + const alias: ValType = { + kind: "future", + element: { + kind: "result", + ok: null, + error: { kind: "own", rt: new ResourceTableInfo(rt.resource) }, + }, + }; + assertEq( + sameElemType(t, alias), + true, + "shared origin survives component hops", + ); + assertEq( + valTypeEqual(t, alias), + false, + "local abstract types remain distinct", + ); + + const otherRt = new ResourceTableInfo(new ResourceTypeInfo(null, null)); const different: ValType = { kind: "future", element: { kind: "result", ok: null, error: { kind: "own", rt: otherRt } }, @@ -118,24 +138,37 @@ Deno.test("pin: fmtValType is cycle-safe and structural", () => { // --- Pin 2 ------------------------------------------------------------------ -Deno.test("pin: tables naming one ResourceIndex share one identity token", () => { +Deno.test("pin: tables naming one ResourceIndex share origin, not local identity", () => { const loaded = loadPlan(minimalPlan({ resourceTables: [ { kind: "concrete", resource: 0, instance: 0 }, - { kind: "concrete", resource: 0, instance: 1 }, // alias (composed peer) + { kind: "concrete", resource: 0, instance: 1 }, // composed peer { kind: "concrete", resource: 1, instance: 0 }, + { kind: "concrete", resource: 0, instance: 0 }, // distinct abstract import ], })); assertEq( - loaded.resourceTokens[0] === loaded.resourceTokens[1], + loaded.resourceTokens[0].resource === loaded.resourceTokens[1].resource, true, - "same ResourceIndex through two tables -> one token", + "same ResourceIndex through two tables -> one origin", ); assertEq( - loaded.resourceTokens[0] === loaded.resourceTokens[2], + loaded.resourceTokens[0].resource === loaded.resourceTokens[2].resource, false, "distinct resources stay distinct", ); + assertEq(loaded.resourceTokens[0] === loaded.resourceTokens[1], false); + assertEq(loaded.resourceTokens[0] === loaded.resourceTokens[3], false); + assertEq( + loaded.resourceTokens[0].resource === loaded.resourceTokens[3].resource, + true, + ); + const again = loadPlan(loaded.wire); + assertEq(loaded.resourceTokens[0] === again.resourceTokens[0], false); + assertEq( + loaded.resourceTokens[0].resource === again.resourceTokens[0].resource, + false, + ); }); // --- Pin 3 ------------------------------------------------------------------ @@ -143,8 +176,8 @@ Deno.test("pin: tables naming one ResourceIndex share one identity token", () => Deno.test("pin: transfer-borrow works inside a FACT [async-start] window", () => { const srcInst = { handles: new Table(), mayLeave: true }; const dstInst = { handles: new Table(), mayLeave: true }; - const srcRt = new ResourceTypeInfo(null, null); - const dstRt = new ResourceTypeInfo(null, null); // dst does NOT implement it + const srcRt = new ResourceTableInfo(new ResourceTypeInfo(null, null)); + const dstRt = new ResourceTableInfo(srcRt.resource); // dst does NOT implement it const factStartScopes: FactStartScope[] = []; const ctx = {