From 90945c4c0e8de6e6b5664fcc7bc29c601bf613fb Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 18 Aug 2026 23:54:30 +0000 Subject: [PATCH 1/3] perf(proxies): add explicit module ownership context Amp-Thread-ID: https://ampcode.com/threads/T-01a01745-fa3a-754d-a85c-052e07555f9c Co-authored-by: Upd4ting --- src/index.ts | 1 + src/proxies.ts | 23 ++++ src/tests/module-ownership-context.test.ts | 150 +++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 src/tests/module-ownership-context.test.ts diff --git a/src/index.ts b/src/index.ts index 8406e25..e1b4410 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { EventProxy, GetResponsibleModule, RegisteringProxy, + RunWithResponsibleModule, } from "./proxies"; internal.asyncContextReporter = (trace: NodeJS.CallSite[]) => { diff --git a/src/proxies.ts b/src/proxies.ts index 65601e6..f977887 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -1,8 +1,27 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { MissingProviderError } from "./errors"; import { internal } from "./internal"; import { findResponsibleFile } from "./responsible-module"; type Func = (...args: A) => R; +const responsibleModuleContext = new AsyncLocalStorage(); + +/** + * Runs work with an explicit responsible module. + * + * The module remains available to nested synchronous and asynchronous work. + * Calls outside this context continue to use stack-based module resolution. + * + * @param module Module ID responsible for the work + * @param callback Work to run in the module context + * @returns The callback result + */ +export function RunWithResponsibleModule( + module: string, + callback: () => T, +): T { + return responsibleModuleContext.run(module, callback); +} /** * Proxy for an asynchronous function. @@ -279,6 +298,10 @@ function captureCallStack(startFrame = 0): NodeJS.CallSite[] { * @returns The module ID or undefined if no module is found */ export function GetResponsibleModule(startFrame = 0): string | undefined { + const explicitModule = responsibleModuleContext.getStore(); + if (explicitModule !== undefined) { + return explicitModule; + } const trace = captureCallStack(startFrame); const responsible = findResponsibleFile(trace); if (responsible.module) { diff --git a/src/tests/module-ownership-context.test.ts b/src/tests/module-ownership-context.test.ts new file mode 100644 index 0000000..cf502e1 --- /dev/null +++ b/src/tests/module-ownership-context.test.ts @@ -0,0 +1,150 @@ +import { expect } from "chai"; +import { + AsyncProxy, + EventProxy, + GetResponsibleModule, + RegisteringProxy, + RunWithResponsibleModule, +} from ".."; +import { MissingProviderError } from "../errors"; +import { internal } from "../internal"; +import { Events } from "../modules"; + +describe("explicit module ownership", () => { + let testStubMode: boolean; + + beforeEach(() => { + testStubMode = internal.testStubMode; + internal.knownAsync.clear(); + internal.knownRegisters.clear(); + }); + + afterEach(() => { + internal.testStubMode = testStubMode; + }); + + it("resolves without capturing a stack", () => { + const captureStackTrace = Error.captureStackTrace; + let captures = 0; + Error.captureStackTrace = (...args) => { + captures += 1; + captureStackTrace(...args); + }; + + try { + expect( + RunWithResponsibleModule("module-a", () => GetResponsibleModule()), + ).to.equal("module-a"); + expect(captures).to.equal(0); + } finally { + Error.captureStackTrace = captureStackTrace; + } + }); + + it("retains stack resolution outside an explicit context", () => { + const captureStackTrace = Error.captureStackTrace; + let captures = 0; + Error.captureStackTrace = (...args) => { + captures += 1; + captureStackTrace(...args); + }; + + try { + GetResponsibleModule(); + expect(captures).to.equal(1); + } finally { + Error.captureStackTrace = captureStackTrace; + } + }); + + it("restores nested ownership", () => { + RunWithResponsibleModule("outer", () => { + expect(GetResponsibleModule()).to.equal("outer"); + RunWithResponsibleModule("inner", () => { + expect(GetResponsibleModule()).to.equal("inner"); + }); + expect(GetResponsibleModule()).to.equal("outer"); + }); + }); + + it("preserves ownership across asynchronous work", async () => { + await RunWithResponsibleModule("async-module", async () => { + await Promise.resolve(); + expect(GetResponsibleModule()).to.equal("async-module"); + }); + }); + + it("restores ownership when nested work throws", () => { + RunWithResponsibleModule("outer", () => { + expect(() => + RunWithResponsibleModule("inner", () => { + throw new Error("boom"); + }), + ).to.throw("boom"); + expect(GetResponsibleModule()).to.equal("outer"); + }); + }); + + it("detaches an async provider during module destruction", async () => { + const proxy = new AsyncProxy<() => string>(); + RunWithResponsibleModule("provider", () => proxy.onCall(() => "ready")); + expect(await proxy.call()).to.equal("ready"); + + Events.ModuleDestroyed.emit("provider"); + internal.testStubMode = true; + + let rejection: unknown; + try { + await proxy.call(); + } catch (error) { + rejection = error; + } + expect(rejection).to.be.instanceOf(MissingProviderError); + }); + + it("cleans handlers and preserves replay attribution", () => { + const registrations = new RegisteringProxy<(id: string) => void>(); + const events = new EventProxy<() => void>(); + const registered: string[] = []; + const unregistered: string[] = []; + let eventCalls = 0; + + RunWithResponsibleModule("consumer", () => { + registrations.register("entry"); + events.register(() => { + eventCalls += 1; + }); + }); + RunWithResponsibleModule("provider", () => { + registrations.onRegister((id) => registered.push(id)); + registrations.onUnregister((id) => unregistered.push(id)); + }); + + expect(registered).to.deep.equal(["entry"]); + Events.ModuleDestroyed.emit("consumer"); + events.emit(); + + expect(eventCalls).to.equal(0); + expect(registered).to.deep.equal(["entry"]); + expect(unregistered).to.deep.equal(["entry"]); + }); + + it("detaches registering providers during hot reload", () => { + const proxy = new RegisteringProxy<(id: string) => void>(); + const registrations: string[] = []; + RunWithResponsibleModule("provider", () => + proxy.onRegister(() => undefined), + ); + + Events.ModuleDestroyed.emit("provider"); + internal.testStubMode = true; + + expect(() => proxy.register("entry")).to.throw(); + + RunWithResponsibleModule("provider", () => + proxy.onRegister((id) => registrations.push(id)), + ); + RunWithResponsibleModule("consumer", () => proxy.register("reloaded")); + expect(registrations).to.deep.equal(["reloaded"]); + }); +}); From aa0e2ac726e6e92935888dbbe7ef5802304cb304 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 18 Aug 2026 23:54:30 +0000 Subject: [PATCH 2/3] docs(proxies): document ownership context integration Amp-Thread-ID: https://ampcode.com/threads/T-01a01745-fa3a-754d-a85c-052e07555f9c Co-authored-by: Upd4ting --- docs/2.proxies.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/2.proxies.md b/docs/2.proxies.md index 287a615..a9d705b 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -218,7 +218,22 @@ import { GetResponsibleModule } from "@antelopejs/interface-core"; const moduleId = GetResponsibleModule(); ``` -> **Warning:** Calling `GetResponsibleModule` from within an async context (such as `setTimeout` or `setInterval`) breaks hot reloading. The system logs an error when this is detected. +> **Warning:** Calling `GetResponsibleModule` from within an async context (such as `setTimeout` or `setInterval`) without explicit ownership breaks hot reloading. The system logs an error when this is detected. + +## `RunWithResponsibleModule` + +`RunWithResponsibleModule` sets the responsible module explicitly for synchronous and asynchronous work. Proxy registrations made in the callback use this module directly instead of capturing and walking a stack. Nested contexts restore their parent when they complete, including when a callback throws. + +```ts +import { RunWithResponsibleModule } from "@antelopejs/interface-core"; + +await RunWithResponsibleModule("my-module", async () => { + proxy.onCall(myHandler); + await initializeModule(); +}); +``` + +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. ## Next steps From d4abc034b70f5f8436e756fcf87f5da727ad1f42 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 00:38:41 +0000 Subject: [PATCH 3/3] fix(proxies): invalidate destroyed module contexts Amp-Thread-ID: https://ampcode.com/threads/T-01a01745-fa3a-754d-a85c-052e07555f9c Co-authored-by: Upd4ting --- docs/2.proxies.md | 2 + src/errors.ts | 13 +++ src/modules.ts | 2 + src/proxies.ts | 63 +++++++++---- src/tests/module-ownership-context.test.ts | 105 +++++++++++++++++++++ 5 files changed, 169 insertions(+), 16 deletions(-) diff --git a/docs/2.proxies.md b/docs/2.proxies.md index a9d705b..8165d78 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -235,6 +235,8 @@ 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. + ## Next steps - [Decorators](./3.decorators.md) - Build type-safe decorator factories diff --git a/src/errors.ts b/src/errors.ts index 40cc4e6..4a8ad9d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,5 @@ export const MISSING_PROVIDER_CODE = "ERR_NO_PROVIDER"; +export const MODULE_CONTEXT_INVALIDATED_CODE = "ERR_MODULE_CONTEXT_INVALIDATED"; const MISSING_PROVIDER_MESSAGE = "Interface function called without implementation in test environment. " + @@ -21,6 +22,18 @@ export class MissingProviderError extends Error { } } +/** + * Error emitted when work inherited ownership from a destroyed module. + */ +export class ModuleContextInvalidatedError extends Error { + public readonly code = MODULE_CONTEXT_INVALIDATED_CODE; + + public constructor(module: string) { + super(`Module context has been invalidated: ${module}`); + this.name = "ModuleContextInvalidatedError"; + } +} + /** * Whether the value is an error, including one built in another realm. * diff --git a/src/modules.ts b/src/modules.ts index b9e0c67..6e9b143 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,5 +1,6 @@ import { EventProxy, InterfaceFunction } from "."; import { internal } from "./internal"; +import { InvalidateResponsibleModule } from "./proxies"; /** * Contains events related to module lifecycle management. @@ -51,6 +52,7 @@ export namespace Events { // Using the Events namespace from modules.ts instead of the lowercase events Events.ModuleDestroyed.register((module) => { + InvalidateResponsibleModule(module); if (internal.knownAsync.has(module)) { for (const proxy of internal.knownAsync.get(module) ?? []) { proxy.detach(); diff --git a/src/proxies.ts b/src/proxies.ts index f977887..9b4ff8c 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -1,10 +1,39 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { MissingProviderError } from "./errors"; +import { MissingProviderError, ModuleContextInvalidatedError } from "./errors"; import { internal } from "./internal"; import { findResponsibleFile } from "./responsible-module"; type Func = (...args: A) => R; -const responsibleModuleContext = new AsyncLocalStorage(); + +interface ResponsibleModuleContext { + module: string; + token: symbol; +} + +const responsibleModuleContext = + new AsyncLocalStorage(); +const activeResponsibleModuleTokens = new Map(); + +function getResponsibleModuleToken(module: string): symbol { + const activeToken = activeResponsibleModuleTokens.get(module); + if (activeToken) { + return activeToken; + } + const token = Symbol(module); + activeResponsibleModuleTokens.set(module, token); + return token; +} + +function assertActiveContext(context: ResponsibleModuleContext): void { + if (activeResponsibleModuleTokens.get(context.module) !== context.token) { + throw new ModuleContextInvalidatedError(context.module); + } +} + +/** @internal */ +export function InvalidateResponsibleModule(module: string): void { + activeResponsibleModuleTokens.delete(module); +} /** * Runs work with an explicit responsible module. @@ -20,7 +49,12 @@ export function RunWithResponsibleModule( module: string, callback: () => T, ): T { - return responsibleModuleContext.run(module, callback); + const inheritedContext = responsibleModuleContext.getStore(); + if (inheritedContext) { + assertActiveContext(inheritedContext); + } + const context = { module, token: getResponsibleModuleToken(module) }; + return responsibleModuleContext.run(context, callback); } /** @@ -47,12 +81,10 @@ export class AsyncProxy>> { * @param manualDetach Don't detach automatically when module is unloaded */ public onCall(callback: T, manualDetach?: boolean) { + const caller = manualDetach ? undefined : GetResponsibleModule(); this.callback = callback; - if (!manualDetach) { - const caller = GetResponsibleModule(); - if (caller) { - internal.addAsyncProxy(caller, this); - } + if (caller) { + internal.addAsyncProxy(caller, this); } if (this.queue.length > 0) { this.queue.forEach(({ args, resolve, reject }) => { @@ -125,12 +157,10 @@ export class RegisteringProxy { * @param manualDetach Don't detach automatically */ public onRegister(callback: T, manualDetach?: boolean) { + const caller = manualDetach ? undefined : GetResponsibleModule(); this.registerCallback = callback; - if (!manualDetach) { - const caller = GetResponsibleModule(); - if (caller) { - internal.addRegisteringProxy(caller, this); - } + if (caller) { + internal.addRegisteringProxy(caller, this); } for (const [id, { args }] of this.registered) { try { @@ -298,9 +328,10 @@ function captureCallStack(startFrame = 0): NodeJS.CallSite[] { * @returns The module ID or undefined if no module is found */ export function GetResponsibleModule(startFrame = 0): string | undefined { - const explicitModule = responsibleModuleContext.getStore(); - if (explicitModule !== undefined) { - return explicitModule; + const explicitContext = responsibleModuleContext.getStore(); + if (explicitContext) { + assertActiveContext(explicitContext); + return explicitContext.module; } const trace = captureCallStack(startFrame); const responsible = findResponsibleFile(trace); diff --git a/src/tests/module-ownership-context.test.ts b/src/tests/module-ownership-context.test.ts index cf502e1..9ae45f8 100644 --- a/src/tests/module-ownership-context.test.ts +++ b/src/tests/module-ownership-context.test.ts @@ -3,6 +3,7 @@ import { AsyncProxy, EventProxy, GetResponsibleModule, + ModuleContextInvalidatedError, RegisteringProxy, RunWithResponsibleModule, } from ".."; @@ -10,6 +11,21 @@ import { MissingProviderError } from "../errors"; import { internal } from "../internal"; import { Events } from "../modules"; +function runDetached(module: string, callback: () => void): Promise { + return new Promise((resolve) => { + RunWithResponsibleModule(module, () => { + setImmediate(() => { + try { + callback(); + resolve(undefined); + } catch (error) { + resolve(error); + } + }); + }); + }); +} + describe("explicit module ownership", () => { let testStubMode: boolean; @@ -74,6 +90,95 @@ describe("explicit module ownership", () => { }); }); + it("does not let detached work register after module destruction", async () => { + const events = new EventProxy<() => void>(); + let calls = 0; + const registration = runDetached("detached-module", () => { + events.register(() => { + calls += 1; + }); + }); + + Events.ModuleDestroyed.emit("detached-module"); + const error = await registration; + events.emit(); + + expect(error).to.be.instanceOf(ModuleContextInvalidatedError); + expect(calls).to.equal(0); + }); + + it("keeps old work invalid after reloading the same module", async () => { + const events = new EventProxy<(source: string) => void>(); + const calls: string[] = []; + const oldRegistration = runDetached("reload-module", () => { + RunWithResponsibleModule("reload-module", () => { + events.register(() => calls.push("old")); + }); + }); + + Events.ModuleDestroyed.emit("reload-module"); + RunWithResponsibleModule("reload-module", () => { + events.register(() => calls.push("new")); + }); + const oldError = await oldRegistration; + events.emit("event"); + + expect(oldError).to.be.instanceOf(ModuleContextInvalidatedError); + expect(calls).to.deep.equal(["new"]); + }); + + it("invalidates concurrent work from the destroyed generation", async () => { + const events = new EventProxy<() => void>(); + const first = runDetached("concurrent-module", () => + events.register(() => undefined), + ); + const second = runDetached("concurrent-module", () => + events.register(() => undefined), + ); + + Events.ModuleDestroyed.emit("concurrent-module"); + + expect(await first).to.be.instanceOf(ModuleContextInvalidatedError); + expect(await second).to.be.instanceOf(ModuleContextInvalidatedError); + }); + + it("invalidates only the destroyed nested context", async () => { + const calls: string[] = []; + const events = new EventProxy<() => void>(); + let inner: Promise | undefined; + const outer = RunWithResponsibleModule("outer-module", () => { + inner = runDetached("inner-module", () => + events.register(() => calls.push("inner")), + ); + return runDetached("outer-module", () => + events.register(() => calls.push("outer")), + ); + }); + + Events.ModuleDestroyed.emit("outer-module"); + expect(await outer).to.be.instanceOf(ModuleContextInvalidatedError); + expect(await inner).to.equal(undefined); + events.emit(); + expect(calls).to.deep.equal(["inner"]); + }); + + it("does not let stale providers replace a reloaded provider", async () => { + const proxy = new AsyncProxy<() => string>(); + const staleAttachment = runDetached("provider-module", () => + proxy.onCall(() => "stale"), + ); + + Events.ModuleDestroyed.emit("provider-module"); + RunWithResponsibleModule("provider-module", () => + proxy.onCall(() => "reloaded"), + ); + + expect(await staleAttachment).to.be.instanceOf( + ModuleContextInvalidatedError, + ); + expect(await proxy.call()).to.equal("reloaded"); + }); + it("restores ownership when nested work throws", () => { RunWithResponsibleModule("outer", () => { expect(() =>