From 420a9382b393d44ecde9669f66ca699529a6e566 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 22:16:11 +0000 Subject: [PATCH 1/4] fix(runtime): preserve provider generation context Capture provider execution contexts on proxy attachment and key automatic cleanup by explicit owner generations. Expose provider selection metadata and reject incompatible shared runtimes. Amp-Thread-ID: https://ampcode.com/threads/T-01a01742-a6f0-7092-a10b-01e8e6633a0d Co-authored-by: Upd4ting --- package.json | 3 +- src/index.ts | 8 +- src/internal.ts | 68 ++++++-- src/modules.ts | 28 ++-- src/proxies.ts | 76 +++++++-- src/tests/generation-cleanup.test.ts | 114 ++++++++++++++ src/tests/interface-connections.test.ts | 47 ++++++ src/tests/provider-context.test.ts | 200 ++++++++++++++++++++++++ src/tests/runtime-protocol.test.ts | 41 +++-- test/package-consumer.mjs | 141 +++++++++++++++++ 10 files changed, 677 insertions(+), 49 deletions(-) create mode 100644 src/tests/generation-cleanup.test.ts create mode 100644 src/tests/interface-connections.test.ts create mode 100644 src/tests/provider-context.test.ts create mode 100644 test/package-consumer.mjs diff --git a/package.json b/package.json index 1729487..695d293 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "lint": "biome check .", "prepack": "pnpm run build", "release": "pnpm run lint && pnpm run prepack && release-it", - "test": "pnpm run build && ajs module test ." + "test": "pnpm run build && ajs module test .", + "test:package": "node test/package-consumer.mjs" }, "antelopeJs": { "test": "src/antelope.test.ts", diff --git a/src/index.ts b/src/index.ts index a4f5b2a..b0dc34a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; import type { Class } from "./decorators"; -import { internal } from "./internal"; +import { type InterfaceConnection, internal } from "./internal"; import { Logging } from "./logging"; import { AsyncProxy, @@ -11,6 +11,7 @@ import { } from "./proxies"; export * from "./errors"; +export type { InterfaceConnection } from "./internal"; export { AsyncProxy, EventProxy, @@ -291,11 +292,6 @@ export function ImplementInterface< return { declaration: decl, implementation: impl as T2 }; } -interface InterfaceConnection { - id?: string; - path: string; -} - /** * Gets all instances of a specific interface across the system. * diff --git a/src/internal.ts b/src/internal.ts index 8c6a470..ff6e6a7 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,21 +1,35 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { ModuleContextInvalidatedError } from "./errors"; -export const RUNTIME_PROTOCOL_VERSION = 2; +export const RUNTIME_PROTOCOL_VERSION = 3; export const RUNTIME_SYMBOL = Symbol.for("@antelopejs/interface-core/runtime"); +/** Provider connection metadata visible to an interface consumer. */ export interface InterfaceConnection { + /** Optional connection alias. */ id?: string; + /** Resolved interface package path. */ path: string; + /** Module ID of the provider represented by this connection. */ + provider: string; + /** Whether this provider is selected for unqualified interface calls. */ + selected: boolean; } +/** Module identity and provider routes propagated through asynchronous work. */ export interface ModuleExecutionContext { + /** Stable module ID used by existing lifecycle and attribution APIs. */ module: string; + /** Unique lifecycle generation ID. Defaults to the module ID when omitted. */ + owner?: string; + /** Provider ID used when attaching implementations. */ provider?: string; + /** Provider selections keyed by stable interface proxy identity. */ providerRoutes?: Readonly>; } interface ActiveModuleExecutionContext extends ModuleExecutionContext { + owner: string; ownershipToken: symbol; } @@ -52,11 +66,17 @@ export interface InterfaceRuntime { testStubMode: boolean; knownAsync: Map>; knownRegisters: Map>; - registeringProxies: Set<{ unregisterModule(module: string): void }>; - knownEvents: Set<{ unregisterModule(module: string): void }>; + registeringProxies: Set<{ + unregisterModule(module: string): void; + unregisterOwner(owner: string): void; + }>; + knownEvents: Set<{ + unregisterModule(module: string): void; + unregisterOwner(owner: string): void; + }>; interfaceConnections: Record>; executionContext: AsyncLocalStorage; - activeModuleTokens: Map; + activeOwnerTokens: Map; proxyStates: Map; nextProxyIdentity: number; nextLeaseGeneration: number; @@ -94,7 +114,7 @@ function createRuntime(): InterfaceRuntime { Record >, executionContext: new AsyncLocalStorage(), - activeModuleTokens: new Map(), + activeOwnerTokens: new Map(), proxyStates: new Map(), nextProxyIdentity: 1, nextLeaseGeneration: 1, @@ -133,19 +153,19 @@ function getRuntime(): InterfaceRuntime { /** @internal */ export const internal = getRuntime(); -function getModuleToken(module: string): symbol { - const activeToken = internal.activeModuleTokens.get(module); +function getOwnerToken(owner: string): symbol { + const activeToken = internal.activeOwnerTokens.get(owner); if (activeToken) { return activeToken; } - const token = Symbol(module); - internal.activeModuleTokens.set(module, token); + const token = Symbol(owner); + internal.activeOwnerTokens.set(owner, token); return token; } function assertActiveModuleContext(context: ActiveModuleExecutionContext) { if ( - internal.activeModuleTokens.get(context.module) !== context.ownershipToken + internal.activeOwnerTokens.get(context.owner) !== context.ownershipToken ) { throw new ModuleContextInvalidatedError(context.module); } @@ -162,14 +182,18 @@ export function runWithModuleContext( if (!context.module) { throw new Error("Module execution context requires a module ID."); } + const owner = context.owner ?? context.module; const activeContext = { ...context, - ownershipToken: getModuleToken(context.module), + owner, + ownershipToken: getOwnerToken(owner), }; return internal.executionContext.run(activeContext, callback); } -export function getModuleContext(): ModuleExecutionContext | undefined { +export function captureModuleContext(): + | ActiveModuleExecutionContext + | undefined { const context = internal.executionContext.getStore(); if (context) { assertActiveModuleContext(context); @@ -177,6 +201,22 @@ export function getModuleContext(): ModuleExecutionContext | undefined { return context; } -export function invalidateModuleContext(module: string) { - internal.activeModuleTokens.delete(module); +export function runWithCapturedModuleContext( + context: ActiveModuleExecutionContext, + callback: () => T, +): T { + assertActiveModuleContext(context); + return internal.executionContext.run(context, callback); +} + +export function peekModuleContext(): ModuleExecutionContext | undefined { + return internal.executionContext.getStore(); +} + +export function getModuleContext(): ModuleExecutionContext | undefined { + return captureModuleContext(); +} + +export function invalidateModuleContext(owner: string) { + internal.activeOwnerTokens.delete(owner); } diff --git a/src/modules.ts b/src/modules.ts index 240ff40..a708a05 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -4,6 +4,7 @@ import { internal, invalidateModuleContext, type ModuleExecutionContext, + peekModuleContext, type RuntimeCleanup, runWithModuleContext, } from "./internal"; @@ -95,19 +96,28 @@ function runCleanup( } } +function getDestroyedOwner(module: string): string { + const context = peekModuleContext(); + if (context?.module !== module) { + return module; + } + return context.owner ?? module; +} + Events.ModuleDestroyed.register((module) => { - invalidateModuleContext(module); - for (const cleanup of internal.knownAsync.get(module) ?? []) { - runCleanup(cleanup, module, "detach-async-provider"); + const owner = getDestroyedOwner(module); + invalidateModuleContext(owner); + for (const cleanup of internal.knownAsync.get(owner) ?? []) { + runCleanup(cleanup, owner, "detach-async-provider"); } - internal.knownAsync.delete(module); - for (const cleanup of internal.knownRegisters.get(module) ?? []) { - runCleanup(cleanup, module, "detach-registering-provider"); + internal.knownAsync.delete(owner); + for (const cleanup of internal.knownRegisters.get(owner) ?? []) { + runCleanup(cleanup, owner, "detach-registering-provider"); } - internal.knownRegisters.delete(module); + internal.knownRegisters.delete(owner); for (const proxy of internal.registeringProxies) { try { - proxy.unregisterModule(module); + proxy.unregisterOwner(owner); } catch (error) { internal.runtimeErrorReporter?.(error, { operation: "unregister-module", @@ -117,7 +127,7 @@ Events.ModuleDestroyed.register((module) => { } for (const proxy of internal.knownEvents) { try { - proxy.unregisterModule(module); + proxy.unregisterOwner(owner); } catch (error) { internal.runtimeErrorReporter?.(error, { operation: "unregister-event-module", diff --git a/src/proxies.ts b/src/proxies.ts index 4d18253..ea53f4d 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -4,11 +4,13 @@ import { ProviderQueueFullError, } from "./errors"; import { + captureModuleContext, getModuleContext, internal, invalidateModuleContext, type ProxyBrand, RUNTIME_PROTOCOL_VERSION, + runWithCapturedModuleContext, runWithModuleContext, } from "./internal"; import { findResponsibleFile } from "./responsible-module"; @@ -64,6 +66,7 @@ interface RegisterCallbacks { interface RegisteredEntry { args: RArgs; module?: string; + owner?: string; provider?: string; } @@ -74,6 +77,7 @@ interface RegisteringProxyState { interface EventEntry { module?: string; + owner?: string; func: T; } @@ -138,7 +142,8 @@ function getAttachmentRoute(manualDetach?: boolean) { const context = getModuleContext(); const responsible = manualDetach || context?.module ? undefined : GetResponsibleModule(); - const owner = context?.module ?? responsible ?? DEFAULT_PROVIDER; + const owner = + context?.owner ?? context?.module ?? responsible ?? DEFAULT_PROVIDER; return { owner, provider: context?.provider ?? owner }; } @@ -147,6 +152,29 @@ function getRequestedProvider(proxyIdentity: string) { return context?.providerRoutes?.[proxyIdentity] ?? context?.provider; } +function bindProviderCallback(callback: T): T { + const context = captureModuleContext(); + if (!context) { + return callback; + } + return ((...args: Parameters) => + runWithCapturedModuleContext(context, () => callback(...args))) as T; +} + +interface ExecutionOwnership { + module?: string; + owner?: string; +} + +function getExecutionOwnership(): ExecutionOwnership { + const context = getModuleContext(); + if (context) { + return { module: context.module, owner: context.owner ?? context.module }; + } + const module = GetResponsibleModule(); + return { module, owner: module }; +} + function selectProvider( callbacks: Map, proxyIdentity: string, @@ -212,13 +240,17 @@ export class AsyncProxy>> { public onCall(callback: T, manualDetach?: boolean): AttachmentLease { const route = getAttachmentRoute(manualDetach); const lease = { ...route, generation: internal.nextLeaseGeneration++ }; - this.state.callbacks.set(route.provider, { callback, ...lease }); + const providerCallback = bindProviderCallback(callback); + this.state.callbacks.set(route.provider, { + callback: providerCallback, + ...lease, + }); if (!manualDetach) { internal.addAsyncProxy(route.owner, { cleanup: () => this.detach(lease), }); } - this.replayQueue(route.provider, callback); + this.replayQueue(route.provider, providerCallback); return lease; } @@ -310,7 +342,7 @@ export class RegisteringProxy { const route = getAttachmentRoute(manualDetach); const current = this.state.callbacks.get(route.provider); return this.attachHandlers( - callback, + bindProviderCallback(callback), current?.unregister, manualDetach, true, @@ -332,7 +364,7 @@ export class RegisteringProxy { : getAttachmentRoute(); return this.attachHandlers( current?.register, - callback, + bindProviderCallback(callback), current?.manualDetach, false, route, @@ -345,7 +377,11 @@ export class RegisteringProxy { unregister: (id: RID) => void, manualDetach?: boolean, ): AttachmentLease { - return this.attachHandlers(register, unregister, manualDetach); + return this.attachHandlers( + bindProviderCallback(register), + bindProviderCallback(unregister), + manualDetach, + ); } /** Detaches one leased provider, or every provider when called without a lease. */ @@ -384,8 +420,9 @@ export class RegisteringProxy { internal.maxPendingOperations, ); } + const ownership = getExecutionOwnership(); this.state.registered.set(id, { - module: GetResponsibleModule(), + ...ownership, provider: requested ?? callback?.provider, args, }); @@ -412,8 +449,20 @@ export class RegisteringProxy { /** Unregisters every entry owned by a destroyed module. */ public unregisterModule(module: string) { + this.unregisterMatching((entry) => entry.module === module, module); + } + + /** Unregisters every entry owned by a destroyed module generation. */ + public unregisterOwner(owner: string) { + this.unregisterMatching((entry) => entry.owner === owner, owner); + } + + private unregisterMatching( + matches: (entry: RegisteredEntry) => boolean, + owner: string, + ) { for (const [id, entry] of this.state.registered) { - if (entry.module !== module) { + if (!matches(entry)) { continue; } try { @@ -423,7 +472,7 @@ export class RegisteringProxy { error, "unregister", this[PROXY_BRAND].identity, - module, + owner, id, ); } finally { @@ -515,7 +564,7 @@ export class EventProxy { if (this.state.registered.some((existing) => existing.func === func)) { return; } - this.state.registered.push({ module: GetResponsibleModule(), func }); + this.state.registered.push({ ...getExecutionOwnership(), func }); } /** Unregisters a handler. */ @@ -531,6 +580,13 @@ export class EventProxy { (entry) => entry.module !== module, ); } + + /** Unregisters handlers owned by a destroyed module generation. */ + public unregisterOwner(owner: string) { + this.state.registered = this.state.registered.filter( + (entry) => entry.owner !== owner, + ); + } } function captureCallStack(startFrame = 0): NodeJS.CallSite[] { diff --git a/src/tests/generation-cleanup.test.ts b/src/tests/generation-cleanup.test.ts new file mode 100644 index 0000000..9b3cd1f --- /dev/null +++ b/src/tests/generation-cleanup.test.ts @@ -0,0 +1,114 @@ +import { expect } from "chai"; +import { + AsyncProxy, + EventProxy, + GetInterfaceProxyIdentity, + RegisteringProxy, +} from ".."; +import { internal } from "../internal"; +import { Events, RunWithModuleContext } from "../modules"; + +describe("generation-owned cleanup", () => { + afterEach(() => { + internal.testStubMode = false; + }); + + it("keeps a same-module and provider replacement after stale cleanup", async () => { + const proxy = new AsyncProxy<() => string>("generation.async"); + const identity = GetInterfaceProxyIdentity(proxy) as string; + let oldLease: ReturnType | undefined; + RunWithModuleContext( + { module: "shared", owner: "shared#old", provider: "shared" }, + () => { + oldLease = proxy.onCall(() => "old"); + }, + ); + RunWithModuleContext( + { module: "shared", owner: "shared#new", provider: "shared" }, + () => proxy.onCall(() => "new"), + ); + + proxy.detach(oldLease); + RunWithModuleContext( + { module: "shared", owner: "shared#old", provider: "shared" }, + () => { + Events.ModuleDestroyed.emit("shared"); + Events.ModuleDestroyed.emit("shared"); + }, + ); + + expect(internal.knownAsync.has("shared#old")).to.equal(false); + expect(internal.knownAsync.has("shared#new")).to.equal(true); + expect( + await RunWithModuleContext( + { + module: "consumer", + owner: "consumer#1", + providerRoutes: { [identity]: "shared" }, + }, + () => proxy.call(), + ), + ).to.equal("new"); + }); + + it("keeps registering replacements after old automatic cleanup", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "generation.registering", + ); + const calls: string[] = []; + RunWithModuleContext( + { module: "shared", owner: "shared-register#old", provider: "shared" }, + () => + proxy.onHandlers( + (id) => calls.push(`old:${id}`), + () => undefined, + ), + ); + RunWithModuleContext( + { module: "shared", owner: "shared-register#new", provider: "shared" }, + () => + proxy.onHandlers( + (id) => calls.push(`new:${id}`), + () => undefined, + ), + ); + RunWithModuleContext( + { module: "shared", owner: "shared-register#old", provider: "shared" }, + () => Events.ModuleDestroyed.emit("shared"), + ); + + expect(internal.knownRegisters.has("shared-register#old")).to.equal(false); + expect(internal.knownRegisters.has("shared-register#new")).to.equal(true); + proxy.register("item"); + + expect(calls).to.deep.equal(["new:item"]); + }); + + it("cleans only registrations and events from the destroyed owner", () => { + const registrations = new RegisteringProxy<(id: string) => void>( + "generation.consumer-registering", + ); + const event = new EventProxy<() => void>("generation.consumer-event"); + const calls: string[] = []; + registrations.onHandlers( + () => undefined, + (id) => calls.push(`remove:${id}`), + true, + ); + RunWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => { + registrations.register("old"); + event.register(() => calls.push("old-event")); + }); + RunWithModuleContext({ module: "consumer", owner: "consumer#new" }, () => { + registrations.register("new"); + event.register(() => calls.push("new-event")); + }); + RunWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => { + Events.ModuleDestroyed.emit("consumer"); + }); + + event.emit(); + + expect(calls).to.deep.equal(["remove:old", "new-event"]); + }); +}); diff --git a/src/tests/interface-connections.test.ts b/src/tests/interface-connections.test.ts new file mode 100644 index 0000000..b5856a0 --- /dev/null +++ b/src/tests/interface-connections.test.ts @@ -0,0 +1,47 @@ +import { expect } from "chai"; +import { + GetInterfaceInstance, + GetInterfaceInstances, + type InterfaceConnection, +} from ".."; +import { internal } from "../internal"; +import { RunWithModuleContext } from "../modules"; + +describe("interface connection metadata", () => { + afterEach(() => { + delete internal.interfaceConnections.consumer; + }); + + it("publishes provider and selection metadata", () => { + const connections: InterfaceConnection[] = [ + { + id: "primary", + path: "@antelopejs/interface-example", + provider: "provider-a", + selected: true, + }, + { + path: "@antelopejs/interface-example", + provider: "provider-b", + selected: false, + }, + ]; + internal.interfaceConnections.consumer = { + "@antelopejs/interface-example": connections, + }; + + const result = RunWithModuleContext( + { module: "consumer", owner: "consumer#metadata" }, + () => ({ + all: GetInterfaceInstances("@antelopejs/interface-example"), + selected: GetInterfaceInstance( + "@antelopejs/interface-example", + "primary", + ), + }), + ); + + expect(result.all).to.deep.equal(connections); + expect(result.selected).to.deep.equal(connections[0]); + }); +}); diff --git a/src/tests/provider-context.test.ts b/src/tests/provider-context.test.ts new file mode 100644 index 0000000..da21c54 --- /dev/null +++ b/src/tests/provider-context.test.ts @@ -0,0 +1,200 @@ +import { expect } from "chai"; +import { + AsyncProxy, + GetInterfaceProxyIdentity, + ModuleContextInvalidatedError, + RegisteringProxy, +} from ".."; +import { Events, GetModuleContext, RunWithModuleContext } from "../modules"; + +interface ContextObservation { + module?: string; + owner?: string; + provider?: string; +} + +function observeContext(): ContextObservation { + const context = GetModuleContext(); + return { + module: context?.module, + owner: context?.owner, + provider: context?.provider, + }; +} + +describe("provider callback context", () => { + it("restores async provider context across awaits and nested calls", async () => { + const nested = new AsyncProxy<() => string>("context.nested"); + const outer = new AsyncProxy<() => Promise>( + "context.outer", + ); + const nestedIdentity = GetInterfaceProxyIdentity(nested) as string; + const outerIdentity = GetInterfaceProxyIdentity(outer) as string; + RunWithModuleContext( + { module: "nested-owner", owner: "nested#1", provider: "nested" }, + () => nested.onCall(() => `${observeContext().owner}:value`), + ); + RunWithModuleContext( + { + module: "provider-owner", + owner: "provider-owner#1", + provider: "provider-a", + providerRoutes: { [nestedIdentity]: "nested" }, + }, + () => + outer.onCall(async () => { + const beforeAwait = observeContext(); + await Promise.resolve(); + const afterAwait = observeContext(); + expect(await nested.call()).to.equal("nested#1:value"); + return [beforeAwait, afterAwait, observeContext()]; + }), + ); + + const observations = await RunWithModuleContext( + { + module: "consumer", + owner: "consumer#1", + providerRoutes: { [outerIdentity]: "provider-a" }, + }, + () => outer.call(), + ); + + expect(observations).to.deep.equal( + Array.from({ length: 3 }, () => ({ + module: "provider-owner", + owner: "provider-owner#1", + provider: "provider-a", + })), + ); + }); + + it("restores registering context for replay, register and unregister", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "context.registering", + ); + const identity = GetInterfaceProxyIdentity(proxy) as string; + const observations: Array<{ + operation: string; + context: ContextObservation; + }> = []; + RunWithModuleContext({ module: "consumer", owner: "consumer#1" }, () => { + proxy.register("queued"); + }); + RunWithModuleContext( + { module: "provider-owner", owner: "provider#1", provider: "provider" }, + () => { + proxy.onHandlers( + (id) => + observations.push({ + operation: `register:${id}`, + context: observeContext(), + }), + (id) => + observations.push({ + operation: `unregister:${id}`, + context: observeContext(), + }), + ); + }, + ); + RunWithModuleContext( + { + module: "consumer", + owner: "consumer#1", + providerRoutes: { [identity]: "provider" }, + }, + () => { + proxy.register("direct"); + proxy.unregister("queued"); + proxy.unregister("direct"); + }, + ); + + expect(observations.map(({ operation }) => operation)).to.deep.equal([ + "register:queued", + "register:direct", + "unregister:queued", + "unregister:direct", + ]); + expect(observations.map(({ context }) => context)).to.deep.equal( + Array.from({ length: 4 }, () => ({ + module: "provider-owner", + owner: "provider#1", + provider: "provider", + })), + ); + }); + + it("preserves synchronous registering callback throws", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "context.registering-throw", + ); + const failure = new Error("register failed"); + RunWithModuleContext( + { module: "provider", owner: "provider-throw#1", provider: "provider" }, + () => + proxy.onHandlers( + () => { + expect(observeContext().owner).to.equal("provider-throw#1"); + throw failure; + }, + () => undefined, + ), + ); + + expect(() => proxy.register("item")).to.throw(failure); + }); + + it("rejects callbacks captured from an invalidated owner", async () => { + const proxy = new AsyncProxy<() => string>("context.invalidated"); + const identity = GetInterfaceProxyIdentity(proxy) as string; + RunWithModuleContext( + { module: "provider", owner: "provider-old#1", provider: "provider" }, + () => { + proxy.onCall(() => "stale", true); + Events.ModuleDestroyed.emit("provider"); + }, + ); + + const error = await RunWithModuleContext( + { + module: "consumer", + owner: "consumer#1", + providerRoutes: { [identity]: "provider" }, + }, + () => + proxy.call().then( + () => undefined, + (reason: unknown) => reason, + ), + ); + + expect(error).to.be.instanceOf(ModuleContextInvalidatedError); + }); + + it("rejects registering handlers captured from an invalidated owner", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "context.invalidated-registering", + ); + RunWithModuleContext( + { + module: "provider", + owner: "provider-register#old", + provider: "provider", + }, + () => { + proxy.onHandlers( + () => undefined, + () => undefined, + true, + ); + Events.ModuleDestroyed.emit("provider"); + }, + ); + + expect(() => proxy.register("item")).to.throw( + ModuleContextInvalidatedError, + ); + }); +}); diff --git a/src/tests/runtime-protocol.test.ts b/src/tests/runtime-protocol.test.ts index d9fc543..bb73966 100644 --- a/src/tests/runtime-protocol.test.ts +++ b/src/tests/runtime-protocol.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import { cpSync, mkdtempSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { expect } from "chai"; -import { AsyncProxy, ImplementInterface, RunWithResponsibleModule } from ".."; +import { AsyncProxy, RunWithResponsibleModule } from ".."; +import { RunWithModuleContext } from "../modules"; interface ForeignCore { AsyncProxy: new ( @@ -17,6 +18,14 @@ interface ForeignCore { GetResponsibleModule(): string | undefined; } +interface ForeignContext { + owner?: string; +} + +interface ForeignModules { + GetModuleContext(): ForeignContext | undefined; +} + describe("global runtime protocol", () => { let copyPath: string | undefined; @@ -32,6 +41,9 @@ describe("global runtime protocol", () => { copyPath = join(temporary, "dist"); cpSync(join(__dirname, ".."), copyPath, { recursive: true }); const foreign = require(join(copyPath, "index.js")) as ForeignCore; + const foreignModules = require( + join(copyPath, "modules.js"), + ) as ForeignModules; const localProxy = new AsyncProxy<() => string>("test.cross-copy"); const foreignProxy = new foreign.AsyncProxy("test.cross-copy"); @@ -41,16 +53,27 @@ describe("global runtime protocol", () => { foreign.GetResponsibleModule(), ), ).to.equal("shared-owner"); - foreign.ImplementInterface( - { proxy: localProxy }, - { proxy: () => "shared" }, + RunWithModuleContext( + { module: "provider", owner: "provider#copy", provider: "provider" }, + () => + foreign.ImplementInterface( + { proxy: localProxy }, + { + proxy: () => foreignModules.GetModuleContext()?.owner ?? "missing", + }, + ), ); - expect(await foreignProxy.call()).to.equal("shared"); - ImplementInterface({ proxy: foreignProxy }, { - proxy: () => "local", - } as never); - expect(await localProxy.call()).to.equal("local"); + expect( + await RunWithModuleContext( + { + module: "consumer", + owner: "consumer#copy", + providerRoutes: { "async:test.cross-copy": "provider" }, + }, + () => foreignProxy.call(), + ), + ).to.equal("provider#copy"); }); it("fails clearly when a realm already contains an incompatible protocol", () => { diff --git a/test/package-consumer.mjs b/test/package-consumer.mjs new file mode 100644 index 0000000..f7668dc --- /dev/null +++ b/test/package-consumer.mjs @@ -0,0 +1,141 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = dirname(dirname(fileURLToPath(import.meta.url))); +const temporary = mkdtempSync(join(tmpdir(), "interface-core-consumer-")); + +function run(command, args, cwd, env = process.env) { + execFileSync(command, args, { cwd, env, stdio: "inherit" }); +} + +function createConsumer(tarball) { + writeFileSync( + join(temporary, "package.json"), + JSON.stringify({ + name: "interface-core-package-consumer", + packageManager: "pnpm@10.6.5", + private: true, + dependencies: { + "@antelopejs/interface-core": `file:${tarball}`, + }, + }), + ); + writeFileSync(join(temporary, "contract.cjs"), consumerSource); + writeFileSync(join(temporary, "contract.ts"), typeConsumerSource); +} + +const typeConsumerSource = ` +import type { InterfaceConnection } from "@antelopejs/interface-core"; +import type { ModuleExecutionContext } from "@antelopejs/interface-core/modules"; + +const connection: InterfaceConnection = { + path: "example", + provider: "provider", + selected: true, +}; +const context: ModuleExecutionContext = { + module: "provider", + owner: "provider#1", +}; +void connection; +void context; +`; + +const consumerSource = ` +const assert = require("node:assert/strict"); +const core = require("@antelopejs/interface-core"); +const { internal } = require("@antelopejs/interface-core/internal"); +const modules = require("@antelopejs/interface-core/modules"); + +const proxy = core.InterfaceFunction("package-consumer.context"); +const identity = core.GetInterfaceProxyIdentity(proxy.proxy); +const providerContext = { module: "provider", owner: "provider#old", provider: "provider" }; +const consumerContext = { + module: "consumer", + owner: "consumer#1", + providerRoutes: { [identity]: "provider" }, +}; + +modules.RunWithModuleContext(providerContext, () => { + core.ImplementInterface({ GetValue: proxy }, { + GetValue: async () => { + await Promise.resolve(); + return modules.GetModuleContext(); + }, + }); +}); + +(async () => { + const oldContext = await modules.RunWithModuleContext(consumerContext, () => proxy()); + assert.equal(oldContext.module, "provider"); + assert.equal(oldContext.owner, "provider#old"); + assert.equal(oldContext.provider, "provider"); + + modules.RunWithModuleContext( + { module: "provider", owner: "provider#new", provider: "provider" }, + () => core.ImplementInterface({ GetValue: proxy }, { + GetValue: () => modules.GetModuleContext().owner, + }), + ); + modules.RunWithModuleContext(providerContext, () => { + modules.Events.ModuleDestroyed.emit("provider"); + }); + const replacement = await modules.RunWithModuleContext(consumerContext, () => proxy()); + assert.equal(replacement, "provider#new"); + + internal.interfaceConnections.consumer = { + example: [{ path: "example", provider: "provider", selected: true }], + }; + const metadata = modules.RunWithModuleContext(consumerContext, () => + core.GetInterfaceInstances("example"), + ); + assert.deepEqual(metadata, [ + { path: "example", provider: "provider", selected: true }, + ]); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +`; + +try { + run("corepack", ["pnpm", "run", "build"], repository); + run( + "corepack", + ["pnpm", "pack", "--pack-destination", temporary], + repository, + { ...process.env, npm_config_ignore_scripts: "true" }, + ); + const tarball = join( + temporary, + readdirSync(temporary).find((entry) => entry.endsWith(".tgz")), + ); + createConsumer(tarball); + run( + "corepack", + ["pnpm", "install", "--ignore-workspace", "--frozen-lockfile=false"], + temporary, + ); + run( + join(repository, "node_modules", ".bin", "tsc"), + [ + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "ES2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "contract.ts", + ], + temporary, + ); + run(process.execPath, ["contract.cjs"], temporary); +} finally { + rmSync(temporary, { force: true, recursive: true }); +} From f7a389b7b412b9c801b5104eb6bf79cab9ebbdde Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 22:16:11 +0000 Subject: [PATCH 2/4] docs: document provider generation ownership Amp-Thread-ID: https://ampcode.com/threads/T-01a01742-a6f0-7092-a10b-01e8e6633a0d Co-authored-by: Upd4ting --- docs/2.proxies.md | 19 ++++++++++++++++++- docs/5.modules.md | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/2.proxies.md b/docs/2.proxies.md index 8165d78..f0b8fcb 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -31,6 +31,8 @@ const greeting = await proxy.call("Bob"); // "Hello, Bob!" Attaches a callback function to the proxy. The proxy automatically tracks the calling module and detaches the callback when that module is unloaded. Pass `manualDetach: true` to disable automatic cleanup. +When attachment occurs inside `RunWithModuleContext`, calls execute in the captured provider context rather than the consumer context. The captured module, owner generation, provider, and provider routes remain available across nested calls and `await`. Calls reject with `ModuleContextInvalidatedError` if the captured owner has been destroyed. + ```ts // Automatic cleanup (default) - detaches when the module unloads proxy.onCall(myHandler); @@ -127,10 +129,14 @@ routeRegistry.unregister("about"); Attaches the register callback. Any previously registered entries replay through this callback immediately. Automatic module-aware detachment applies unless `manualDetach` is `true`. +Register replay and direct registration execute in the provider context captured when the callback attaches. + ### `onUnregister(callback)` Attaches the unregister callback. This callback is detached at the same time as the register callback. +Unregistration executes in the context captured when this callback attaches. + ### `register(id, ...args)` Registers an entry with the given identifier. If a register callback is attached, it executes immediately. Otherwise, the entry is stored and replayed when a callback is attached. The calling module is tracked for automatic cleanup. @@ -208,6 +214,17 @@ const connections = GetInterfaceInstances("database"); const primary = GetInterfaceInstance("database", "primary"); ``` +Each `InterfaceConnection` includes the provider module ID and whether that provider is selected for unqualified calls: + +```ts +interface InterfaceConnection { + id?: string; + path: string; + provider: string; + selected: boolean; +} +``` + ## `GetResponsibleModule` `GetResponsibleModule` inspects the call stack to determine which module is responsible for the current execution. The proxy classes use this internally for automatic cleanup tracking. @@ -235,7 +252,7 @@ await RunWithResponsibleModule("my-module", async () => { The module loader should wrap known module-owned entry points, including module evaluation and lifecycle hooks. Existing callers need no migration: outside an explicit context, `GetResponsibleModule` retains stack-based resolution as a backward-compatible fallback. Automatic proxy detachment and registration cleanup use the resolved module in both paths. -Ownership contexts are scoped to a loaded module generation. `ModuleDestroyed` invalidates that generation before cleanup, so detached asynchronous work cannot add stale providers or handlers afterward. Such work receives a `ModuleContextInvalidatedError`. A later invocation for the same module ID creates a fresh generation without reactivating older contexts. +Ownership contexts are scoped to a loaded module generation. Loaders that can overlap old and replacement instances should use `RunWithModuleContext` and provide a unique `owner` for every generation. `ModuleDestroyed` invalidates and cleans only the active event context's owner while preserving the existing module ID event contract. Detached asynchronous work from that owner then receives a `ModuleContextInvalidatedError`. ## Next steps diff --git a/docs/5.modules.md b/docs/5.modules.md index d199ed2..b109d36 100644 --- a/docs/5.modules.md +++ b/docs/5.modules.md @@ -35,6 +35,26 @@ loaded -> constructed -> active -> constructed -> loaded | `active` | Module is fully started and providing services | | `unknown` | Module status cannot be determined | +## Module execution context + +`RunWithModuleContext` propagates module ownership and provider routing through synchronous and asynchronous work: + +```ts +import { RunWithModuleContext } from "@antelopejs/interface-core/modules"; + +await RunWithModuleContext( + { + module: "search-provider", + owner: "search-provider#42", + provider: "search-provider", + providerRoutes: routes, + }, + () => constructModule(), +); +``` + +`module` remains the stable public module ID. `owner` identifies one lifecycle generation and should be unique when old and replacement instances can overlap. Providers capture this full context when attaching callbacks. `GetModuleContext` returns the active context and throws `ModuleContextInvalidatedError` after its owner is destroyed. + ## Lifecycle events The `Events` namespace exposes four `EventProxy` instances that fire during module lifecycle transitions. @@ -81,6 +101,8 @@ Events.ModuleDestroyed.register((moduleId: string) => { }); ``` +The event signature remains the module ID. When emitted inside `RunWithModuleContext`, cleanup targets that context's `owner`; without an explicit owner it retains the module-level behavior used by earlier releases. + ## Management functions These functions are declared as `InterfaceFunction` proxies. They are available once the core runtime provides their implementation. From 15c94ebf29305b82fdc66b9a956142d1f6f5433e Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 22:26:20 +0000 Subject: [PATCH 3/4] address greptile review feedback (greploop iteration 1) Co-authored-by: Upd4ting --- src/proxies.ts | 13 ++++++--- src/tests/generation-cleanup.test.ts | 41 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/proxies.ts b/src/proxies.ts index ea53f4d..063c137 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -341,9 +341,11 @@ export class RegisteringProxy { public onRegister(callback: T, manualDetach?: boolean): AttachmentLease { const route = getAttachmentRoute(manualDetach); const current = this.state.callbacks.get(route.provider); + const unregister = + current?.owner === route.owner ? current.unregister : undefined; return this.attachHandlers( bindProviderCallback(callback), - current?.unregister, + unregister, manualDetach, true, route, @@ -359,13 +361,16 @@ export class RegisteringProxy { this[PROXY_BRAND].identity, requested, ); - const route = current + const contextOwner = context?.owner ?? context?.module; + const canExtendCurrent = + current && (!contextOwner || current.owner === contextOwner); + const route = canExtendCurrent ? { owner: current.owner, provider: current.provider } : getAttachmentRoute(); return this.attachHandlers( - current?.register, + canExtendCurrent ? current.register : undefined, bindProviderCallback(callback), - current?.manualDetach, + canExtendCurrent ? current.manualDetach : undefined, false, route, ); diff --git a/src/tests/generation-cleanup.test.ts b/src/tests/generation-cleanup.test.ts index 9b3cd1f..51a9ae3 100644 --- a/src/tests/generation-cleanup.test.ts +++ b/src/tests/generation-cleanup.test.ts @@ -84,6 +84,47 @@ describe("generation-owned cleanup", () => { expect(calls).to.deep.equal(["new:item"]); }); + it("keeps split handlers owned by their replacement generation", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "generation.split-registering", + ); + const calls: string[] = []; + RunWithModuleContext( + { module: "shared", owner: "split#old", provider: "shared" }, + () => proxy.onRegister((id) => calls.push(`old-register:${id}`)), + ); + RunWithModuleContext( + { module: "shared", owner: "split#new", provider: "shared" }, + () => proxy.onUnregister((id) => calls.push(`new-unregister:${id}`)), + ); + proxy.register("item"); + + RunWithModuleContext( + { module: "shared", owner: "split#old", provider: "shared" }, + () => { + Events.ModuleDestroyed.emit("shared"); + Events.ModuleDestroyed.emit("shared"); + }, + ); + proxy.unregister("item"); + + expect(calls).to.deep.equal(["new-unregister:item"]); + expect(internal.knownRegisters.has("split#old")).to.equal(false); + expect(internal.knownRegisters.has("split#new")).to.equal(true); + + RunWithModuleContext( + { module: "shared", owner: "split#new", provider: "shared" }, + () => { + Events.ModuleDestroyed.emit("shared"); + Events.ModuleDestroyed.emit("shared"); + }, + ); + internal.testStubMode = true; + + expect(() => proxy.register("detached")).to.throw(); + expect(internal.knownRegisters.has("split#new")).to.equal(false); + }); + it("cleans only registrations and events from the destroyed owner", () => { const registrations = new RegisteringProxy<(id: string) => void>( "generation.consumer-registering", From 7b646df3a672b22b34775eaffad6476903e4ce43 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 22:35:11 +0000 Subject: [PATCH 4/4] address greptile review feedback (greploop iteration 2) Co-authored-by: Upd4ting --- src/proxies.ts | 153 ++++++++++++++++++--------- src/tests/generation-cleanup.test.ts | 54 +++++++++- 2 files changed, 155 insertions(+), 52 deletions(-) diff --git a/src/proxies.ts b/src/proxies.ts index 063c137..212aa6c 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -54,13 +54,19 @@ interface AsyncProxyState { queue: Array>; } +interface RegisterAttachment extends Attachment { + manualDetach: boolean; +} + interface RegisterCallbacks { - generation: number; - owner: string; provider: string; + register?: RegisterAttachment; + unregister?: RegisterAttachment<(id: RID) => void>; +} + +interface AttachmentOptions { + route: AttachmentRoute; manualDetach: boolean; - register?: T; - unregister?: (id: RID) => void; } interface RegisteredEntry { @@ -210,6 +216,16 @@ function reportRuntimeError( }); } +function matchesLease( + attachment: Attachment | undefined, + lease: AttachmentLease, +) { + return ( + attachment?.generation === lease.generation && + attachment.owner === lease.owner + ); +} + /** @internal */ export function InvalidateResponsibleModule(module: string): void { invalidateModuleContext(module); @@ -340,15 +356,10 @@ export class RegisteringProxy { /** Attaches a register callback. */ public onRegister(callback: T, manualDetach?: boolean): AttachmentLease { const route = getAttachmentRoute(manualDetach); - const current = this.state.callbacks.get(route.provider); - const unregister = - current?.owner === route.owner ? current.unregister : undefined; - return this.attachHandlers( + return this.attachRegister( bindProviderCallback(callback), - unregister, - manualDetach, - true, route, + Boolean(manualDetach), ); } @@ -362,18 +373,18 @@ export class RegisteringProxy { requested, ); const contextOwner = context?.owner ?? context?.module; - const canExtendCurrent = - current && (!contextOwner || current.owner === contextOwner); - const route = canExtendCurrent - ? { owner: current.owner, provider: current.provider } - : getAttachmentRoute(); - return this.attachHandlers( - canExtendCurrent ? current.register : undefined, - bindProviderCallback(callback), - canExtendCurrent ? current.manualDetach : undefined, - false, - route, - ); + const attachments = [current?.unregister, current?.register]; + const sibling = contextOwner + ? attachments.find((attachment) => attachment?.owner === contextOwner) + : attachments.find((attachment) => Boolean(attachment)); + const canExtendCurrent = current && sibling; + const options: AttachmentOptions = canExtendCurrent + ? { + route: { owner: sibling.owner, provider: current.provider }, + manualDetach: sibling.manualDetach, + } + : { route: getAttachmentRoute(), manualDetach: false }; + return this.attachUnregister(bindProviderCallback(callback), options); } /** Atomically attaches both registration handlers. */ @@ -382,11 +393,18 @@ export class RegisteringProxy { unregister: (id: RID) => void, manualDetach?: boolean, ): AttachmentLease { - return this.attachHandlers( - bindProviderCallback(register), - bindProviderCallback(unregister), - manualDetach, - ); + const route = getAttachmentRoute(manualDetach); + const lease = this.createLease(route); + const boundRegister = bindProviderCallback(register); + const boundUnregister = bindProviderCallback(unregister); + this.state.callbacks.set(route.provider, { + provider: route.provider, + register: this.createAttachment(boundRegister, lease, manualDetach), + unregister: this.createAttachment(boundUnregister, lease, manualDetach), + }); + this.trackAttachment(lease, Boolean(manualDetach)); + this.replayRegistrations(route.provider, boundRegister); + return lease; } /** Detaches one leased provider, or every provider when called without a lease. */ @@ -396,10 +414,16 @@ export class RegisteringProxy { return; } const current = this.state.callbacks.get(lease.provider); - if ( - current?.generation === lease.generation && - current.owner === lease.owner - ) { + if (!current) { + return; + } + if (matchesLease(current.register, lease)) { + current.register = undefined; + } + if (matchesLease(current.unregister, lease)) { + current.unregister = undefined; + } + if (!current.register && !current.unregister) { this.state.callbacks.delete(lease.provider); } } @@ -431,7 +455,7 @@ export class RegisteringProxy { provider: requested ?? callback?.provider, args, }); - callback?.register?.(id, ...args); + callback?.register?.callback(id, ...args); } /** Unregisters an entry from the provider that accepted it. */ @@ -446,7 +470,7 @@ export class RegisteringProxy { entry.provider, ); try { - callback?.unregister?.(id); + callback?.unregister?.callback(id); } finally { this.state.registered.delete(id); } @@ -486,31 +510,58 @@ export class RegisteringProxy { } } - private attachHandlers( - register?: T, - unregister?: (id: RID) => void, - manualDetach?: boolean, - shouldReplay = true, - attachmentRoute?: AttachmentRoute, + private attachRegister( + callback: T, + route: AttachmentRoute, + manualDetach: boolean, ): AttachmentLease { - const route = attachmentRoute ?? getAttachmentRoute(manualDetach); - const lease = { ...route, generation: internal.nextLeaseGeneration++ }; + const lease = this.createLease(route); + const current = this.state.callbacks.get(route.provider); this.state.callbacks.set(route.provider, { - register, - unregister, - manualDetach: Boolean(manualDetach), - ...lease, + provider: route.provider, + register: this.createAttachment(callback, lease, manualDetach), + unregister: current?.unregister, + }); + this.trackAttachment(lease, manualDetach); + this.replayRegistrations(route.provider, callback); + return lease; + } + + private attachUnregister( + callback: (id: RID) => void, + options: AttachmentOptions, + ): AttachmentLease { + const { route, manualDetach } = options; + const lease = this.createLease(route); + const current = this.state.callbacks.get(route.provider); + this.state.callbacks.set(route.provider, { + provider: route.provider, + register: current?.register, + unregister: this.createAttachment(callback, lease, manualDetach), }); + this.trackAttachment(lease, manualDetach); + return lease; + } + + private trackAttachment(lease: AttachmentLease, manualDetach: boolean) { if (!manualDetach) { - internal.addRegisteringProxy(route.owner, { + internal.addRegisteringProxy(lease.owner, { cleanup: () => this.detach(lease), unregisterModule: (module: string) => this.unregisterModule(module), }); } - if (register && shouldReplay) { - this.replayRegistrations(route.provider, register); - } - return lease; + } + + private createLease(route: AttachmentRoute): AttachmentLease { + return { ...route, generation: internal.nextLeaseGeneration++ }; + } + + private createAttachment( + callback: F, + lease: AttachmentLease, + manualDetach?: boolean, + ): RegisterAttachment { + return { callback, ...lease, manualDetach: Boolean(manualDetach) }; } private replayRegistrations(provider: string, callback: T) { diff --git a/src/tests/generation-cleanup.test.ts b/src/tests/generation-cleanup.test.ts index 51a9ae3..f2569d1 100644 --- a/src/tests/generation-cleanup.test.ts +++ b/src/tests/generation-cleanup.test.ts @@ -99,6 +99,8 @@ describe("generation-owned cleanup", () => { ); proxy.register("item"); + expect(calls).to.deep.equal(["old-register:item"]); + RunWithModuleContext( { module: "shared", owner: "split#old", provider: "shared" }, () => { @@ -108,7 +110,7 @@ describe("generation-owned cleanup", () => { ); proxy.unregister("item"); - expect(calls).to.deep.equal(["new-unregister:item"]); + expect(calls).to.deep.equal(["old-register:item", "new-unregister:item"]); expect(internal.knownRegisters.has("split#old")).to.equal(false); expect(internal.knownRegisters.has("split#new")).to.equal(true); @@ -125,6 +127,56 @@ describe("generation-owned cleanup", () => { expect(internal.knownRegisters.has("split#new")).to.equal(false); }); + it("keeps reverse split handlers until their owner is destroyed", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "generation.reverse-split-registering", + ); + const calls: string[] = []; + RunWithModuleContext( + { module: "shared", owner: "reverse#old", provider: "shared" }, + () => proxy.onUnregister((id) => calls.push(`old-unregister:${id}`)), + ); + RunWithModuleContext( + { module: "shared", owner: "reverse#new", provider: "shared" }, + () => proxy.onRegister((id) => calls.push(`new-register:${id}`)), + ); + proxy.register("active"); + proxy.unregister("active"); + proxy.register("survivor"); + + expect(calls).to.deep.equal([ + "new-register:active", + "old-unregister:active", + "new-register:survivor", + ]); + + RunWithModuleContext( + { module: "shared", owner: "reverse#old", provider: "shared" }, + () => { + Events.ModuleDestroyed.emit("shared"); + Events.ModuleDestroyed.emit("shared"); + }, + ); + proxy.unregister("survivor"); + proxy.register("replacement"); + + expect(calls.at(-1)).to.equal("new-register:replacement"); + expect(internal.knownRegisters.has("reverse#old")).to.equal(false); + expect(internal.knownRegisters.has("reverse#new")).to.equal(true); + + RunWithModuleContext( + { module: "shared", owner: "reverse#new", provider: "shared" }, + () => { + Events.ModuleDestroyed.emit("shared"); + Events.ModuleDestroyed.emit("shared"); + }, + ); + internal.testStubMode = true; + + expect(() => proxy.register("detached")).to.throw(); + expect(internal.knownRegisters.has("reverse#new")).to.equal(false); + }); + it("cleans only registrations and events from the destroyed owner", () => { const registrations = new RegisteringProxy<(id: string) => void>( "generation.consumer-registering",