Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/2.proxies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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. " +
Expand All @@ -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.
*
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
EventProxy,
GetResponsibleModule,
RegisteringProxy,
RunWithResponsibleModule,
} from "./proxies";

internal.asyncContextReporter = (trace: NodeJS.CallSite[]) => {
Expand Down
2 changes: 2 additions & 0 deletions src/modules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventProxy, InterfaceFunction } from ".";
import { internal } from "./internal";
import { InvalidateResponsibleModule } from "./proxies";

/**
* Contains events related to module lifecycle management.
Expand Down Expand Up @@ -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();
Expand Down
76 changes: 65 additions & 11 deletions src/proxies.ts
Original file line number Diff line number Diff line change
@@ -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<A extends any[] = any[], R = any> = (...args: A) => R;

interface ResponsibleModuleContext {
module: string;
token: symbol;
}

const responsibleModuleContext =
new AsyncLocalStorage<ResponsibleModuleContext>();
const activeResponsibleModuleTokens = new Map<string, symbol>();

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<T>(
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.
*
Expand All @@ -28,12 +81,10 @@ export class AsyncProxy<T extends Func = Func, R = Awaited<ReturnType<T>>> {
* @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 }) => {
Expand Down Expand Up @@ -106,12 +157,10 @@ export class RegisteringProxy<T extends RegisterFunction = RegisterFunction> {
* @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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading