diff --git a/docs/2.proxies.md b/docs/2.proxies.md index 287a615..8165d78 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -218,7 +218,24 @@ 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. + +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 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/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/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 65601e6..9b4ff8c 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -1,9 +1,62 @@ -import { MissingProviderError } from "./errors"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { MissingProviderError, ModuleContextInvalidatedError } from "./errors"; import { internal } from "./internal"; import { findResponsibleFile } from "./responsible-module"; type Func = (...args: A) => R; +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. + * + * 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 { + const inheritedContext = responsibleModuleContext.getStore(); + if (inheritedContext) { + assertActiveContext(inheritedContext); + } + const context = { module, token: getResponsibleModuleToken(module) }; + return responsibleModuleContext.run(context, callback); +} + /** * Proxy for an asynchronous function. * @@ -28,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 }) => { @@ -106,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 { @@ -279,6 +328,11 @@ 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 explicitContext = responsibleModuleContext.getStore(); + if (explicitContext) { + assertActiveContext(explicitContext); + return explicitContext.module; + } 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..9ae45f8 --- /dev/null +++ b/src/tests/module-ownership-context.test.ts @@ -0,0 +1,255 @@ +import { expect } from "chai"; +import { + AsyncProxy, + EventProxy, + GetResponsibleModule, + ModuleContextInvalidatedError, + RegisteringProxy, + RunWithResponsibleModule, +} from ".."; +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; + + 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("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(() => + 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"]); + }); +});