From a163c732d53e945a0583b4ba1a7dc619cabdc96f Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 18 Aug 2026 23:59:06 +0000 Subject: [PATCH 1/3] fix(runtime): make proxy routing cross-copy safe Share a protocol-versioned runtime across compatible package copies, brand proxies with stable identities, and route provider attachments through module execution context. Validate interface implementations atomically, bound missing-provider queues, and make replay and module cleanup failures observable without aborting remaining work. --- src/errors.ts | 26 + src/index.ts | 186 ++++- src/internal.ts | 149 +++- src/modules.ts | 117 +++- src/proxies.ts | 647 ++++++++++++------ src/runtime.ts | 9 +- .../implement-interface-validation.test.ts | 67 ++ src/tests/provider-routing.test.ts | 129 ++++ src/tests/queue-and-cleanup.test.ts | 100 +++ src/tests/runtime-protocol.test.ts | 66 ++ src/tests/runtime.test.ts | 20 +- 11 files changed, 1212 insertions(+), 304 deletions(-) create mode 100644 src/tests/implement-interface-validation.test.ts create mode 100644 src/tests/provider-routing.test.ts create mode 100644 src/tests/queue-and-cleanup.test.ts create mode 100644 src/tests/runtime-protocol.test.ts diff --git a/src/errors.ts b/src/errors.ts index 40cc4e6..94ddab4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,6 @@ export const MISSING_PROVIDER_CODE = "ERR_NO_PROVIDER"; +export const AMBIGUOUS_PROVIDER_CODE = "ERR_AMBIGUOUS_PROVIDER"; +export const PROVIDER_QUEUE_FULL_CODE = "ERR_PROVIDER_QUEUE_FULL"; const MISSING_PROVIDER_MESSAGE = "Interface function called without implementation in test environment. " + @@ -21,6 +23,30 @@ export class MissingProviderError extends Error { } } +/** Error emitted when a call cannot be routed between multiple providers. */ +export class AmbiguousProviderError extends Error { + public readonly code = AMBIGUOUS_PROVIDER_CODE; + + public constructor(proxyIdentity: string, providers: string[]) { + super( + `Interface proxy ${proxyIdentity} has multiple providers (${providers.join(", ")}); run the call in a module execution context with an explicit provider route.`, + ); + this.name = "AmbiguousProviderError"; + } +} + +/** Error emitted when an unattached proxy's bounded queue is full. */ +export class ProviderQueueFullError extends Error { + public readonly code = PROVIDER_QUEUE_FULL_CODE; + + public constructor(proxyIdentity: string, limit: number) { + super( + `Interface proxy ${proxyIdentity} has ${limit} pending operations without a provider.`, + ); + this.name = "ProviderQueueFullError"; + } +} + /** * Whether the value is an error, including one built in another realm. * diff --git a/src/index.ts b/src/index.ts index 8406e25..de4187b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,16 +4,19 @@ import { internal } from "./internal"; import { Logging } from "./logging"; import { AsyncProxy, - EventProxy, + type EventProxy, GetResponsibleModule, - RegisteringProxy, + IsInterfaceProxy, + type RegisteringProxy, } from "./proxies"; export * from "./errors"; export { AsyncProxy, EventProxy, + GetInterfaceProxyIdentity, GetResponsibleModule, + IsInterfaceProxy, RegisteringProxy, } from "./proxies"; @@ -81,8 +84,8 @@ type Func = (...args: A) => R; export function InterfaceFunction< T extends Func = Func, R = Awaited>, ->(): (...args: Parameters) => Promise { - const proxy = new AsyncProxy(); +>(identity?: string): (...args: Parameters) => Promise { + const proxy = new AsyncProxy(identity); const func = (...args: Parameters) => proxy.call(...args); func.proxy = proxy; return func; @@ -92,13 +95,15 @@ type RID = T extends (id: infer P, ...args: any[]) => void ? P : never; type InterfaceImplType = T extends RegisteringProxy ? { register: P; unregister: (id: RID

) => void } - : T extends EventProxy - ? never - : T extends (...args: infer A) => infer R - ? (...args: A) => Awaited | R - : T extends Record - ? InterfaceToImpl - : never; + : T extends AsyncProxy + ? P + : T extends EventProxy + ? never + : T extends (...args: infer A) => infer R + ? (...args: A) => Awaited | R + : T extends Record + ? InterfaceToImpl + : never; type InterfaceToImpl = T extends infer P ? { @@ -106,22 +111,136 @@ type InterfaceToImpl = T extends infer P } : never; -function implement(decl: Record, impl: Record) { - for (const key in decl) { - if (key in impl) { - const val = decl[key]; - if (val instanceof RegisteringProxy) { - val.onRegister(impl[key].register); - val.onUnregister(impl[key].unregister); - } else if (typeof val === "function" && val.proxy instanceof AsyncProxy) { - (val.proxy).onCall(impl[key]); - } else if (val instanceof AsyncProxy) { - val.onCall(impl[key]); - } else if (!(val instanceof EventProxy)) { - implement(val, impl[key]); - } +interface AsyncProxyProtocol { + onCall(callback: Func): unknown; +} + +interface RegisteringProxyProtocol { + onHandlers(register: Func, unregister: Func): unknown; +} + +interface AttachmentPlan { + attach(): void; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function assertAcyclic(value: unknown, label: string) { + const visited = new WeakSet(); + const active = new WeakSet(); + + const visit = (current: unknown, path: string) => { + if (!isObject(current) || visited.has(current)) { + return; + } + if (active.has(current)) { + throw new TypeError(`${label} contains a cycle at ${path}.`); + } + active.add(current); + for (const [key, child] of Object.entries(current)) { + visit(child, `${path}.${key}`); + } + active.delete(current); + visited.add(current); + }; + + visit(value, label); +} + +function requireFunction(value: unknown, path: string): Func { + if (typeof value !== "function") { + throw new TypeError(`Missing or malformed interface handler at ${path}.`); + } + return value as Func; +} + +function planProxyAttachment( + proxy: unknown, + implementation: unknown, + path: string, +): AttachmentPlan | undefined { + if (IsInterfaceProxy(proxy, "event")) { + return; + } + if (IsInterfaceProxy(proxy, "async")) { + const callback = requireFunction(implementation, path); + return { + attach: () => (proxy as AsyncProxyProtocol).onCall(callback), + }; + } + if (!IsInterfaceProxy(proxy, "registering")) { + return; + } + if (!isObject(implementation)) { + throw new TypeError(`Missing or malformed interface handler at ${path}.`); + } + const register = requireFunction(implementation.register, `${path}.register`); + const unregister = requireFunction( + implementation.unregister, + `${path}.unregister`, + ); + return { + attach: () => + (proxy as RegisteringProxyProtocol).onHandlers(register, unregister), + }; +} + +function createAttachmentPlan( + declaration: Record, + implementation: Record, + path = "implementation", +): AttachmentPlan[] { + const plans: AttachmentPlan[] = []; + for (const [key, declared] of Object.entries(declaration)) { + const implemented = implementation[key]; + const proxy = + typeof declared === "function" && "proxy" in declared + ? (declared as Func & { proxy?: unknown }).proxy + : declared; + const proxyPlan = planProxyAttachment(proxy, implemented, `${path}.${key}`); + if (proxyPlan) { + plans.push(proxyPlan); + continue; + } + if (isObject(declared) && !IsInterfaceProxy(declared)) { + const nestedImplementation = isObject(implemented) ? implemented : {}; + plans.push( + ...createAttachmentPlan( + declared, + nestedImplementation, + `${path}.${key}`, + ), + ); } } + return plans; +} + +function attachImplementation( + declaration: Record, + implementation: Record, +) { + if (!isObject(declaration) || !isObject(implementation)) { + throw new TypeError( + "Interface declaration and implementation must be objects.", + ); + } + assertAcyclic(declaration, "declaration"); + assertAcyclic(implementation, "implementation"); + const plans = createAttachmentPlan(declaration, implementation); + plans.forEach((plan) => { + plan.attach(); + }); +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as PromiseLike).then === "function" + ); } /** @@ -146,28 +265,28 @@ export function ImplementInterface< T extends Record, T2 extends InterfaceToImpl, >( - declaration: T | Promise, - implementation: T2 | Promise, + declaration: T | PromiseLike, + implementation: T2 | PromiseLike, ): Promise<{ declaration: Awaited; implementation: T2 }>; export function ImplementInterface< T extends Record, T2 extends Record, >( - declaration: T | Promise, - implementation: T2 | Promise, + declaration: T | PromiseLike, + implementation: T2 | PromiseLike, ): | { declaration: T; implementation: T2 } | Promise<{ declaration: T; implementation: T2 }> { - if (declaration instanceof Promise || implementation instanceof Promise) { + if (isThenable(declaration) || isThenable(implementation)) { return Promise.all([declaration, implementation]).then(([decl, impl]) => { - implement(decl, impl); + attachImplementation(decl, impl); return { declaration: decl, implementation: impl as T2 }; }); } const decl = declaration; const impl = implementation as Record; - implement(decl, impl); + attachImplementation(decl, impl); return { declaration: decl, implementation: impl as T2 }; } @@ -189,8 +308,7 @@ export function GetInterfaceInstances( ): InterfaceConnection[] { const module = GetResponsibleModule(); if (!module || !(module in internal.interfaceConnections)) return []; - const connections = internal.interfaceConnections[module]; - return connections[interfaceID] ?? []; + return internal.interfaceConnections[module][interfaceID] ?? []; } /** diff --git a/src/internal.ts b/src/internal.ts index c73ef88..beeec50 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,42 +1,127 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export const RUNTIME_PROTOCOL_VERSION = 1; +export const RUNTIME_SYMBOL = Symbol.for("@antelopejs/interface-core/runtime"); + export interface InterfaceConnection { id?: string; path: string; } -function addToMapArray(map: Map>, key: string, value: any) { - if (!map.has(key)) { - map.set(key, []); - } - map.get(key)?.push(value); +export interface ModuleExecutionContext { + module: string; + provider?: string; + providerRoutes?: Readonly>; } -/** @internal */ -export const internal = { - moduleByFolder: [] as { +export interface ProxyBrand { + protocol: number; + kind: "async" | "registering" | "event"; + identity: string; +} + +export interface RuntimeProxyState { + kind: ProxyBrand["kind"]; + value: unknown; +} + +export interface RuntimeCleanup { + cleanup(): void; + unregisterModule?(module: string): void; +} + +export interface RuntimeErrorDetails { + operation: string; + module?: string; + proxyIdentity?: string; + registrationId?: unknown; +} + +export interface InterfaceRuntime { + protocol: number; + moduleByFolder: Array<{ dir: string; id: string; isImplementor?: boolean; - }[], - testStubMode: false, - knownAsync: new Map>(), - knownRegisters: new Map>(), - knownEvents: [] as any[], - interfaceConnections: {} as Record< - string, - Record - >, - asyncContextReporter: undefined as - | ((trace: NodeJS.CallSite[]) => void) - | undefined, - replayErrorReporter: undefined as - | ((id: unknown, err: unknown) => void) - | undefined, - - addAsyncProxy(module: string, proxy: any) { - addToMapArray(internal.knownAsync, module, proxy); - }, - - addRegisteringProxy(module: string, proxy: any) { - addToMapArray(internal.knownRegisters, module, proxy); - }, -}; + }>; + testStubMode: boolean; + knownAsync: Map>; + knownRegisters: Map>; + registeringProxies: Set<{ unregisterModule(module: string): void }>; + knownEvents: Set<{ unregisterModule(module: string): void }>; + interfaceConnections: Record>; + executionContext: AsyncLocalStorage; + proxyStates: Map; + nextProxyIdentity: number; + nextLeaseGeneration: number; + maxPendingOperations: number; + asyncContextReporter?: (trace: NodeJS.CallSite[]) => void; + runtimeErrorReporter?: (error: unknown, details: RuntimeErrorDetails) => void; + replayErrorReporter?: (id: unknown, error: unknown) => void; + addAsyncProxy( + module: string, + proxy: RuntimeCleanup | { detach(): void }, + ): void; + addRegisteringProxy( + module: string, + proxy: RuntimeCleanup | { detach(): void }, + ): void; +} + +function addToMapSet(map: Map>, key: string, value: T) { + const values = map.get(key) ?? new Set(); + values.add(value); + map.set(key, values); +} + +function createRuntime(): InterfaceRuntime { + const runtime: InterfaceRuntime = { + protocol: RUNTIME_PROTOCOL_VERSION, + moduleByFolder: [], + testStubMode: false, + knownAsync: new Map(), + knownRegisters: new Map(), + registeringProxies: new Set(), + knownEvents: new Set(), + interfaceConnections: Object.create(null) as Record< + string, + Record + >, + executionContext: new AsyncLocalStorage(), + proxyStates: new Map(), + nextProxyIdentity: 1, + nextLeaseGeneration: 1, + maxPendingOperations: 1_000, + addAsyncProxy(module, proxy) { + addToMapSet(runtime.knownAsync, module, proxy); + }, + addRegisteringProxy(module, proxy) { + addToMapSet(runtime.knownRegisters, module, proxy); + }, + }; + return runtime; +} + +function getRuntime(): InterfaceRuntime { + const globals = globalThis as Record; + const existing = globals[RUNTIME_SYMBOL] as InterfaceRuntime | undefined; + if (existing && existing.protocol !== RUNTIME_PROTOCOL_VERSION) { + throw new Error( + `Incompatible @antelopejs/interface-core runtime protocol: expected ${RUNTIME_PROTOCOL_VERSION}, received ${existing.protocol}`, + ); + } + if (existing) { + return existing; + } + const runtime = createRuntime(); + Object.defineProperty(globals, RUNTIME_SYMBOL, { + configurable: false, + enumerable: false, + writable: false, + value: runtime, + }); + return runtime; +} + +/** @internal */ +export const internal = getRuntime(); diff --git a/src/modules.ts b/src/modules.ts index b9e0c67..78c6067 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,5 +1,27 @@ import { EventProxy, InterfaceFunction } from "."; -import { internal } from "./internal"; +import { + internal, + type ModuleExecutionContext, + type RuntimeCleanup, +} from "./internal"; + +/** Runs work with module ownership and an optional provider route across awaits. */ +export function RunWithModuleContext( + context: ModuleExecutionContext, + callback: () => T, +): T { + if (!context.module) { + throw new Error("Module execution context requires a module ID."); + } + return internal.executionContext.run(context, callback); +} + +/** Returns the active module execution context, if one exists. */ +export function GetModuleContext(): ModuleExecutionContext | undefined { + return internal.executionContext.getStore(); +} + +export type { ModuleExecutionContext } from "./internal"; /** * Contains events related to module lifecycle management. @@ -16,7 +38,9 @@ export namespace Events { * * @param module Module ID */ - export const ModuleConstructed = new EventProxy<(module: string) => void>(); + export const ModuleConstructed = new EventProxy<(module: string) => void>( + "modules.ModuleConstructed", + ); /** * Event triggers when a module is started. @@ -26,7 +50,9 @@ export namespace Events { * * @param module Module ID */ - export const ModuleStarted = new EventProxy<(module: string) => void>(); + export const ModuleStarted = new EventProxy<(module: string) => void>( + "modules.ModuleStarted", + ); /** * Event triggers when a module is stopped. @@ -36,7 +62,9 @@ export namespace Events { * * @param module Module ID */ - export const ModuleStopped = new EventProxy<(module: string) => void>(); + export const ModuleStopped = new EventProxy<(module: string) => void>( + "modules.ModuleStopped", + ); /** * Event triggers when a module is destroyed. @@ -46,30 +74,55 @@ export namespace Events { * * @param module Module ID */ - export const ModuleDestroyed = new EventProxy<(module: string) => void>(); + export const ModuleDestroyed = new EventProxy<(module: string) => void>( + "modules.ModuleDestroyed", + ); } -// Using the Events namespace from modules.ts instead of the lowercase events -Events.ModuleDestroyed.register((module) => { - if (internal.knownAsync.has(module)) { - for (const proxy of internal.knownAsync.get(module) ?? []) { - proxy.detach(); +function runCleanup( + cleanup: RuntimeCleanup | { detach(): void }, + module: string, + operation: string, +) { + try { + if ("cleanup" in cleanup) { + cleanup.cleanup(); + } else { + cleanup.detach(); } - internal.knownAsync.delete(module); + } catch (error) { + internal.runtimeErrorReporter?.(error, { operation, module }); } - if (internal.knownRegisters.has(module)) { - for (const proxy of internal.knownRegisters.get(module) ?? []) { - proxy.detach(); - } - internal.knownRegisters.delete(module); +} + +Events.ModuleDestroyed.register((module) => { + for (const cleanup of internal.knownAsync.get(module) ?? []) { + runCleanup(cleanup, module, "detach-async-provider"); } - for (const [, proxies] of internal.knownRegisters) { - for (const proxy of proxies) { + internal.knownAsync.delete(module); + for (const cleanup of internal.knownRegisters.get(module) ?? []) { + runCleanup(cleanup, module, "detach-registering-provider"); + } + internal.knownRegisters.delete(module); + for (const proxy of internal.registeringProxies) { + try { proxy.unregisterModule(module); + } catch (error) { + internal.runtimeErrorReporter?.(error, { + operation: "unregister-module", + module, + }); } } for (const proxy of internal.knownEvents) { - proxy.unregisterModule(module); + try { + proxy.unregisterModule(module); + } catch (error) { + internal.runtimeErrorReporter?.(error, { + operation: "unregister-event-module", + module, + }); + } } }); @@ -134,7 +187,9 @@ export type ModuleInfo = Required & { * * @returns Array of module IDs */ -export const ListModules = InterfaceFunction<() => string[]>(); +export const ListModules = InterfaceFunction<() => string[]>( + "modules.ListModules", +); /** * Retrieve the configuration and status information of a loaded module. @@ -145,8 +200,9 @@ export const ListModules = InterfaceFunction<() => string[]>(); * @param module The module ID to get information for * @returns Complete module information object */ -export const GetModuleInfo = - InterfaceFunction<(module: string) => ModuleInfo>(); +export const GetModuleInfo = InterfaceFunction<(module: string) => ModuleInfo>( + "modules.GetModuleInfo", +); /** * Load a new module with the given ID and configuration. @@ -165,7 +221,7 @@ export const LoadModule = configuration: ModuleDefinition, autostart?: boolean, ) => string[] - >(); + >("modules.LoadModule"); /** * Start a loaded but inactive module. @@ -176,7 +232,9 @@ export const LoadModule = * @param module The module ID to start * @throws Error if the module is not loaded or cannot be started */ -export const StartModule = InterfaceFunction<(module: string) => void>(); +export const StartModule = InterfaceFunction<(module: string) => void>( + "modules.StartModule", +); /** * Stop an active module. @@ -187,7 +245,8 @@ export const StartModule = InterfaceFunction<(module: string) => void>(); * @param module The module ID to stop * @throws Error if the module is not loaded or cannot be stopped */ -export const StopModule = InterfaceFunction<(module: string) => void>(); +export const StopModule = + InterfaceFunction<(module: string) => void>("modules.StopModule"); /** * Destroy a stopped module. @@ -198,7 +257,9 @@ export const StopModule = InterfaceFunction<(module: string) => void>(); * @param module The module ID to destroy * @throws Error if the module is active or not loaded */ -export const DestroyModule = InterfaceFunction<(module: string) => void>(); +export const DestroyModule = InterfaceFunction<(module: string) => void>( + "modules.DestroyModule", +); /** * Unload a module and retrigger its source mechanism. @@ -210,4 +271,6 @@ export const DestroyModule = InterfaceFunction<(module: string) => void>(); * @param module The module ID to reload * @throws Error if the module cannot be reloaded */ -export const ReloadModule = InterfaceFunction<(module: string) => void>(); +export const ReloadModule = InterfaceFunction<(module: string) => void>( + "modules.ReloadModule", +); diff --git a/src/proxies.ts b/src/proxies.ts index 65601e6..71696e2 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -1,258 +1,497 @@ -import { MissingProviderError } from "./errors"; -import { internal } from "./internal"; +import { + AmbiguousProviderError, + MissingProviderError, + ProviderQueueFullError, +} from "./errors"; +import { + internal, + type ProxyBrand, + RUNTIME_PROTOCOL_VERSION, +} from "./internal"; import { findResponsibleFile } from "./responsible-module"; type Func = (...args: A) => R; +type RegisterFunction = (id: any, ...args: any[]) => void; +type RID = T extends (id: infer P, ...args: any[]) => void ? P : never; +type RArgs = T extends (id: any, ...args: infer P) => void ? P : never; +type ProxyKind = ProxyBrand["kind"]; + +const PROXY_BRAND = Symbol.for("@antelopejs/interface-core/proxy"); +const DEFAULT_PROVIDER = "@antelopejs/interface-core/default-provider"; + +interface Attachment { + callback: T; + generation: number; + owner: string; + provider: string; +} + +export interface AttachmentLease { + generation: number; + owner: string; + provider: string; +} + +interface PendingCall { + args: Parameters; + provider?: string; + resolve: (value: R | PromiseLike) => void; + reject: (reason?: any) => void; +} + +interface AsyncProxyState { + callbacks: Map>; + queue: Array>; +} + +interface RegisterCallbacks { + generation: number; + owner: string; + provider: string; + manualDetach: boolean; + register?: T; + unregister?: (id: RID) => void; +} + +interface RegisteredEntry { + args: RArgs; + module?: string; + provider?: string; +} + +interface RegisteringProxyState { + callbacks: Map>; + registered: Map, RegisteredEntry>; +} + +interface EventEntry { + module?: string; + func: T; +} + +interface EventProxyState { + registered: EventEntry[]; +} + +function createIdentity(kind: ProxyKind, identity?: string) { + if (identity) { + return `${kind}:${identity}`; + } + const nextIdentity = internal.nextProxyIdentity++; + return `${kind}:anonymous:${nextIdentity}`; +} + +function getProxyState(brand: ProxyBrand, create: () => T): T { + const existing = internal.proxyStates.get(brand.identity); + if (existing && existing.kind !== brand.kind) { + throw new Error(`Proxy identity ${brand.identity} has conflicting kinds.`); + } + if (existing) { + return existing.value as T; + } + const value = create(); + internal.proxyStates.set(brand.identity, { kind: brand.kind, value }); + return value; +} + +function createBrand(kind: ProxyKind, identity?: string): ProxyBrand { + return Object.freeze({ + protocol: RUNTIME_PROTOCOL_VERSION, + kind, + identity: createIdentity(kind, identity), + }); +} + +function readBrand(value: unknown): ProxyBrand | undefined { + if ((typeof value !== "object" && typeof value !== "function") || !value) { + return; + } + const brand = (value as Record)[PROXY_BRAND] as + | ProxyBrand + | undefined; + if (!brand || brand.protocol !== RUNTIME_PROTOCOL_VERSION) { + return; + } + return brand; +} + +/** Returns whether a value implements this runtime's stable proxy protocol. */ +export function IsInterfaceProxy(value: unknown, kind?: ProxyKind): boolean { + const brand = readBrand(value); + return Boolean(brand && (!kind || brand.kind === kind)); +} + +/** Returns the stable identity used to bind a proxy to a provider route. */ +export function GetInterfaceProxyIdentity(value: unknown): string | undefined { + return readBrand(value)?.identity; +} + +function getAttachmentRoute(manualDetach?: boolean) { + const context = internal.executionContext.getStore(); + const responsible = + manualDetach || context?.module ? undefined : GetResponsibleModule(); + const owner = context?.module ?? responsible ?? DEFAULT_PROVIDER; + return { owner, provider: context?.provider ?? owner }; +} + +function getRequestedProvider(proxyIdentity: string) { + const context = internal.executionContext.getStore(); + return context?.providerRoutes?.[proxyIdentity] ?? context?.provider; +} + +function selectProvider( + callbacks: Map, + proxyIdentity: string, + requested?: string, +): T | undefined { + if (requested) { + const callback = callbacks.get(requested); + if (!callback && callbacks.size > 0) { + throw new MissingProviderError( + `Interface proxy ${proxyIdentity} has no provider for route ${requested}.`, + ); + } + return callback; + } + if (callbacks.size <= 1) { + return callbacks.values().next().value; + } + throw new AmbiguousProviderError(proxyIdentity, [...callbacks.keys()]); +} + +function reportRuntimeError( + error: unknown, + operation: string, + proxyIdentity: string, + module?: string, + registrationId?: unknown, +) { + internal.runtimeErrorReporter?.(error, { + operation, + module, + proxyIdentity, + registrationId, + }); +} -/** - * Proxy for an asynchronous function. - * - * Queues up calls while unattached, automatically unattaches when the source module is unloaded. - * Provides a mechanism for delayed execution and module-aware function binding. - */ +/** Proxy for an asynchronous interface function. */ export class AsyncProxy>> { - private callback?: T; - private queue: Array<{ - args: Parameters; - resolve: (value: R | PromiseLike) => void; - reject: (reason?: any) => void; - }> = []; - - /** - * Attaches a callback to the proxy - * - * Automatically detached if the module calling this function gets unloaded and manualDetach is not set to true. - * When attached, any queued calls will be executed immediately. - * - * @param callback Function to attach - * @param manualDetach Don't detach automatically when module is unloaded - */ - public onCall(callback: T, manualDetach?: boolean) { - this.callback = callback; + public readonly [PROXY_BRAND]: ProxyBrand; + private readonly state: AsyncProxyState; + + public constructor(identity?: string) { + this[PROXY_BRAND] = createBrand("async", identity); + this.state = getProxyState(this[PROXY_BRAND], () => ({ + callbacks: new Map(), + queue: [], + })); + } + + /** Attaches a provider callback and replays compatible queued calls. */ + 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 }); if (!manualDetach) { - const caller = GetResponsibleModule(); - if (caller) { - internal.addAsyncProxy(caller, this); - } - } - if (this.queue.length > 0) { - this.queue.forEach(({ args, resolve, reject }) => { - try { - resolve(callback(...args)); - } catch (err) { - reject(err); - } + internal.addAsyncProxy(route.owner, { + cleanup: () => this.detach(lease), }); - this.queue.splice(0, this.queue.length); } + this.replayQueue(route.provider, callback); + return lease; } - /** - * Manually detach the callback on this proxy. - */ - public detach() { - this.callback = undefined; + /** Detaches one leased provider, or every provider when called without a lease. */ + public detach(lease?: AttachmentLease) { + if (!lease) { + this.state.callbacks.clear(); + return; + } + const current = this.state.callbacks.get(lease.provider); + if ( + current?.generation === lease.generation && + current.owner === lease.owner + ) { + this.state.callbacks.delete(lease.provider); + } } - /** - * Call the function attached to this proxy. - * - * If a callback has not been attached yet, the call is queued up for later. - */ + /** Calls the provider selected by the current module execution context. */ public call(...args: Parameters): Promise { - if (this.callback) { - try { - return Promise.resolve(this.callback(...args)); - } catch (err) { - return Promise.reject(err); - } + const requested = getRequestedProvider(this[PROXY_BRAND].identity); + let attachment: Attachment | undefined; + try { + attachment = selectProvider( + this.state.callbacks, + this[PROXY_BRAND].identity, + requested, + ); + } catch (error) { + return Promise.reject(error); + } + if (attachment) { + return this.invoke(attachment.callback, args); } if (internal.testStubMode) { return Promise.reject(new MissingProviderError()); } - return new Promise((resolve, reject) => - this.queue.push({ args, resolve, reject }), - ); + if (this.state.queue.length >= internal.maxPendingOperations) { + return Promise.reject( + new ProviderQueueFullError( + this[PROXY_BRAND].identity, + internal.maxPendingOperations, + ), + ); + } + return new Promise((resolve, reject) => { + this.state.queue.push({ args, provider: requested, resolve, reject }); + }); } -} -type RegisterFunction = (id: any, ...args: any[]) => void; -type RID = T extends (id: infer P, ...args: any[]) => void ? P : never; -type RArgs = T extends (id: any, ...args: infer P) => void ? P : never; - -/** - * Proxy for a pair of register/unregister functions. - * - * Manages registration of handlers and ensures proper cleanup when modules are unloaded. - * This allows for module-aware event registration with automatic cleanup. - */ -export class RegisteringProxy { - private registerCallback?: T; - private unregisterCallback?: (id: RID) => void; - private registered = new Map< - RID, - { - module?: string; - args: RArgs; - } - >(); - - /** - * Attaches a register callback to the proxy - * - * Automatically detached if the module calling this function gets unloaded and manualDetach is not set to true. - * - * @param callback Function to attach as the register callback - * @param manualDetach Don't detach automatically - */ - public onRegister(callback: T, manualDetach?: boolean) { - this.registerCallback = callback; - if (!manualDetach) { - const caller = GetResponsibleModule(); - if (caller) { - internal.addRegisteringProxy(caller, this); - } + private invoke(callback: T, args: Parameters): Promise { + try { + return Promise.resolve(callback(...args)); + } catch (error) { + return Promise.reject(error); } - for (const [id, { args }] of this.registered) { - try { - callback(id, ...args); - } catch (err) { - // Best-effort replay: one throwing consumer must not strand the others. - if (internal.replayErrorReporter) { - internal.replayErrorReporter(id, err); - } + } + + private replayQueue(provider: string, callback: T) { + const remaining: Array> = []; + for (const pending of this.state.queue) { + if (pending.provider && pending.provider !== provider) { + remaining.push(pending); + continue; } + this.invoke(callback, pending.args).then(pending.resolve, pending.reject); } + this.state.queue = remaining; + } +} + +/** Proxy for provider-aware register and unregister handlers. */ +export class RegisteringProxy { + public readonly [PROXY_BRAND]: ProxyBrand; + private readonly state: RegisteringProxyState; + + public constructor(identity?: string) { + this[PROXY_BRAND] = createBrand("registering", identity); + this.state = getProxyState(this[PROXY_BRAND], () => ({ + callbacks: new Map(), + registered: new Map(), + })); + internal.registeringProxies.add(this); } - /** - * Attaches an unregister callback to the proxy - * - * Detached at the same time as the register callback. - * - * @param callback Function to attach as the unregister callback - */ - public onUnregister(callback: (id: RID) => void) { - this.unregisterCallback = callback; + /** Attaches a register callback. */ + public onRegister(callback: T, manualDetach?: boolean): AttachmentLease { + const route = getAttachmentRoute(); + const current = this.state.callbacks.get(route.provider); + return this.attachHandlers(callback, current?.unregister, manualDetach); } - /** - * Manually detach the callbacks on this proxy. - */ - public detach() { - this.registerCallback = undefined; - this.unregisterCallback = undefined; + /** Attaches an unregister callback to the current provider route. */ + public onUnregister(callback: (id: RID) => void): AttachmentLease { + const route = getAttachmentRoute(); + const current = this.state.callbacks.get(route.provider); + return this.attachHandlers( + current?.register, + callback, + current?.manualDetach, + false, + ); } - /** - * Call the register callback attached to this proxy. - * - * If a callback has not been attached yet, the call is queued up for later. - * - * @param id Unique identifier used to unregister - * @param args Extra arguments - */ + /** Atomically attaches both registration handlers. */ + public onHandlers( + register: T, + unregister: (id: RID) => void, + manualDetach?: boolean, + ): AttachmentLease { + return this.attachHandlers(register, unregister, manualDetach); + } + + /** Detaches one leased provider, or every provider when called without a lease. */ + public detach(lease?: AttachmentLease) { + if (!lease) { + this.state.callbacks.clear(); + return; + } + const current = this.state.callbacks.get(lease.provider); + if ( + current?.generation === lease.generation && + current.owner === lease.owner + ) { + this.state.callbacks.delete(lease.provider); + } + } + + /** Registers an entry with the selected provider or queues it for bootstrap. */ public register(id: RID, ...args: RArgs) { - if (!this.registerCallback && internal.testStubMode) { + const requested = getRequestedProvider(this[PROXY_BRAND].identity); + const callback = selectProvider( + this.state.callbacks, + this[PROXY_BRAND].identity, + requested, + ); + if (!callback && internal.testStubMode) { throw new MissingProviderError(); } - const module = GetResponsibleModule(); - this.registered.set(id, { module, args }); - if (this.registerCallback) { - this.registerCallback(id, ...args); + if ( + !callback && + !this.state.registered.has(id) && + this.state.registered.size >= internal.maxPendingOperations + ) { + throw new ProviderQueueFullError( + this[PROXY_BRAND].identity, + internal.maxPendingOperations, + ); } + this.state.registered.set(id, { + module: GetResponsibleModule(), + provider: requested ?? callback?.provider, + args, + }); + callback?.register?.(id, ...args); } - /** - * Call the unregister callback attached to this proxy. - * - * @param id Unique identifier to unregister - */ + /** Unregisters an entry from the provider that accepted it. */ public unregister(id: RID) { - if (this.registered.has(id)) { - if (this.unregisterCallback) { - this.unregisterCallback(id); + const entry = this.state.registered.get(id); + if (!entry) { + return; + } + const callback = selectProvider( + this.state.callbacks, + this[PROXY_BRAND].identity, + entry.provider, + ); + try { + callback?.unregister?.(id); + } finally { + this.state.registered.delete(id); + } + } + + /** Unregisters every entry owned by a destroyed module. */ + public unregisterModule(module: string) { + for (const [id, entry] of this.state.registered) { + if (entry.module !== module) { + continue; + } + try { + this.unregister(id); + } catch (error) { + reportRuntimeError( + error, + "unregister", + this[PROXY_BRAND].identity, + module, + id, + ); + } finally { + this.state.registered.delete(id); } - this.registered.delete(id); } } - /** - * Unregister all entries created by the given module - * @internal - * - * @param mod Module ID - */ - public unregisterModule(mod: string) { - for (const [id, { module }] of this.registered) { - if (module === mod) { - if (this.unregisterCallback) { - this.unregisterCallback(id); - } - this.registered.delete(id); + private attachHandlers( + register?: T, + unregister?: (id: RID) => void, + manualDetach?: boolean, + shouldReplay = true, + ): AttachmentLease { + const route = getAttachmentRoute(manualDetach); + const lease = { ...route, generation: internal.nextLeaseGeneration++ }; + this.state.callbacks.set(route.provider, { + register, + unregister, + manualDetach: Boolean(manualDetach), + ...lease, + }); + if (!manualDetach) { + internal.addRegisteringProxy(route.owner, { + cleanup: () => this.detach(lease), + unregisterModule: (module: string) => this.unregisterModule(module), + }); + } + if (register && shouldReplay) { + this.replayRegistrations(route.provider, register); + } + return lease; + } + + private replayRegistrations(provider: string, callback: T) { + for (const [id, entry] of this.state.registered) { + if (entry.provider && entry.provider !== provider) { + continue; + } + try { + callback(id, ...entry.args); + entry.provider = provider; + } catch (error) { + internal.replayErrorReporter?.(id, error); + reportRuntimeError( + error, + "register-replay", + this[PROXY_BRAND].identity, + entry.module, + id, + ); } } } } type EventFunction = (...args: any[]) => void; -/** - * Event handler list that automatically removes handlers from unloaded modules. - * - * Provides a module-aware event system that cleans up event handlers when modules are unloaded, - * preventing memory leaks and ensuring proper modularity. - */ + +/** Module-aware event handler collection. */ export class EventProxy { - private registered: Array<{ - module?: string; - func: T; - }> = []; + public readonly [PROXY_BRAND]: ProxyBrand; + private readonly state: EventProxyState; - public constructor() { - internal.knownEvents.push(this); + public constructor(identity?: string) { + this[PROXY_BRAND] = createBrand("event", identity); + this.state = getProxyState(this[PROXY_BRAND], () => ({ registered: [] })); + internal.knownEvents.add(this); } - /** - * Call all the event handlers with the specified arguments. - * - * @param args Arguments - */ + /** Emits to every handler, reporting failures without aborting later handlers. */ public emit(...args: Parameters) { - for (const { func } of this.registered) { - func(...args); + for (const { func, module } of this.state.registered) { + try { + func(...args); + } catch (error) { + reportRuntimeError( + error, + "event-emit", + this[PROXY_BRAND].identity, + module, + ); + } } } - /** - * Register a new handler for this event. - * - * @param func Handler - */ + /** Registers a handler once. */ public register(func: T) { - if (this.registered.some((existing) => existing.func === func)) { + if (this.state.registered.some((existing) => existing.func === func)) { return; } - const module = GetResponsibleModule(); - this.registered.push({ module, func }); + this.state.registered.push({ module: GetResponsibleModule(), func }); } - /** - * Unregister a handler on this event. - * - * @param fn The handler that was passed to {@link register} - */ + /** Unregisters a handler. */ public unregister(fn: T) { - this.registered = this.registered.filter(({ func }) => func !== fn); + this.state.registered = this.state.registered.filter( + ({ func }) => func !== fn, + ); } - /** - * Unregister all handlers created by the given module. - * @internal - * - * @param mod Module ID - */ - public unregisterModule(mod: string) { - this.registered = this.registered.filter(({ module }) => module !== mod); + /** Unregisters handlers owned by a destroyed module. */ + public unregisterModule(module: string) { + this.state.registered = this.state.registered.filter( + (entry) => entry.module !== module, + ); } } @@ -261,24 +500,20 @@ function captureCallStack(startFrame = 0): NodeJS.CallSite[] { const oldLimit = Error.stackTraceLimit; Error.stackTraceLimit = Infinity; Error.prepareStackTrace = (_, trace) => trace; - const errObj = {} as { stack: Array }; - Error.captureStackTrace(errObj, GetResponsibleModule); - const trace = errObj.stack as unknown as NodeJS.CallSite[]; + const error = {} as { stack: string[] }; + Error.captureStackTrace(error, GetResponsibleModule); + const trace = error.stack as unknown as NodeJS.CallSite[]; Error.prepareStackTrace = oldHandler; Error.stackTraceLimit = oldLimit; return trace.slice(startFrame); } -/** - * Gets the responsible module for the current execution context. - * - * Determines which module is responsible for the current code execution by analyzing the call stack. - * This is used for automatic proxy detachment and event handler cleanup. - * - * @param startFrame The starting frame in the stack trace to analyze - * @returns The module ID or undefined if no module is found - */ +/** Gets the responsible module from explicit async context or the call stack. */ export function GetResponsibleModule(startFrame = 0): string | undefined { + const contextModule = internal.executionContext.getStore()?.module; + if (contextModule) { + return contextModule; + } const trace = captureCallStack(startFrame); const responsible = findResponsibleFile(trace); if (responsible.module) { diff --git a/src/runtime.ts b/src/runtime.ts index 65018e6..0de93aa 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -84,7 +84,9 @@ export const DEV_REGISTRY_PATH = ".antelope/dev.json"; * * @returns Runtime information including dev mode, project path and environment */ -export const GetRuntimeInfo = InterfaceFunction<() => RuntimeInfo>(); +export const GetRuntimeInfo = InterfaceFunction<() => RuntimeInfo>( + "runtime.GetRuntimeInfo", +); /** * Register a development server and the endpoints it is listening on. @@ -96,5 +98,6 @@ export const GetRuntimeInfo = InterfaceFunction<() => RuntimeInfo>(); * @param name Unique name of the server (e.g. 'api') * @param endpoints Endpoints the server is listening on */ -export const RegisterDevServer = - InterfaceFunction<(name: string, endpoints: DevServerEndpoint[]) => void>(); +export const RegisterDevServer = InterfaceFunction< + (name: string, endpoints: DevServerEndpoint[]) => void +>("runtime.RegisterDevServer"); diff --git a/src/tests/implement-interface-validation.test.ts b/src/tests/implement-interface-validation.test.ts new file mode 100644 index 0000000..e00e2e0 --- /dev/null +++ b/src/tests/implement-interface-validation.test.ts @@ -0,0 +1,67 @@ +import { runInNewContext } from "node:vm"; +import { expect } from "chai"; +import { AsyncProxy, ImplementInterface, RegisteringProxy } from ".."; +import { internal } from "../internal"; + +describe("ImplementInterface validation", () => { + afterEach(() => { + internal.testStubMode = false; + }); + + it("validates every handler before attaching any of them", async () => { + const first = new AsyncProxy<() => string>("test.atomic.first"); + const second = new AsyncProxy<() => string>("test.atomic.second"); + + expect(() => + ImplementInterface({ first, second }, { + first: () => "attached", + } as never), + ).to.throw("implementation.second"); + + internal.testStubMode = true; + const error = await first.call().then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.be.instanceOf(Error); + }); + + it("rejects malformed registering handlers atomically", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "test.malformed-registering", + ); + expect(() => + ImplementInterface({ proxy }, { + proxy: { register: () => undefined }, + } as never), + ).to.throw("implementation.proxy.unregister"); + }); + + it("rejects cycles in declarations and implementations", () => { + const declaration: Record = {}; + declaration.self = declaration; + expect(() => ImplementInterface(declaration, {})).to.throw( + "declaration contains a cycle", + ); + + const implementation: Record = {}; + implementation.self = implementation; + expect(() => ImplementInterface({}, implementation)).to.throw( + "implementation contains a cycle", + ); + }); + + it("awaits thenables and promises created in another realm", async () => { + const proxy = new AsyncProxy<() => string>("test.cross-realm-promise"); + const declaration = runInNewContext("Promise.resolve(value)", { + value: { proxy }, + }) as PromiseLike<{ proxy: AsyncProxy<() => string> }>; + const implementation = runInNewContext( + "({ then(resolve) { resolve(value); } })", + { value: { proxy: () => "resolved" } }, + ) as PromiseLike<{ proxy: () => string }>; + + await ImplementInterface(declaration as never, implementation as never); + expect(await proxy.call()).to.equal("resolved"); + }); +}); diff --git a/src/tests/provider-routing.test.ts b/src/tests/provider-routing.test.ts new file mode 100644 index 0000000..8a6b970 --- /dev/null +++ b/src/tests/provider-routing.test.ts @@ -0,0 +1,129 @@ +import { expect } from "chai"; +import { + AmbiguousProviderError, + AsyncProxy, + GetInterfaceProxyIdentity, + MissingProviderError, + RegisteringProxy, +} from ".."; +import { Events, RunWithModuleContext } from "../modules"; + +describe("provider routing and leases", () => { + it("routes providers through async module execution context", async () => { + const proxy = new AsyncProxy<() => string>("test.provider-routing"); + RunWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { + proxy.onCall(() => "a"); + }); + RunWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { + proxy.onCall(() => "b"); + }); + + expect( + await RunWithModuleContext( + { module: "consumer", provider: "provider-a" }, + () => proxy.call(), + ), + ).to.equal("a"); + expect( + await RunWithModuleContext( + { module: "consumer", provider: "provider-b" }, + () => proxy.call(), + ), + ).to.equal("b"); + const error = await proxy.call().then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.be.instanceOf(AmbiguousProviderError); + }); + + it("rejects an explicit route that has no attached provider", async () => { + const proxy = new AsyncProxy<() => string>("test.missing-route"); + RunWithModuleContext({ module: "owner", provider: "available" }, () => { + proxy.onCall(() => "ok"); + }); + + const result = RunWithModuleContext( + { module: "consumer", provider: "missing" }, + () => proxy.call(), + ); + const error = await result.then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.be.instanceOf(MissingProviderError); + }); + + it("supports per-proxy provider bindings in one module context", async () => { + const first = new AsyncProxy<() => string>("test.route-map.first"); + const second = new AsyncProxy<() => string>("test.route-map.second"); + RunWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { + first.onCall(() => "first-a"); + second.onCall(() => "second-a"); + }); + RunWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { + first.onCall(() => "first-b"); + second.onCall(() => "second-b"); + }); + const firstIdentity = GetInterfaceProxyIdentity(first) as string; + const secondIdentity = GetInterfaceProxyIdentity(second) as string; + + const values = await RunWithModuleContext( + { + module: "consumer", + providerRoutes: { + [firstIdentity]: "provider-a", + [secondIdentity]: "provider-b", + }, + }, + () => Promise.all([first.call(), second.call()]), + ); + + expect(values).to.deep.equal(["first-a", "second-b"]); + }); + + it("routes registrations and unregisters to the bound provider", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "test.registering-routes", + ); + const calls: string[] = []; + RunWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { + proxy.onHandlers( + (id) => calls.push(`register-a:${id}`), + (id) => calls.push(`unregister-a:${id}`), + ); + }); + RunWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { + proxy.onHandlers( + (id) => calls.push(`register-b:${id}`), + (id) => calls.push(`unregister-b:${id}`), + ); + }); + + RunWithModuleContext({ module: "consumer", provider: "provider-b" }, () => { + proxy.register("item"); + proxy.unregister("item"); + }); + + expect(calls).to.deep.equal(["register-b:item", "unregister-b:item"]); + }); + + it("does not let an old owner lease detach a newer provider generation", async () => { + const proxy = new AsyncProxy<() => string>("test.provider-lease"); + RunWithModuleContext({ module: "owner-a", provider: "shared" }, () => { + proxy.onCall(() => "old"); + }); + RunWithModuleContext({ module: "owner-b", provider: "shared" }, () => { + proxy.onCall(() => "new"); + }); + + Events.ModuleDestroyed.emit("owner-a"); + + expect( + await RunWithModuleContext( + { module: "consumer", provider: "shared" }, + () => proxy.call(), + ), + ).to.equal("new"); + }); +}); diff --git a/src/tests/queue-and-cleanup.test.ts b/src/tests/queue-and-cleanup.test.ts new file mode 100644 index 0000000..8257f8c --- /dev/null +++ b/src/tests/queue-and-cleanup.test.ts @@ -0,0 +1,100 @@ +import { expect } from "chai"; +import { + AsyncProxy, + EventProxy, + ProviderQueueFullError, + RegisteringProxy, +} from ".."; +import { internal, type RuntimeErrorDetails } from "../internal"; +import { Events, RunWithModuleContext } from "../modules"; + +describe("bounded queues and resilient cleanup", () => { + const originalQueueLimit = internal.maxPendingOperations; + let originalReporter: typeof internal.runtimeErrorReporter; + + beforeEach(() => { + originalReporter = internal.runtimeErrorReporter; + }); + + afterEach(() => { + internal.maxPendingOperations = originalQueueLimit; + internal.runtimeErrorReporter = originalReporter; + }); + + it("bounds missing-provider call queues while preserving queued bootstrap calls", async () => { + internal.maxPendingOperations = 2; + const proxy = new AsyncProxy<(value: number) => number>("test.call-bound"); + const first = proxy.call(1); + const second = proxy.call(2); + + const error = await proxy.call(3).then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.be.instanceOf(ProviderQueueFullError); + proxy.onCall((value) => value * 2, true); + + expect(await Promise.all([first, second])).to.deep.equal([2, 4]); + }); + + it("bounds queued registrations", () => { + internal.maxPendingOperations = 1; + const proxy = new RegisteringProxy<(id: string) => void>( + "test.registration-bound", + ); + proxy.register("first"); + expect(() => proxy.register("second")).to.throw(ProviderQueueFullError); + }); + + it("continues registration cleanup after an unregister callback throws", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "test.cleanup-errors", + ); + const unregistered: string[] = []; + const errors: Array<{ error: unknown; details: RuntimeErrorDetails }> = []; + internal.runtimeErrorReporter = (error, details) => { + errors.push({ error, details }); + }; + RunWithModuleContext({ module: "provider" }, () => { + proxy.onHandlers( + () => undefined, + (id) => { + unregistered.push(id); + if (id === "first") { + throw new Error("cleanup failed"); + } + }, + ); + }); + RunWithModuleContext({ module: "consumer" }, () => { + proxy.register("first"); + proxy.register("second"); + }); + + Events.ModuleDestroyed.emit("consumer"); + + expect(unregistered).to.deep.equal(["first", "second"]); + expect( + errors.some(({ details }) => details.operation === "unregister"), + ).to.equal(true); + }); + + it("continues event delivery and reports handler errors", () => { + const event = new EventProxy<() => void>("test.event-errors"); + const calls: string[] = []; + const operations: string[] = []; + internal.runtimeErrorReporter = (_, details) => { + operations.push(details.operation); + }; + event.register(() => { + calls.push("first"); + throw new Error("event failed"); + }); + event.register(() => calls.push("second")); + + event.emit(); + + expect(calls).to.deep.equal(["first", "second"]); + expect(operations).to.include("event-emit"); + }); +}); diff --git a/src/tests/runtime-protocol.test.ts b/src/tests/runtime-protocol.test.ts new file mode 100644 index 0000000..bd6e9cf --- /dev/null +++ b/src/tests/runtime-protocol.test.ts @@ -0,0 +1,66 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { expect } from "chai"; +import { AsyncProxy, ImplementInterface } from ".."; + +interface ForeignCore { + AsyncProxy: new ( + identity?: string, + ) => { + call(): Promise; + }; + ImplementInterface( + declaration: Record, + implementation: Record, + ): unknown; +} + +describe("global runtime protocol", () => { + let copyPath: string | undefined; + + afterEach(() => { + if (copyPath) { + rmSync(dirname(copyPath), { force: true, recursive: true }); + copyPath = undefined; + } + }); + + it("converges compatible physical copies and accepts foreign proxy brands", async () => { + const temporary = mkdtempSync(join(tmpdir(), "interface-core-copy-")); + copyPath = join(temporary, "dist"); + cpSync(join(__dirname, ".."), copyPath, { recursive: true }); + const foreign = require(join(copyPath, "index.js")) as ForeignCore; + const localProxy = new AsyncProxy<() => string>("test.cross-copy"); + const foreignProxy = new foreign.AsyncProxy("test.cross-copy"); + + expect(foreignProxy).not.to.be.instanceOf(AsyncProxy); + foreign.ImplementInterface( + { proxy: localProxy }, + { proxy: () => "shared" }, + ); + + expect(await foreignProxy.call()).to.equal("shared"); + ImplementInterface({ proxy: foreignProxy }, { + proxy: () => "local", + } as never); + expect(await localProxy.call()).to.equal("local"); + }); + + it("fails clearly when a realm already contains an incompatible protocol", () => { + const internalPath = join(__dirname, "..", "internal.js"); + const script = ` + globalThis[Symbol.for("@antelopejs/interface-core/runtime")] = { protocol: 999 }; + require(${JSON.stringify(internalPath)}); + `; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + }); + + expect(result.status).not.to.equal(0); + expect(result.stderr).to.include( + "Incompatible @antelopejs/interface-core runtime protocol", + ); + }); +}); diff --git a/src/tests/runtime.test.ts b/src/tests/runtime.test.ts index 6920c68..a5d3cbc 100644 --- a/src/tests/runtime.test.ts +++ b/src/tests/runtime.test.ts @@ -34,7 +34,10 @@ describe("runtime interface", () => { projectPath: "/tmp/project", env: "default", }; - ImplementInterface(runtime, { GetRuntimeInfo: () => info }); + ImplementInterface(runtime, { + GetRuntimeInfo: () => info, + RegisterDevServer: () => undefined, + }); expect(await GetRuntimeInfo()).to.deep.equal(info); }); @@ -42,6 +45,11 @@ describe("runtime interface", () => { it("passes name and endpoints to the RegisterDevServer implementation", async () => { const received: Array<[string, DevServerEndpoint[]]> = []; ImplementInterface(runtime, { + GetRuntimeInfo: () => ({ + dev: false, + projectPath: "/tmp/project", + env: "test", + }), RegisterDevServer: (name, endpoints) => { received.push([name, endpoints]); }, @@ -62,7 +70,10 @@ describe("runtime interface", () => { projectPath: "/tmp/project", env: "production", }; - ImplementInterface(runtime, { GetRuntimeInfo: () => info }); + ImplementInterface(runtime, { + GetRuntimeInfo: () => info, + RegisterDevServer: () => undefined, + }); expect(await pending).to.deep.equal(info); }); @@ -75,6 +86,11 @@ describe("runtime interface", () => { const received: Array<[string, DevServerEndpoint[]]> = []; ImplementInterface(runtime, { + GetRuntimeInfo: () => ({ + dev: false, + projectPath: "/tmp/project", + env: "test", + }), RegisterDevServer: (name, registered) => { received.push([name, registered]); }, From 823fa38250ff9a22165ff5b72ba8871747722fc3 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 00:21:00 +0000 Subject: [PATCH 2/3] address greptile review feedback (greploop iteration 1) Amp-Thread-ID: https://ampcode.com/threads/T-01a01742-a6f0-7092-a10b-01e8e6633a0d --- src/proxies.ts | 31 ++++++++++++++++++---- src/tests/registering-proxy-replay.test.ts | 14 ++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/proxies.ts b/src/proxies.ts index 71696e2..df20d54 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -32,6 +32,11 @@ export interface AttachmentLease { provider: string; } +interface AttachmentRoute { + owner: string; + provider: string; +} + interface PendingCall { args: Parameters; provider?: string; @@ -285,20 +290,35 @@ export class RegisteringProxy { /** Attaches a register callback. */ public onRegister(callback: T, manualDetach?: boolean): AttachmentLease { - const route = getAttachmentRoute(); + const route = getAttachmentRoute(manualDetach); const current = this.state.callbacks.get(route.provider); - return this.attachHandlers(callback, current?.unregister, manualDetach); + return this.attachHandlers( + callback, + current?.unregister, + manualDetach, + true, + route, + ); } /** Attaches an unregister callback to the current provider route. */ public onUnregister(callback: (id: RID) => void): AttachmentLease { - const route = getAttachmentRoute(); - const current = this.state.callbacks.get(route.provider); + const context = internal.executionContext.getStore(); + const requested = context?.provider ?? context?.module; + const current = selectProvider( + this.state.callbacks, + this[PROXY_BRAND].identity, + requested, + ); + const route = current + ? { owner: current.owner, provider: current.provider } + : getAttachmentRoute(); return this.attachHandlers( current?.register, callback, current?.manualDetach, false, + route, ); } @@ -400,8 +420,9 @@ export class RegisteringProxy { unregister?: (id: RID) => void, manualDetach?: boolean, shouldReplay = true, + attachmentRoute?: AttachmentRoute, ): AttachmentLease { - const route = getAttachmentRoute(manualDetach); + const route = attachmentRoute ?? getAttachmentRoute(manualDetach); const lease = { ...route, generation: internal.nextLeaseGeneration++ }; this.state.callbacks.set(route.provider, { register, diff --git a/src/tests/registering-proxy-replay.test.ts b/src/tests/registering-proxy-replay.test.ts index bf62fbf..b7de67d 100644 --- a/src/tests/registering-proxy-replay.test.ts +++ b/src/tests/registering-proxy-replay.test.ts @@ -50,4 +50,18 @@ describe("RegisteringProxy onRegister replay", () => { expect(seen).to.deep.equal(["a", "b"]); }); + + it("keeps legacy split handlers on the same manual route", () => { + const proxy = new RegisteringProxy<(id: string) => void>( + "test.split-manual-handlers", + ); + const calls: string[] = []; + + proxy.onRegister((id) => calls.push(`register:${id}`), true); + proxy.onUnregister((id) => calls.push(`unregister:${id}`)); + proxy.register("item"); + proxy.unregister("item"); + + expect(calls).to.deep.equal(["register:item", "unregister:item"]); + }); }); From fb88a4065e36206dd346bbe58890ec18c50c344c Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 00:25:50 +0000 Subject: [PATCH 3/3] test(runtime): resolve dependencies in duplicate-copy fixture Amp-Thread-ID: https://ampcode.com/threads/T-01a01742-a6f0-7092-a10b-01e8e6633a0d --- src/tests/runtime-protocol.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/runtime-protocol.test.ts b/src/tests/runtime-protocol.test.ts index bd6e9cf..4f31583 100644 --- a/src/tests/runtime-protocol.test.ts +++ b/src/tests/runtime-protocol.test.ts @@ -1,6 +1,5 @@ import { spawnSync } from "node:child_process"; import { cpSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { expect } from "chai"; import { AsyncProxy, ImplementInterface } from ".."; @@ -28,7 +27,7 @@ describe("global runtime protocol", () => { }); it("converges compatible physical copies and accepts foreign proxy brands", async () => { - const temporary = mkdtempSync(join(tmpdir(), "interface-core-copy-")); + const temporary = mkdtempSync(join(process.cwd(), ".interface-core-copy-")); copyPath = join(temporary, "dist"); cpSync(join(__dirname, ".."), copyPath, { recursive: true }); const foreign = require(join(copyPath, "index.js")) as ForeignCore;