From 9bbad5feacf10ffa7713d153533b57815e1763cf Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 23 Jun 2026 09:47:56 +0800 Subject: [PATCH 01/16] feat(tegg): isolate per-app state for concurrent multi-app via TeggScope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow multiple tegg apps to boot and serve requests concurrently in one process without cross-talk. Introduce a low-level, type-free `TeggScope` (AsyncLocalStorage>) in @eggjs/tegg-types. Each package backs its process-global statics with a per-app scope slot + fallback, so the static facade call sites stay unchanged; boot hooks, request middleware, unittest scope, standalone Runner, and escape points wrap work in `TeggScope.run(app._teggScopeBag, ...)`. Core - TeggScope keystone: current()/run()/createBag()/registerScope() + a single unified process-default bag for all no-scope fallback, plus a strict-mode escape fuse (dev throw / prod warn) that arms only under true multi-app (explicitScopeCount > 1); single-app stays silent. - metadata: EggPrototypeFactory.instance, GlobalGraph.instance, LoadUnitFactory maps (two-tier creator map: shared base for import-time creators + per-app overlay for boot-time app-capturing creators), per-app class->proto map (getPrototypeByClazz) to fix concurrent getEggObject(clazz) resolution. - runtime: LoadUnitInstanceFactory.instanceMap, EggObjectFactory.eggObjectMap, ContextHandler callbacks, all five lifecycle utils become per-app via a createScopedLifecycleUtil proxy in @eggjs/lifecycle. - common-util: ModuleConfigUtil.configNames per-app (fixes the standalone config-names race). Plugins / standalone - plugin/tegg: build per-app bag in configWillLoad, wrap boot/request/unittest + getEggObject + app.module/ctx.module proxies; per-app CompatibleUtil caches. - controller/aop/dal/eventbus/orm/schedule/langchain: wrap lifecycle-hook registration in the app scope (per-app lifecycle utils require it). - dal: per-app TableModel/MysqlDataSource/SqlMap managers; app extend getter pins to the app scope. - eventbus: SingletonEventBus captures its app bag and re-establishes it in doEmit (detached fire-and-forget dispatch isolation). - controller MCP + mcp-proxy: per-app register/hooks; transport captures its register; addHook runs in scope. - standalone Runner: per-Runner bag wrapping load/init/run/destroy. Tests - New plugin/tegg MultiApp.test.ts: two concurrent apps sharing one module — isolated singletons, per-app data store, EventBus emit/handler dispatch, background tasks, and sequential no-leak, all under strict mode. - Update additive export-stable snapshots. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/common-util/src/ModuleConfig.ts | 15 +- .../eventbus-runtime/src/SingletonEventBus.ts | 71 ++++--- .../core/lifecycle/src/ScopedLifecycleUtil.ts | 39 ++++ tegg/core/lifecycle/src/index.ts | 1 + .../test/__snapshots__/index.test.ts.snap | 1 + .../src/factory/EggPrototypeFactory.ts | 44 +++- .../metadata/src/factory/LoadUnitFactory.ts | 57 +++++- .../impl/LoadUnitMultiInstanceProtoHook.ts | 12 +- tegg/core/metadata/src/model/EggPrototype.ts | 14 +- tegg/core/metadata/src/model/LoadUnit.ts | 9 +- .../metadata/src/model/graph/GlobalGraph.ts | 20 +- .../test/__snapshots__/index.test.ts.snap | 4 +- .../src/factory/EggContainerFactory.ts | 8 +- .../runtime/src/factory/EggObjectFactory.ts | 12 +- .../src/factory/LoadUnitInstanceFactory.ts | 13 +- tegg/core/runtime/src/model/ContextHandler.ts | 45 ++++- tegg/core/runtime/src/model/EggContext.ts | 9 +- tegg/core/runtime/src/model/EggObject.ts | 9 +- .../runtime/src/model/LoadUnitInstance.ts | 9 +- .../test/__snapshots__/index.test.ts.snap | 6 +- .../test/__snapshots__/exports.test.ts.snap | 1 + .../test/__snapshots__/helper.test.ts.snap | 10 +- tegg/core/types/package.json | 4 + tegg/core/types/src/index.ts | 1 + tegg/core/types/src/scope/TeggScope.ts | 191 ++++++++++++++++++ tegg/core/types/src/scope/index.ts | 1 + .../test/__snapshots__/index.test.ts.snap | 1 + tegg/plugin/aop/package.json | 3 +- tegg/plugin/aop/src/app.ts | 31 +-- tegg/plugin/controller/src/app.ts | 71 ++++--- .../src/lib/ControllerMetadataManager.ts | 13 +- .../lib/impl/http/HTTPControllerRegister.ts | 21 +- .../src/lib/impl/mcp/MCPControllerRegister.ts | 37 +++- tegg/plugin/dal/src/app.ts | 35 ++-- tegg/plugin/dal/src/app/extend/application.ts | 8 +- .../dal/src/lib/MysqlDataSourceManager.ts | 14 +- tegg/plugin/dal/src/lib/SqlMapManager.ts | 8 +- tegg/plugin/dal/src/lib/TableModelManager.ts | 9 +- tegg/plugin/dal/test/transaction.test.ts | 3 +- tegg/plugin/eventbus/package.json | 3 +- tegg/plugin/eventbus/src/app.ts | 19 +- tegg/plugin/langchain/src/app.ts | 32 +-- tegg/plugin/mcp-proxy/src/app.ts | 6 +- tegg/plugin/orm/package.json | 1 + tegg/plugin/orm/src/app.ts | 18 +- tegg/plugin/schedule/package.json | 1 + tegg/plugin/schedule/src/app.ts | 13 +- tegg/plugin/tegg/package.json | 2 + tegg/plugin/tegg/src/app.ts | 73 ++++--- .../plugin/tegg/src/app/extend/application.ts | 25 ++- .../src/app/extend/application.unittest.ts | 66 +++--- tegg/plugin/tegg/src/app/extend/context.ts | 29 +-- tegg/plugin/tegg/src/lib/CompatibleUtil.ts | 58 ++++-- .../tegg/src/lib/ctx_lifecycle_middleware.ts | 71 ++++--- tegg/plugin/tegg/src/types.ts | 8 + tegg/plugin/tegg/test/MultiApp.test.ts | 125 ++++++++++++ .../config/config.default.ts | 5 + .../multi-app-isolation-b/config/module.json | 1 + .../multi-app-isolation-b/config/plugin.ts | 6 + .../apps/multi-app-isolation-b/package.json | 4 + .../config/config.default.ts | 5 + .../multi-app-isolation/config/module.json | 1 + .../apps/multi-app-isolation/config/plugin.ts | 6 + .../BackgroundCounterService.ts | 26 +++ .../modules/counter-module/CounterEvent.ts | 34 ++++ .../modules/counter-module/CounterService.ts | 41 ++++ .../modules/counter-module/package.json | 7 + .../apps/multi-app-isolation/package.json | 4 + tegg/standalone/standalone/package.json | 3 +- .../standalone/src/EggModuleLoader.ts | 33 +-- tegg/standalone/standalone/src/Runner.ts | 131 +++++++----- 71 files changed, 1350 insertions(+), 367 deletions(-) create mode 100644 tegg/core/lifecycle/src/ScopedLifecycleUtil.ts create mode 100644 tegg/core/types/src/scope/TeggScope.ts create mode 100644 tegg/core/types/src/scope/index.ts create mode 100644 tegg/plugin/tegg/test/MultiApp.test.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/config.default.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/module.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/plugin.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/package.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/config.default.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/module.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/plugin.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/package.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/package.json diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index 514e2ae910..25b8ea2aca 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -10,6 +10,7 @@ import type { NpmModuleReferenceConfig, ReadModuleReferenceOptions, } from '@eggjs/tegg-types'; +import { TeggScope } from '@eggjs/tegg-types'; import { importResolve } from '@eggjs/utils'; import { extend } from 'extend2'; import globby from 'globby'; @@ -33,8 +34,20 @@ const DEFAULT_READ_MODULE_REF_OPTS = { deep: 10, }; +const CONFIG_NAMES_SLOT = Symbol('tegg:common-util:moduleConfigNames'); + export class ModuleConfigUtil { - static configNames: string[] | undefined; + // Per-app/per-Runner: each standalone Runner (and app) has distinct config + // names (env-based); a process-global static races across them (the standalone + // "should work with env" ordering bug). Backed by TeggScope; with no active + // scope it uses the single process-default bag (single-app / config-plugin boot). + static get configNames(): string[] | undefined { + return TeggScope.getOr(CONFIG_NAMES_SLOT, () => undefined, 'ModuleConfigUtil.configNames'); + } + + static set configNames(configNames: string[] | undefined) { + TeggScope.set(CONFIG_NAMES_SLOT, configNames); + } public static setConfigNames(configNames: string[] | undefined): void { ModuleConfigUtil.configNames = configNames; diff --git a/tegg/core/eventbus-runtime/src/SingletonEventBus.ts b/tegg/core/eventbus-runtime/src/SingletonEventBus.ts index a0cebdd47a..ff8ba81ae2 100644 --- a/tegg/core/eventbus-runtime/src/SingletonEventBus.ts +++ b/tegg/core/eventbus-runtime/src/SingletonEventBus.ts @@ -4,8 +4,8 @@ import { Inject, SingletonProto } from '@eggjs/core-decorator'; import { type EventBus, type Events, type EventWaiter, type EventName, CORK_ID } from '@eggjs/eventbus-decorator'; import type { Arguments } from '@eggjs/eventbus-decorator'; import { ContextHandler } from '@eggjs/tegg-runtime'; -import { AccessLevel } from '@eggjs/tegg-types'; -import type { EggRuntimeContext } from '@eggjs/tegg-types'; +import { AccessLevel, TeggScope } from '@eggjs/tegg-types'; +import type { EggRuntimeContext, TeggScopeBag } from '@eggjs/tegg-types'; // @ts-expect-error await-event is not typed import awaitEvent from 'await-event'; // @ts-expect-error await-first is not typed @@ -49,6 +49,12 @@ export class SingletonEventBus implements EventBus, EventWaiter { private readonly corkedEvents = new Map(); + // The per-app TeggScope bag captured when this (per-app) singleton was created. + // emit() is fire-and-forget, so handlers may run on a later tick or be triggered + // from a detached context; re-establishing this bag in doEmit keeps event + // handlers bound to the owning app's factories regardless of the ambient scope. + private readonly teggScopeBag: TeggScopeBag | undefined = TeggScope.current(); + /** * only use for ensure event will happen */ @@ -144,36 +150,39 @@ export class SingletonEventBus implements EventBus, EventWaiter { } private async doEmit(ctx: EggRuntimeContext, event: EventName, args: Array) { - await ContextHandler.run(ctx, async () => { - const lifecycle = {}; - if (ctx.init) { - await ctx.init(lifecycle); - } - try { - const handlerProtos = this.eventHandlerFactory.getHandlerProtos(event); - await Promise.all( - handlerProtos.map(async (proto) => { - try { - await this.eventHandlerFactory.handle(event, proto, args); - } catch (e: any) { - // should wait all handlers done then destroy ctx - e.message = `[EventBus] process event ${String(event)} for handler ${String(proto.name)} failed: ${e.message}`; + const bag = this.teggScopeBag ?? TeggScope.current(); + const doRun = () => + ContextHandler.run(ctx, async () => { + const lifecycle = {}; + if (ctx.init) { + await ctx.init(lifecycle); + } + try { + const handlerProtos = this.eventHandlerFactory.getHandlerProtos(event); + await Promise.all( + handlerProtos.map(async (proto) => { + try { + await this.eventHandlerFactory.handle(event, proto, args); + } catch (e: any) { + // should wait all handlers done then destroy ctx + e.message = `[EventBus] process event ${String(event)} for handler ${String(proto.name)} failed: ${e.message}`; + this.logger.error(e); + } + }), + ); + } catch (e: any) { + e.message = `[EventBus] process event ${String(event)} failed: ${e.message}`; + this.logger.error(e); + } finally { + if (ctx.destroy) { + ctx.destroy(lifecycle).catch((e) => { + e.message = '[tegg/SingletonEventBus] destroy tegg ctx failed:' + e.message; this.logger.error(e); - } - }), - ); - } catch (e: any) { - e.message = `[EventBus] process event ${String(event)} failed: ${e.message}`; - this.logger.error(e); - } finally { - if (ctx.destroy) { - ctx.destroy(lifecycle).catch((e) => { - e.message = '[tegg/SingletonEventBus] destroy tegg ctx failed:' + e.message; - this.logger.error(e); - }); + }); + } } - } - this.doOnceEmit(event, args); - }); + this.doOnceEmit(event, args); + }); + await (bag ? TeggScope.run(bag, doRun) : doRun()); } } diff --git a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts new file mode 100644 index 0000000000..52f20e8f67 --- /dev/null +++ b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts @@ -0,0 +1,39 @@ +import type { LifecycleContext, LifecycleObject } from '@eggjs/tegg-types'; +import { TeggScope } from '@eggjs/tegg-types'; + +import { LifecycleUtil } from './LifycycleUtil.ts'; + +/** + * Create a per-app {@link LifecycleUtil} facade backed by {@link TeggScope}. + * + * The returned object has the exact same shape/type as a `LifecycleUtil`, but + * every property/method access transparently resolves the per-app instance from + * the active {@link TeggScope} bag (lazily created on first use), falling back to + * the process-default bag when no scope is active (single-app lazy default). + * + * This lets module-level lifecycle-util singletons (e.g. `EggPrototypeLifecycleUtil`) + * become per-app WITHOUT changing any call site: hooks registered during one + * app's boot land in that app's util, and concurrent apps never cross-fire. + */ +export function createScopedLifecycleUtil>( + slot: symbol, + desc: string, +): LifecycleUtil { + const resolve = (): LifecycleUtil => TeggScope.resolve(slot, () => new LifecycleUtil(), desc); + // The placeholder target keeps `instanceof LifecycleUtil` working; all real + // reads/writes are forwarded to the per-app instance resolved from the scope. + const placeholder = new LifecycleUtil(); + return new Proxy(placeholder, { + get(_target, prop) { + const real = resolve(); + const value = Reflect.get(real as object, prop, real); + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(real) : value; + }, + set(_target, prop, value) { + return Reflect.set(resolve() as object, prop, value); + }, + has(_target, prop) { + return Reflect.has(resolve() as object, prop); + }, + }); +} diff --git a/tegg/core/lifecycle/src/index.ts b/tegg/core/lifecycle/src/index.ts index 5589a0df50..42b3452553 100644 --- a/tegg/core/lifecycle/src/index.ts +++ b/tegg/core/lifecycle/src/index.ts @@ -1,5 +1,6 @@ export * from '@eggjs/tegg-types/lifecycle'; export * from './LifycycleUtil.ts'; +export * from './ScopedLifecycleUtil.ts'; export * from './IdenticalObject.ts'; export * from './decorator/index.ts'; diff --git a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap index e319b58cb0..8a0df25aa3 100644 --- a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap @@ -11,5 +11,6 @@ exports[`should export stable 1`] = ` "LifecyclePreInject": [Function], "LifecyclePreLoad": [Function], "LifecycleUtil": [Function], + "createScopedLifecycleUtil": [Function], } `; diff --git a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts index da0b04bba6..95e93438de 100644 --- a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts +++ b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts @@ -1,17 +1,43 @@ import { FrameworkErrorFormatter } from '@eggjs/errors'; import { MapUtil } from '@eggjs/tegg-common-util'; -import { AccessLevel } from '@eggjs/tegg-types'; -import type { EggPrototypeName, EggPrototype, LoadUnit, QualifierInfo } from '@eggjs/tegg-types'; +import { AccessLevel, TeggScope } from '@eggjs/tegg-types'; +import type { EggProtoImplClass, EggPrototypeName, EggPrototype, LoadUnit, QualifierInfo } from '@eggjs/tegg-types'; import { EggPrototypeNotFound, MultiPrototypeFound } from '../errors.ts'; +const EGG_PROTOTYPE_FACTORY_SLOT = Symbol('tegg:metadata:eggPrototypeFactory'); + export class EggPrototypeFactory { - public static instance: EggPrototypeFactory = new EggPrototypeFactory(); + /** + * Per-app prototype registry, resolved from the active TeggScope bag (lazily + * created on first access), or the process-default bag in single-app mode. + * Call sites stay unchanged — `EggPrototypeFactory.instance` now returns the + * current app's registry instead of one process-global singleton. + */ + public static get instance(): EggPrototypeFactory { + return TeggScope.resolve( + EGG_PROTOTYPE_FACTORY_SLOT, + () => new EggPrototypeFactory(), + 'EggPrototypeFactory.instance', + ); + } // Map> private publicProtoMap: Map = new Map(); + /** + * Per-app class → prototype map. The global `PrototypeUtil.getClazzProto()` + * stores ONE proto per class on the class itself, so under CONCURRENT multi-app + * boot two apps overwrite each other (last writer wins). This per-app map lets + * `getEggObject(clazz)` resolve the CURRENT app's proto regardless. + */ + private clazzProtoMap: WeakMap = new WeakMap(); + public registerPrototype(proto: EggPrototype, loadUnit: LoadUnit): void { + const clazz = (proto as unknown as { clazz?: EggProtoImplClass }).clazz; + if (clazz) { + this.clazzProtoMap.set(clazz, proto); + } if (proto.accessLevel === AccessLevel.PUBLIC) { const protoList = MapUtil.getOrStore(this.publicProtoMap, proto.name, []); protoList.push(proto); @@ -20,6 +46,10 @@ export class EggPrototypeFactory { } public deletePrototype(proto: EggPrototype, loadUnit: LoadUnit): void { + const clazz = (proto as unknown as { clazz?: EggProtoImplClass }).clazz; + if (clazz) { + this.clazzProtoMap.delete(clazz); + } if (proto.accessLevel === AccessLevel.PUBLIC) { const protos = this.publicProtoMap.get(proto.name); if (protos) { @@ -33,6 +63,14 @@ export class EggPrototypeFactory { loadUnit.deletePrototype(proto); } + /** + * Resolve a prototype by its class from THIS app's registry. Preferred over + * the global `PrototypeUtil.getClazzProto()` in multi-app scenarios. + */ + public getPrototypeByClazz(clazz: EggProtoImplClass): EggPrototype | undefined { + return this.clazzProtoMap.get(clazz); + } + public getPrototype(name: PropertyKey, loadUnit?: LoadUnit, qualifiers?: QualifierInfo[]): EggPrototype { qualifiers = qualifiers || []; const protos = this.doGetPrototype(name, qualifiers, loadUnit); diff --git a/tegg/core/metadata/src/factory/LoadUnitFactory.ts b/tegg/core/metadata/src/factory/LoadUnitFactory.ts index 1c216da76c..7996899113 100644 --- a/tegg/core/metadata/src/factory/LoadUnitFactory.ts +++ b/tegg/core/metadata/src/factory/LoadUnitFactory.ts @@ -1,3 +1,4 @@ +import { TeggScope } from '@eggjs/tegg-types'; import type { EggLoadUnitTypeLike, Id, @@ -10,13 +11,34 @@ import type { import { LoadUnitLifecycleUtil } from '../model/index.ts'; +const LOAD_UNIT_MAP_SLOT = Symbol('tegg:metadata:loadUnitMap'); +const LOAD_UNIT_ID_MAP_SLOT = Symbol('tegg:metadata:loadUnitIdMap'); +const LOAD_UNIT_CREATOR_OVERLAY_SLOT = Symbol('tegg:metadata:loadUnitCreatorOverlay'); + +/** + * Creators registered at IMPORT time are app-agnostic (MODULE/APP) and live in + * this process-global base map. Creators registered at BOOT time that capture + * per-app state (CONTROLLER/Standalone) go into the active app's overlay so + * concurrent apps never clobber each other. Lookup checks overlay first, then base. + */ +const loadUnitCreatorBaseMap: Map = new Map(); + export class LoadUnitFactory { - private static loadUnitCreatorMap: Map = new Map(); - private static loadUnitMap: Map = new Map(); - private static loadUnitIdMap: Map = new Map(); + // Per-app caches: collide across apps if shared (unitPath / name-based id), + // so resolved from the active TeggScope bag (or process-default in single-app). + private static get loadUnitMap(): Map { + return TeggScope.resolve(LOAD_UNIT_MAP_SLOT, () => new Map(), 'LoadUnitFactory.loadUnitMap'); + } + + private static get loadUnitIdMap(): Map { + return TeggScope.resolve(LOAD_UNIT_ID_MAP_SLOT, () => new Map(), 'LoadUnitFactory.loadUnitIdMap'); + } protected static async getLoanUnit(ctx: LoadUnitLifecycleContext, type: EggLoadUnitTypeLike): Promise { - const creator = LoadUnitFactory.loadUnitCreatorMap.get(type); + const overlay = TeggScope.current()?.get(LOAD_UNIT_CREATOR_OVERLAY_SLOT) as + | Map + | undefined; + const creator = overlay?.get(type) ?? loadUnitCreatorBaseMap.get(type); if (!creator) { throw new Error(`not find creator for load unit type ${type}`); } @@ -24,8 +46,9 @@ export class LoadUnitFactory { } static async createLoadUnit(unitPath: string, type: EggLoadUnitTypeLike, loader: Loader): Promise { - if (LoadUnitFactory.loadUnitMap.has(unitPath)) { - return LoadUnitFactory.loadUnitMap.get(unitPath)!.loadUnit; + const loadUnitMap = LoadUnitFactory.loadUnitMap; + if (loadUnitMap.has(unitPath)) { + return loadUnitMap.get(unitPath)!.loadUnit; } const ctx: LoadUnitLifecycleContext = { unitPath, @@ -37,7 +60,7 @@ export class LoadUnitFactory { await loadUnit.init(ctx); } await LoadUnitLifecycleUtil.objectPostCreate(ctx, loadUnit); - LoadUnitFactory.loadUnitMap.set(unitPath, { loadUnit, ctx }); + loadUnitMap.set(unitPath, { loadUnit, ctx }); LoadUnitFactory.loadUnitIdMap.set(loadUnit.id, loadUnit); return loadUnit; } @@ -51,14 +74,15 @@ export class LoadUnitFactory { } static async destroyLoadUnit(loadUnit: LoadUnit): Promise { - const { ctx } = LoadUnitFactory.loadUnitMap.get(loadUnit.unitPath)!; + const loadUnitMap = LoadUnitFactory.loadUnitMap; + const { ctx } = loadUnitMap.get(loadUnit.unitPath)!; try { await LoadUnitLifecycleUtil.objectPreDestroy(ctx, loadUnit); if (loadUnit.destroy) { await loadUnit.destroy(ctx); } } finally { - LoadUnitFactory.loadUnitMap.delete(loadUnit.unitPath); + loadUnitMap.delete(loadUnit.unitPath); LoadUnitFactory.loadUnitIdMap.delete(loadUnit.id); LoadUnitLifecycleUtil.clearObjectLifecycle(loadUnit); } @@ -69,6 +93,19 @@ export class LoadUnitFactory { } static registerLoadUnitCreator(type: EggLoadUnitTypeLike, creator: LoadUnitCreator): void { - LoadUnitFactory.loadUnitCreatorMap.set(type, creator); + const bag = TeggScope.current(); + if (bag) { + // Boot-time registration inside an app scope: keep it per-app so a creator + // capturing this app's state never overwrites another concurrent app's. + let overlay = bag.get(LOAD_UNIT_CREATOR_OVERLAY_SLOT) as Map | undefined; + if (!overlay) { + overlay = new Map(); + bag.set(LOAD_UNIT_CREATOR_OVERLAY_SLOT, overlay); + } + overlay.set(type, creator); + return; + } + // Import-time / no-scope registration: app-agnostic, shared base. + loadUnitCreatorBaseMap.set(type, creator); } } diff --git a/tegg/core/metadata/src/impl/LoadUnitMultiInstanceProtoHook.ts b/tegg/core/metadata/src/impl/LoadUnitMultiInstanceProtoHook.ts index af27b9d8ff..4c815d1b29 100644 --- a/tegg/core/metadata/src/impl/LoadUnitMultiInstanceProtoHook.ts +++ b/tegg/core/metadata/src/impl/LoadUnitMultiInstanceProtoHook.ts @@ -1,8 +1,18 @@ import { PrototypeUtil } from '@eggjs/core-decorator'; +import { TeggScope } from '@eggjs/tegg-types'; import type { EggProtoImplClass, LifecycleHook, LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; +const MULTI_INSTANCE_CLAZZ_SET_SLOT = Symbol('tegg:metadata:multiInstanceClazzSet'); + export class LoadUnitMultiInstanceProtoHook implements LifecycleHook { - static multiInstanceClazzSet: Set = new Set(); + // Per-app set: closing one app must not clear another concurrent app's set. + static get multiInstanceClazzSet(): Set { + return TeggScope.resolve( + MULTI_INSTANCE_CLAZZ_SET_SLOT, + () => new Set(), + 'LoadUnitMultiInstanceProtoHook.multiInstanceClazzSet', + ); + } static setAllClassList(clazzList: readonly EggProtoImplClass[]): void { for (const clazz of clazzList) { diff --git a/tegg/core/metadata/src/model/EggPrototype.ts b/tegg/core/metadata/src/model/EggPrototype.ts index 4458bcfee8..41e09e39b3 100644 --- a/tegg/core/metadata/src/model/EggPrototype.ts +++ b/tegg/core/metadata/src/model/EggPrototype.ts @@ -1,4 +1,14 @@ -import { LifecycleUtil } from '@eggjs/lifecycle'; +import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; -export const EggPrototypeLifecycleUtil: LifecycleUtil = new LifecycleUtil(); +const EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:eggPrototypeLifecycleUtil'); + +/** + * Per-app prototype lifecycle util, backed by TeggScope. Hooks registered during + * one app's boot stay isolated to that app under concurrent multi-app. + */ +export const EggPrototypeLifecycleUtil: LifecycleUtil = + createScopedLifecycleUtil( + EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT, + 'EggPrototypeLifecycleUtil', + ); diff --git a/tegg/core/metadata/src/model/LoadUnit.ts b/tegg/core/metadata/src/model/LoadUnit.ts index aacbe45b59..5e7181e21c 100644 --- a/tegg/core/metadata/src/model/LoadUnit.ts +++ b/tegg/core/metadata/src/model/LoadUnit.ts @@ -1,4 +1,9 @@ -import { LifecycleUtil } from '@eggjs/lifecycle'; +import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; -export const LoadUnitLifecycleUtil: LifecycleUtil = new LifecycleUtil(); +const LOAD_UNIT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:loadUnitLifecycleUtil'); + +export const LoadUnitLifecycleUtil: LifecycleUtil = createScopedLifecycleUtil< + LoadUnitLifecycleContext, + LoadUnit +>(LOAD_UNIT_LIFECYCLE_UTIL_SLOT, 'LoadUnitLifecycleUtil'); diff --git a/tegg/core/metadata/src/model/graph/GlobalGraph.ts b/tegg/core/metadata/src/model/graph/GlobalGraph.ts index d4b3001a03..ffdef72f97 100644 --- a/tegg/core/metadata/src/model/graph/GlobalGraph.ts +++ b/tegg/core/metadata/src/model/graph/GlobalGraph.ts @@ -10,6 +10,7 @@ import { ObjectInitType, type ProtoDescriptor, type QualifierInfo, + TeggScope, } from '@eggjs/tegg-types'; import { EggPrototypeNotFound, MultiPrototypeFound } from '../../errors.ts'; @@ -20,6 +21,8 @@ import { ProtoDependencyMeta, ProtoNode } from './ProtoNode.ts'; const debug = debuglog('tegg/core/metadata/model/graph/GlobalGraph'); +const GLOBAL_GRAPH_SLOT = Symbol('tegg:metadata:globalGraph'); + export interface GlobalGraphOptions { // TODO next major version refactor to force strict // all proto should be load before build global graph @@ -67,9 +70,22 @@ export class GlobalGraph { private buildHooks: GlobalGraphBuildHook[]; /** - * The global instance used in ModuleLoadUnit + * The per-app graph instance used in ModuleLoadUnit, backed by TeggScope. + * In a scope, the active app's bag is the source of truth (undefined until the + * loader assigns it during boot); with no scope, falls back to a module-level + * legacy var (single-app / metadata-only paths). Call sites stay unchanged. */ - static instance?: GlobalGraph; + static #legacyInstance?: GlobalGraph; + + static get instance(): GlobalGraph | undefined { + return TeggScope.getOr(GLOBAL_GRAPH_SLOT, () => GlobalGraph.#legacyInstance, 'GlobalGraph.instance'); + } + + static set instance(value: GlobalGraph | undefined) { + if (!TeggScope.set(GLOBAL_GRAPH_SLOT, value)) { + GlobalGraph.#legacyInstance = value; + } + } constructor(options?: GlobalGraphOptions) { this.moduleGraph = new Graph(); diff --git a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap index 6b26262359..e0ab483cc9 100644 --- a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap @@ -16,7 +16,7 @@ exports[`should export stable 1`] = ` "EggPrototypeCreatorFactory": [Function], "EggPrototypeFactory": [Function], "EggPrototypeImpl": [Function], - "EggPrototypeLifecycleUtil": LifecycleUtil { + "EggPrototypeLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, @@ -31,7 +31,7 @@ exports[`should export stable 1`] = ` "GlobalModuleNodeBuilder": [Function], "IncompatibleProtoInject": [Function], "LoadUnitFactory": [Function], - "LoadUnitLifecycleUtil": LifecycleUtil { + "LoadUnitLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, diff --git a/tegg/core/runtime/src/factory/EggContainerFactory.ts b/tegg/core/runtime/src/factory/EggContainerFactory.ts index f465c6c452..5dc4d02d44 100644 --- a/tegg/core/runtime/src/factory/EggContainerFactory.ts +++ b/tegg/core/runtime/src/factory/EggContainerFactory.ts @@ -85,8 +85,14 @@ export class EggContainerFactory { name?: EggObjectName, qualifiers?: QualifierInfo[], ): Promise { - let proto = PrototypeUtil.getClazzProto(clazz as EggProtoImplClass) as EggPrototype | undefined; const isMultiInstance = PrototypeUtil.isEggMultiInstancePrototype(clazz as EggProtoImplClass); + // Prefer the CURRENT app's class→proto map over the global + // PrototypeUtil.getClazzProto(), which is shared across apps and overwritten + // by concurrent multi-app boot (last writer wins). + let proto: EggPrototype | undefined = isMultiInstance + ? undefined + : (EggPrototypeFactory.instance.getPrototypeByClazz(clazz as EggProtoImplClass) ?? + (PrototypeUtil.getClazzProto(clazz as EggProtoImplClass) as EggPrototype | undefined)); debug('getOrCreateEggObjectFromClazz:%o, isMultiInstance:%s, proto:%o', clazz.name, isMultiInstance, !!proto); if (isMultiInstance) { const defaultName = NameUtil.getClassName(clazz as EggProtoImplClass); diff --git a/tegg/core/runtime/src/factory/EggObjectFactory.ts b/tegg/core/runtime/src/factory/EggObjectFactory.ts index 953bdc7456..c6d5dce2c1 100644 --- a/tegg/core/runtime/src/factory/EggObjectFactory.ts +++ b/tegg/core/runtime/src/factory/EggObjectFactory.ts @@ -1,4 +1,5 @@ import { LoadUnitFactory } from '@eggjs/metadata'; +import { TeggScope } from '@eggjs/tegg-types'; import type { CreateObjectMethod, EggObject, @@ -17,8 +18,17 @@ interface EggObjectPair { ctx: EggObjectLifeCycleContext; } +const EGG_OBJECT_MAP_SLOT = Symbol('tegg:runtime:eggObjectMap'); + export class EggObjectFactory { - static eggObjectMap: Map = new Map(); + // The live egg-object registry (singletons + context objects) collides across + // apps (proto.id), so it is per-app, resolved from the active TeggScope bag. + static get eggObjectMap(): Map { + return TeggScope.resolve(EGG_OBJECT_MAP_SLOT, () => new Map(), 'EggObjectFactory.eggObjectMap'); + } + + // proto class -> create method is class-keyed and registered at import time + // (app-agnostic), so it is safe to keep process-global (shared). static eggObjectCreateMap: Map = new Map(); public static registerEggObjectCreateMethod(protoClass: EggPrototypeClass, method: CreateObjectMethod): void { diff --git a/tegg/core/runtime/src/factory/LoadUnitInstanceFactory.ts b/tegg/core/runtime/src/factory/LoadUnitInstanceFactory.ts index d9e74b1003..3873dc2f5f 100644 --- a/tegg/core/runtime/src/factory/LoadUnitInstanceFactory.ts +++ b/tegg/core/runtime/src/factory/LoadUnitInstanceFactory.ts @@ -1,5 +1,5 @@ import { IdenticalUtil } from '@eggjs/lifecycle'; -import { ObjectInitType } from '@eggjs/tegg-types'; +import { ObjectInitType, TeggScope } from '@eggjs/tegg-types'; import type { EggLoadUnitTypeLike, EggPrototype, @@ -17,9 +17,18 @@ interface LoadUnitInstancePair { ctx: LoadUnitInstanceLifecycleContext; } +const LOAD_UNIT_INSTANCE_MAP_SLOT = Symbol('tegg:runtime:loadUnitInstanceMap'); + export class LoadUnitInstanceFactory { + // type -> creator is class/type-keyed and registered at import/boot time with + // app-agnostic class refs, so it is safe to keep process-global (shared). private static creatorMap: Map = new Map(); - private static instanceMap: Map = new Map(); + + // The live load-unit instance registry collides across apps (name-based + // instanceId), so it is per-app, resolved from the active TeggScope bag. + private static get instanceMap(): Map { + return TeggScope.resolve(LOAD_UNIT_INSTANCE_MAP_SLOT, () => new Map(), 'LoadUnitInstanceFactory.instanceMap'); + } static registerLoadUnitInstanceClass(type: EggLoadUnitTypeLike, creator: LoadUnitInstanceCreator): void { this.creatorMap.set(type, creator); diff --git a/tegg/core/runtime/src/model/ContextHandler.ts b/tegg/core/runtime/src/model/ContextHandler.ts index c94653ff2d..9c248e5332 100644 --- a/tegg/core/runtime/src/model/ContextHandler.ts +++ b/tegg/core/runtime/src/model/ContextHandler.ts @@ -1,20 +1,53 @@ import assert from 'node:assert'; +import { TeggScope } from '@eggjs/tegg-types'; import type { EggRuntimeContext } from '@eggjs/tegg-types'; type runInContextCallback = (context: EggRuntimeContext, fn: () => Promise) => Promise; +interface ContextCallbacks { + getContextCallback?: () => EggRuntimeContext | undefined; + runInContextCallback?: runInContextCallback; +} + +const CONTEXT_CALLBACK_SLOT = Symbol('tegg:runtime:contextCallback'); + +/** + * The per-app request-context callbacks (read + run bridges). Each app installs + * its own (capturing its ctxStorage / currentContext) into its TeggScope bag, so + * concurrent apps no longer clobber a single process-global pair. With no active + * scope it resolves to the single process-default bag (single-app / tests). + */ +function callbacks(): ContextCallbacks { + return TeggScope.resolve(CONTEXT_CALLBACK_SLOT, () => ({}) as ContextCallbacks, 'ContextHandler.callbacks'); +} + export class ContextHandler { - static getContextCallback: () => EggRuntimeContext | undefined; - static runInContextCallback: runInContextCallback; + static get getContextCallback(): (() => EggRuntimeContext | undefined) | undefined { + return callbacks().getContextCallback; + } + + static set getContextCallback(cb: () => EggRuntimeContext | undefined) { + callbacks().getContextCallback = cb; + } + + static get runInContextCallback(): runInContextCallback | undefined { + return callbacks().runInContextCallback; + } + + static set runInContextCallback(cb: runInContextCallback) { + callbacks().runInContextCallback = cb; + } static getContext(): EggRuntimeContext | undefined { - assert(this.getContextCallback, 'getContextCallback not set'); - return this.getContextCallback ? this.getContextCallback() : undefined; + const cb = callbacks().getContextCallback; + assert(cb, 'getContextCallback not set'); + return cb ? cb() : undefined; } static run(context: EggRuntimeContext, fn: () => Promise): Promise { - assert(this.runInContextCallback, 'runInContextCallback not set'); - return this.runInContextCallback(context, fn); + const cb = callbacks().runInContextCallback; + assert(cb, 'runInContextCallback not set'); + return cb(context, fn); } } diff --git a/tegg/core/runtime/src/model/EggContext.ts b/tegg/core/runtime/src/model/EggContext.ts index 8378043738..90b4b98b79 100644 --- a/tegg/core/runtime/src/model/EggContext.ts +++ b/tegg/core/runtime/src/model/EggContext.ts @@ -1,5 +1,10 @@ -import { LifecycleUtil } from '@eggjs/lifecycle'; +import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; import type { EggRuntimeContext, EggContextLifecycleContext } from '@eggjs/tegg-types'; +const EGG_CONTEXT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggContextLifecycleUtil'); + export const EggContextLifecycleUtil: LifecycleUtil = - new LifecycleUtil(); + createScopedLifecycleUtil( + EGG_CONTEXT_LIFECYCLE_UTIL_SLOT, + 'EggContextLifecycleUtil', + ); diff --git a/tegg/core/runtime/src/model/EggObject.ts b/tegg/core/runtime/src/model/EggObject.ts index fb7bcbdb5a..1c7e7f282e 100644 --- a/tegg/core/runtime/src/model/EggObject.ts +++ b/tegg/core/runtime/src/model/EggObject.ts @@ -1,4 +1,9 @@ -import { LifecycleUtil } from '@eggjs/lifecycle'; +import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; import type { EggObject, EggObjectLifeCycleContext } from '@eggjs/tegg-types'; -export const EggObjectLifecycleUtil: LifecycleUtil = new LifecycleUtil(); +const EGG_OBJECT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggObjectLifecycleUtil'); + +export const EggObjectLifecycleUtil: LifecycleUtil = createScopedLifecycleUtil< + EggObjectLifeCycleContext, + EggObject +>(EGG_OBJECT_LIFECYCLE_UTIL_SLOT, 'EggObjectLifecycleUtil'); diff --git a/tegg/core/runtime/src/model/LoadUnitInstance.ts b/tegg/core/runtime/src/model/LoadUnitInstance.ts index f5700ac307..c5cc47771c 100644 --- a/tegg/core/runtime/src/model/LoadUnitInstance.ts +++ b/tegg/core/runtime/src/model/LoadUnitInstance.ts @@ -1,5 +1,10 @@ -import { LifecycleUtil } from '@eggjs/lifecycle'; +import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext } from '@eggjs/tegg-types'; +const LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:loadUnitInstanceLifecycleUtil'); + export const LoadUnitInstanceLifecycleUtil: LifecycleUtil = - new LifecycleUtil(); + createScopedLifecycleUtil( + LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT, + 'LoadUnitInstanceLifecycleUtil', + ); diff --git a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap index 42841eddee..1ad04c6be9 100644 --- a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap @@ -8,13 +8,13 @@ exports[`should export stable 1`] = ` "ContextObjectGraph": [Function], "EggAlwaysNewObjectContainer": [Function], "EggContainerFactory": [Function], - "EggContextLifecycleUtil": LifecycleUtil { + "EggContextLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, "EggObjectFactory": [Function], "EggObjectImpl": [Function], - "EggObjectLifecycleUtil": LifecycleUtil { + "EggObjectLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, @@ -27,7 +27,7 @@ exports[`should export stable 1`] = ` }, "EggObjectUtil": [Function], "LoadUnitInstanceFactory": [Function], - "LoadUnitInstanceLifecycleUtil": LifecycleUtil { + "LoadUnitInstanceLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, diff --git a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap index b0330fb63c..009a3c8e81 100644 --- a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap @@ -232,6 +232,7 @@ exports[`should export stable 1`] = ` "NAME": "NAME", }, }, + "createScopedLifecycleUtil": [Function], "orm": { "Attribute": [Function], "AttributeMeta": [Function], diff --git a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap index 57b72fda2e..10a4332c53 100644 --- a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap @@ -13,7 +13,7 @@ exports[`should helper exports stable 1`] = ` "ContextObjectGraph": [Function], "EggAlwaysNewObjectContainer": [Function], "EggContainerFactory": [Function], - "EggContextLifecycleUtil": LifecycleUtil { + "EggContextLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, @@ -24,7 +24,7 @@ exports[`should helper exports stable 1`] = ` }, "EggObjectFactory": [Function], "EggObjectImpl": [Function], - "EggObjectLifecycleUtil": LifecycleUtil { + "EggObjectLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, @@ -40,7 +40,7 @@ exports[`should helper exports stable 1`] = ` "EggPrototypeCreatorFactory": [Function], "EggPrototypeFactory": [Function], "EggPrototypeImpl": [Function], - "EggPrototypeLifecycleUtil": LifecycleUtil { + "EggPrototypeLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, @@ -60,11 +60,11 @@ exports[`should helper exports stable 1`] = ` "IncompatibleProtoInject": [Function], "LoadUnitFactory": [Function], "LoadUnitInstanceFactory": [Function], - "LoadUnitInstanceLifecycleUtil": LifecycleUtil { + "LoadUnitInstanceLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, - "LoadUnitLifecycleUtil": LifecycleUtil { + "LoadUnitLifecycleUtil": bound LifecycleUtil { "lifecycleSet": Set {}, "objLifecycleSet": Map {}, }, diff --git a/tegg/core/types/package.json b/tegg/core/types/package.json index f406a5b44c..d0467ff5b5 100644 --- a/tegg/core/types/package.json +++ b/tegg/core/types/package.json @@ -124,6 +124,8 @@ "./runtime/model/EggObject": "./src/runtime/model/EggObject.ts", "./runtime/model/LoadUnitInstance": "./src/runtime/model/LoadUnitInstance.ts", "./schedule": "./src/schedule.ts", + "./scope": "./src/scope/index.ts", + "./scope/TeggScope": "./src/scope/TeggScope.ts", "./transaction": "./src/transaction.ts", "./package.json": "./package.json" }, @@ -227,6 +229,8 @@ "./runtime/model/EggObject": "./dist/runtime/model/EggObject.js", "./runtime/model/LoadUnitInstance": "./dist/runtime/model/LoadUnitInstance.js", "./schedule": "./dist/schedule.js", + "./scope": "./dist/scope/index.js", + "./scope/TeggScope": "./dist/scope/TeggScope.js", "./transaction": "./dist/transaction.js", "./package.json": "./package.json" } diff --git a/tegg/core/types/src/index.ts b/tegg/core/types/src/index.ts index 55437468af..be206079c7 100644 --- a/tegg/core/types/src/index.ts +++ b/tegg/core/types/src/index.ts @@ -8,6 +8,7 @@ export * from './lifecycle/index.ts'; export * from './metadata/index.ts'; export * from './orm.ts'; export * from './runtime/index.ts'; +export * from './scope/index.ts'; export * from './schedule.ts'; export * from './transaction.ts'; export * from './agent-runtime/index.ts'; diff --git a/tegg/core/types/src/scope/TeggScope.ts b/tegg/core/types/src/scope/TeggScope.ts new file mode 100644 index 0000000000..c80aab324f --- /dev/null +++ b/tegg/core/types/src/scope/TeggScope.ts @@ -0,0 +1,191 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * The per-app scope "bag": a type-free container keyed by per-package symbols. + * + * Each tegg package defines its own slot {@link symbol} and its own concrete + * fallback, and reads/writes ONLY its own slot. No package imports another + * package's slot — the bag is the single shared, type-agnostic carrier. The + * aggregating "TeggRuntime" convenience that knows the concrete types lives in a + * higher layer (plugin/tegg, standalone Runner) where importing metadata + + * runtime is legal; it builds the bag and wraps boot/request in + * {@link TeggScope.run}. + */ +export type TeggScopeBag = Map; + +const als = new AsyncLocalStorage(); + +/** + * Number of explicitly-established app scopes currently alive in the process. + * Incremented by every tegg app/Runner at boot ({@link TeggScope.registerScope}) + * and decremented at close ({@link TeggScope.unregisterScope}). + * + * Drives the strict-mode escape fuse: while MORE THAN ONE app is alive (true + * multi-app), falling back to the process-default bag is treated as a scope + * escape bug. With a single app (or none) the silent lazy default is kept so + * existing single-app code and tests are unaffected. + */ +let explicitScopeCount = 0; + +/** + * Process-wide default bag, used when no ALS scope is active. In single-app mode + * this IS the app's effective storage (the lazy default). Created on demand so + * every fallback slot lands in the SAME bag and stays cross-consistent (e.g. a + * factory created in the default bag references the lifecycle util also in the + * default bag). Tests may install/reset it explicitly. + */ +let defaultBag: TeggScopeBag | undefined; + +function isProduction(): boolean { + // Follow the egg/tegg convention: prefer EGG_SERVER_ENV, then NODE_ENV. + const serverEnv = process.env.EGG_SERVER_ENV; + if (serverEnv) { + return serverEnv === 'prod'; + } + return process.env.NODE_ENV === 'production'; +} + +function reportEscape(desc: string): void { + const err = new Error( + `[tegg] TeggScope escaped to the process-default bag under multi-app mode (${desc}). ` + + 'A per-app scope was expected but none is active — the access likely happened ' + + 'outside TeggScope.run(app scope, ...). This would cross-talk between apps.', + ); + if (!isProduction()) { + throw err; + } + // Production: do not crash a running service; surface loudly instead. + // eslint-disable-next-line no-console + console.warn(err.stack); +} + +/** + * Type-free, lowest-level async scope container for tegg multi-app isolation. + * + * It owns ONLY the AsyncLocalStorage, the scope counter, and the strict-mode + * escape fuse. It knows nothing about concrete factories/managers — those are + * resolved by each package via {@link TeggScope.resolve} / {@link TeggScope.getOr} + * against its own slot. + */ +export class TeggScope { + /** The bag for the currently active async scope, if any. */ + static current(): TeggScopeBag | undefined { + return als.getStore(); + } + + /** Run `fn` with `bag` as the active scope. Synchronous or async `fn`. */ + static run(bag: TeggScopeBag, fn: () => R): R { + return als.run(bag, fn); + } + + /** Create a fresh, empty per-app bag. */ + static createBag(): TeggScopeBag { + return new Map(); + } + + /** Mark that an explicit per-app scope has been established (boot). */ + static registerScope(): void { + explicitScopeCount++; + } + + /** Mark that a previously-established per-app scope has been torn down (close). */ + static unregisterScope(): void { + if (explicitScopeCount > 0) { + explicitScopeCount--; + } + } + + /** True when more than one app scope is alive — genuine multi-app mode. */ + static get isMultiApp(): boolean { + return explicitScopeCount > 1; + } + + /** Number of live explicit app scopes. */ + static get scopeCount(): number { + return explicitScopeCount; + } + + /** + * Resolve a lazily-materialized per-app singleton (factory / manager / cache). + * + * - inside a scope: lazily create via `create()` and memoize in the active bag; + * - no scope: lazily create+memoize in the single process-default bag + * (single-app lazy default). Under multi-app, hitting the default bag is + * reported as an escape (dev throw / prod warn). + * + * `create()` may reference sibling slots' static facades (e.g. `X.instance`); + * because they resolve against the SAME bag, cross-slot dependencies stay + * consistent in both scoped and default modes. + */ + static resolve(slot: symbol, create: () => T, desc: string): T { + if (!als.getStore() && TeggScope.isMultiApp) { + reportEscape(desc); + } + return TeggScope.#getOrCreate(TeggScope.#activeBag(), slot, create); + } + + /** + * Read a "set later, may be undefined" slot (e.g. globalGraph, context + * callbacks). The active bag (scoped, or the single process-default bag) is the + * source of truth; `legacy()` only supplies the initial value before any set. + */ + static getOr(slot: symbol, legacy: () => T | undefined, desc: string): T | undefined { + if (!als.getStore() && TeggScope.isMultiApp) { + reportEscape(desc); + } + const bag = TeggScope.#activeBag(); + return bag.has(slot) ? (bag.get(slot) as T) : legacy(); + } + + /** + * Write a slot into the active bag (scoped, or the single process-default bag). + * Always succeeds; returns true for symmetry with earlier call sites. + */ + static set(slot: symbol, value: unknown): boolean { + TeggScope.#activeBag().set(slot, value); + return true; + } + + /** + * @internal Install an explicit process-default bag (test harnesses that run + * outside any {@link TeggScope.run}). Lets deprecated static accesses resolve + * to the same per-test bag. + */ + static _setDefaultBag(bag: TeggScopeBag | undefined): void { + defaultBag = bag; + } + + /** @internal Reset the process-default bag (e.g. between tests). */ + static _resetDefaultBag(): void { + defaultBag = undefined; + } + + /** @internal The current process-default bag, if any. */ + static _getDefaultBag(): TeggScopeBag | undefined { + return defaultBag; + } + + /** + * The active bag: the current ALS scope's bag, or the single lazily-created + * process-default bag when no scope is active. Routing every no-scope fallback + * (resolve / getOr / set / context callbacks) through ONE bag keeps all slots + * mutually consistent in single-app / test paths. + */ + static #activeBag(): TeggScopeBag { + const store = als.getStore(); + if (store) { + return store; + } + if (!defaultBag) { + defaultBag = new Map(); + } + return defaultBag; + } + + static #getOrCreate(bag: TeggScopeBag, slot: symbol, create: () => T): T { + if (!bag.has(slot)) { + bag.set(slot, create()); + } + return bag.get(slot) as T; + } +} diff --git a/tegg/core/types/src/scope/index.ts b/tegg/core/types/src/scope/index.ts new file mode 100644 index 0000000000..f813731abf --- /dev/null +++ b/tegg/core/types/src/scope/index.ts @@ -0,0 +1 @@ +export * from './TeggScope.ts'; diff --git a/tegg/core/types/test/__snapshots__/index.test.ts.snap b/tegg/core/types/test/__snapshots__/index.test.ts.snap index 3a01927c00..084833f765 100644 --- a/tegg/core/types/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/types/test/__snapshots__/index.test.ts.snap @@ -277,6 +277,7 @@ exports[`should export stable 1`] = ` "UPDATE": "UPDATE", }, "TRANSACTION_META_DATA": Symbol(EggPrototype#transaction#metaData), + "TeggScope": [Function], "Templates": { "BASE_DAO": "base_dao", "DAO": "dao", diff --git a/tegg/plugin/aop/package.json b/tegg/plugin/aop/package.json index ab894cbb45..50efaa2b84 100644 --- a/tegg/plugin/aop/package.json +++ b/tegg/plugin/aop/package.json @@ -52,7 +52,8 @@ "@eggjs/lifecycle": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/module-common": "workspace:*", - "@eggjs/tegg-runtime": "workspace:*" + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*" }, "devDependencies": { "@eggjs/mock": "workspace:*", diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index 51c33a42b7..a917da2b68 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -9,6 +9,7 @@ import { pointCutGraphHook, } from '@eggjs/aop-runtime'; import { GlobalGraph } from '@eggjs/metadata'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { AopContextHook } from './lib/AopContextHook.ts'; @@ -30,24 +31,30 @@ export default class AopAppHook implements ILifecycleBoot { } configDidLoad(): void { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); - this.app.eggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); + TeggScope.run(this.app._teggScopeBag, () => { + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); + this.app.eggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); + }); } async didLoad(): Promise { await this.app.moduleHandler.ready(); - assert(GlobalGraph.instance, 'GlobalGraph.instance is not set'); - GlobalGraph.instance.registerBuildHook(crossCutGraphHook); - GlobalGraph.instance.registerBuildHook(pointCutGraphHook); - this.aopContextHook = new AopContextHook(this.app.moduleHandler); - this.app.eggContextLifecycleUtil.registerLifecycle(this.aopContextHook); + await TeggScope.run(this.app._teggScopeBag, async () => { + assert(GlobalGraph.instance, 'GlobalGraph.instance is not set'); + GlobalGraph.instance.registerBuildHook(crossCutGraphHook); + GlobalGraph.instance.registerBuildHook(pointCutGraphHook); + this.aopContextHook = new AopContextHook(this.app.moduleHandler); + this.app.eggContextLifecycleUtil.registerLifecycle(this.aopContextHook); + }); } async beforeClose(): Promise { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eggPrototypeCrossCutHook); - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitAopHook); - this.app.eggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook); - this.app.eggContextLifecycleUtil.deleteLifecycle(this.aopContextHook); + await TeggScope.run(this.app._teggScopeBag, async () => { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eggPrototypeCrossCutHook); + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitAopHook); + this.app.eggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook); + this.app.eggContextLifecycleUtil.deleteLifecycle(this.aopContextHook); + }); } } diff --git a/tegg/plugin/controller/src/app.ts b/tegg/plugin/controller/src/app.ts index 837c46fdef..320755aba0 100644 --- a/tegg/plugin/controller/src/app.ts +++ b/tegg/plugin/controller/src/app.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { ControllerMetaBuilderFactory, ControllerType } from '@eggjs/controller-decorator'; import { GlobalGraph, type LoadUnitLifecycleContext } from '@eggjs/metadata'; import { type LoadUnitInstanceLifecycleContext, ModuleLoadUnitInstance } from '@eggjs/tegg-runtime'; -import { AGENT_CONTROLLER_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; +import { AGENT_CONTROLLER_PROTO_IMPL_TYPE, TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { AgentControllerObject } from './lib/AgentControllerObject.ts'; @@ -48,6 +48,14 @@ export default class ControllerAppBootHook implements ILifecycleBoot { } configWillLoad(): void { + // Controller boot registers lifecycle hooks and a per-app-capturing load-unit + // creator, all of which must land in this app's TeggScope. + TeggScope.run(this.app._teggScopeBag, () => { + this.doConfigWillLoad(); + }); + } + + private doConfigWillLoad(): void { this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitHook); this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.controllerPrototypeHook); this.app.eggObjectFactory.registerEggObjectCreateMethod(AgentControllerProto, AgentControllerObject.createObject); @@ -140,31 +148,40 @@ export default class ControllerAppBootHook implements ILifecycleBoot { async didLoad(): Promise { await this.app.moduleHandler.ready(); - this.controllerLoadUnitHandler = new ControllerLoadUnitHandler(this.app); - await this.controllerLoadUnitHandler.ready(); - - // The real register HTTP controller/method. - // HTTP method should sort by priority - // The HTTPControllerRegister will collect all the methods - // and register methods after collect is done. - HTTPControllerRegister.instance?.doRegister(this.app.rootProtoManager); - - this.app.config.mcp.hooks = MCPControllerRegister.hooks; + // ControllerLoadUnitHandler extends sdk-base (its ctor kicks off async _init + // that touches the per-app factories), and the HTTP/MCP registers below are + // per-app — run the whole flow inside this app's scope. + await TeggScope.run(this.app._teggScopeBag, async () => { + this.controllerLoadUnitHandler = new ControllerLoadUnitHandler(this.app); + await this.controllerLoadUnitHandler.ready(); + + // The real register HTTP controller/method. + // HTTP method should sort by priority + // The HTTPControllerRegister will collect all the methods + // and register methods after collect is done. + HTTPControllerRegister.instance?.doRegister(this.app.rootProtoManager); + + this.app.config.mcp.hooks = MCPControllerRegister.hooks; + }); } configDidLoad(): void { - GlobalGraph.instance?.registerBuildHook(middlewareGraphHook); + TeggScope.run(this.app._teggScopeBag, () => { + GlobalGraph.instance?.registerBuildHook(middlewareGraphHook); + }); } async willReady(): Promise { if (this.mcpEnable()) { - await MCPControllerRegister.connectStatelessStreamTransport(); - const names = MCPControllerRegister.instance?.mcpConfig.getMultipleServerNames(); - if (names && names.length > 0) { - for (const name of names) { - await MCPControllerRegister.connectStatelessStreamTransport(name); + await TeggScope.run(this.app._teggScopeBag, async () => { + await MCPControllerRegister.connectStatelessStreamTransport(); + const names = MCPControllerRegister.instance?.mcpConfig.getMultipleServerNames(); + if (names && names.length > 0) { + for (const name of names) { + await MCPControllerRegister.connectStatelessStreamTransport(name); + } } - } + }); } } @@ -173,13 +190,15 @@ export default class ControllerAppBootHook implements ILifecycleBoot { } async beforeClose(): Promise { - if (this.controllerLoadUnitHandler) { - await this.controllerLoadUnitHandler.destroy(); - } - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitHook); - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.controllerPrototypeHook); - ControllerMetadataManager.instance.clear(); - HTTPControllerRegister.clean(); - MCPControllerRegister.clean(); + await TeggScope.run(this.app._teggScopeBag, async () => { + if (this.controllerLoadUnitHandler) { + await this.controllerLoadUnitHandler.destroy(); + } + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitHook); + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.controllerPrototypeHook); + ControllerMetadataManager.instance.clear(); + HTTPControllerRegister.clean(); + MCPControllerRegister.clean(); + }); } } diff --git a/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts b/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts index 7b67e5e167..e74f0ca43a 100644 --- a/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts +++ b/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts @@ -1,10 +1,21 @@ import type { ControllerMetadata, ControllerTypeLike } from '@eggjs/controller-decorator'; import { MapUtil } from '@eggjs/tegg-common-util'; +import { TeggScope } from '@eggjs/tegg-types'; + +const CONTROLLER_METADATA_MANAGER_SLOT = Symbol('tegg:controller:controllerMetadataManager'); export class ControllerMetadataManager { private readonly controllers = new Map(); - static instance: ControllerMetadataManager = new ControllerMetadataManager(); + // Per-app: two apps loading the same controller class would collide on the + // duplicate-name check, so each app gets its own manager via TeggScope. + static get instance(): ControllerMetadataManager { + return TeggScope.resolve( + CONTROLLER_METADATA_MANAGER_SLOT, + () => new ControllerMetadataManager(), + 'ControllerMetadataManager.instance', + ); + } constructor() { this.controllers = new Map(); diff --git a/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts b/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts index ebb4019518..db8e485d65 100644 --- a/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts @@ -9,14 +9,33 @@ import { } from '@eggjs/controller-decorator'; import type { EggPrototype } from '@eggjs/metadata'; import { EggContainerFactory } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, Router } from 'egg'; import type { ControllerRegister } from '../../ControllerRegister.ts'; import { RootProtoManager } from '../../RootProtoManager.ts'; import { HTTPMethodRegister } from './HTTPMethodRegister.ts'; +const HTTP_CONTROLLER_REGISTER_SLOT = Symbol('tegg:controller:httpControllerRegister'); + export class HTTPControllerRegister implements ControllerRegister { - static instance?: HTTPControllerRegister; + // Per-app: the register accumulates protos and binds to one app's router, so + // it must be per-app (resolved from the active TeggScope bag). + static #legacyInstance?: HTTPControllerRegister; + + static get instance(): HTTPControllerRegister | undefined { + return TeggScope.getOr( + HTTP_CONTROLLER_REGISTER_SLOT, + () => HTTPControllerRegister.#legacyInstance, + 'HTTPControllerRegister.instance', + ); + } + + static set instance(value: HTTPControllerRegister | undefined) { + if (!TeggScope.set(HTTP_CONTROLLER_REGISTER_SLOT, value)) { + HTTPControllerRegister.#legacyInstance = value; + } + } private readonly router: Router; private readonly checkRouters: Map; diff --git a/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts b/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts index bef8dfeae3..25bc50f257 100644 --- a/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts @@ -15,6 +15,7 @@ import type { } from '@eggjs/tegg'; import { EggContainerFactory } from '@eggjs/tegg-runtime'; import type { EggObject } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; @@ -32,6 +33,9 @@ import type { ControllerRegister } from '../../ControllerRegister.ts'; import { MCPConfig } from './MCPConfig.ts'; import { MCPServerHelper } from './MCPServerHelper.ts'; +const MCP_CONTROLLER_REGISTER_SLOT = Symbol('tegg:controller:mcpControllerRegister'); +const MCP_HOOKS_SLOT = Symbol('tegg:controller:mcpControllerHooks'); + export interface MCPControllerHook { // SSE preSSEInitHandle?: (ctx: Context, transport: SSEServerTransport, register: MCPControllerRegister) => Promise; @@ -67,6 +71,10 @@ interface ServerRegisterRecord { } class InnerSSEServerTransport extends SSEServerTransport { + // Capture the owning per-app register so send() (driven by the MCP SDK, often + // outside any ALS frame) resolves the correct app's request map directly. + register?: MCPControllerRegister; + async send(message: JSONRPCMessage): Promise { let err: null | Error = null; try { @@ -74,7 +82,7 @@ class InnerSSEServerTransport extends SSEServerTransport { } catch (e) { err = e as Error; } finally { - const map = MCPControllerRegister.instance?.sseTransportsRequestMap.get(this); + const map = this.register?.sseTransportsRequestMap.get(this); if (map && 'id' in message) { const { resolve, reject } = map[message.id!] ?? {}; if (resolve) { @@ -87,7 +95,24 @@ class InnerSSEServerTransport extends SSEServerTransport { } export class MCPControllerRegister implements ControllerRegister { - static instance?: MCPControllerRegister; + // Per-app: holds this app's MCP transports/servers/timers, so it is resolved + // from the active TeggScope bag rather than a process-global singleton. + static #legacyInstance?: MCPControllerRegister; + + static get instance(): MCPControllerRegister | undefined { + return TeggScope.getOr( + MCP_CONTROLLER_REGISTER_SLOT, + () => MCPControllerRegister.#legacyInstance, + 'MCPControllerRegister.instance', + ); + } + + static set instance(value: MCPControllerRegister | undefined) { + if (!TeggScope.set(MCP_CONTROLLER_REGISTER_SLOT, value)) { + MCPControllerRegister.#legacyInstance = value; + } + } + readonly app: Application; readonly eggContainerFactory: typeof EggContainerFactory; private readonly router: Router; @@ -111,7 +136,12 @@ export class MCPControllerRegister implements ControllerRegister { > > = new Map(); - static hooks: MCPControllerHook[] = []; + // Per-app hook list (mcp-proxy registers its hook here at agent boot). Each app + // gets its own list so concurrent apps do not accumulate each other's hooks. + static get hooks(): MCPControllerHook[] { + return TeggScope.resolve(MCP_HOOKS_SLOT, () => [], 'MCPControllerRegister.hooks'); + } + globalMiddlewares: compose.ComposedMiddleware; registerMap: Record< @@ -403,6 +433,7 @@ export class MCPControllerRegister implements ControllerRegister { const self = this; const initHandler = async (ctx: Context) => { const transport = new InnerSSEServerTransport(self.mcpConfig.getSseMessagePath(name), ctx.res); + transport.register = self; const id = transport.sessionId; if (MCPControllerRegister.hooks.length > 0) { for (const hook of MCPControllerRegister.hooks) { diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index a5b8c81e30..5ce4243c75 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -1,3 +1,4 @@ +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { DalModuleLoadUnitHook } from './lib/DalModuleLoadUnitHook.ts'; @@ -21,23 +22,27 @@ export default class DalAppBootHook implements ILifecycleBoot { this.dalModuleLoadUnitHook = new DalModuleLoadUnitHook(this.app.config.env, this.app.moduleConfigs); this.dalTableEggPrototypeHook = new DalTableEggPrototypeHook(this.app.logger); this.transactionPrototypeHook = new TransactionPrototypeHook(this.app.moduleConfigs, this.app.logger); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.dalTableEggPrototypeHook); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.transactionPrototypeHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); + TeggScope.run(this.app._teggScopeBag, () => { + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.dalTableEggPrototypeHook); + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.transactionPrototypeHook); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); + }); } async beforeClose(): Promise { - if (this.dalTableEggPrototypeHook) { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.dalTableEggPrototypeHook); - } - if (this.dalModuleLoadUnitHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.dalModuleLoadUnitHook); - } - if (this.transactionPrototypeHook) { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.transactionPrototypeHook); - } - MysqlDataSourceManager.instance.clear(); - SqlMapManager.instance.clear(); - TableModelManager.instance.clear(); + await TeggScope.run(this.app._teggScopeBag, async () => { + if (this.dalTableEggPrototypeHook) { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.dalTableEggPrototypeHook); + } + if (this.dalModuleLoadUnitHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.dalModuleLoadUnitHook); + } + if (this.transactionPrototypeHook) { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.transactionPrototypeHook); + } + MysqlDataSourceManager.instance.clear(); + SqlMapManager.instance.clear(); + TableModelManager.instance.clear(); + }); } } diff --git a/tegg/plugin/dal/src/app/extend/application.ts b/tegg/plugin/dal/src/app/extend/application.ts index 116d931227..3c3b4d90c2 100644 --- a/tegg/plugin/dal/src/app/extend/application.ts +++ b/tegg/plugin/dal/src/app/extend/application.ts @@ -1,7 +1,13 @@ +import { TeggScope } from '@eggjs/tegg-types'; +import type { Application } from 'egg'; + import { MysqlDataSourceManager } from '../../lib/MysqlDataSourceManager.ts'; export default { + // Pin to THIS app's scope so `app.mysqlDataSourceManager` returns the app's + // per-app manager even when accessed outside a request/boot scope. get mysqlDataSourceManager(): MysqlDataSourceManager { - return MysqlDataSourceManager.instance; + const app = this as unknown as Application; + return TeggScope.run(app._teggScopeBag, () => MysqlDataSourceManager.instance); }, }; diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts index 9454bd23ef..44786827e4 100644 --- a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts +++ b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts @@ -1,9 +1,21 @@ import crypto from 'node:crypto'; import { type DataSourceOptions, MysqlDataSource } from '@eggjs/dal-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; + +const MYSQL_DATA_SOURCE_MANAGER_SLOT = Symbol('tegg:dal:mysqlDataSourceManager'); export class MysqlDataSourceManager { - static instance: MysqlDataSourceManager = new MysqlDataSourceManager(); + // Per-app: holds live MysqlDataSource connections keyed by config hash. Made + // per-app so two apps with identical DB config no longer share one connection + // object (and each app's teardown only disposes its own). + static get instance(): MysqlDataSourceManager { + return TeggScope.resolve( + MYSQL_DATA_SOURCE_MANAGER_SLOT, + () => new MysqlDataSourceManager(), + 'MysqlDataSourceManager.instance', + ); + } private readonly dataSourceIndices: Map< string /* moduleName */, diff --git a/tegg/plugin/dal/src/lib/SqlMapManager.ts b/tegg/plugin/dal/src/lib/SqlMapManager.ts index 665fbc13af..14d9825624 100644 --- a/tegg/plugin/dal/src/lib/SqlMapManager.ts +++ b/tegg/plugin/dal/src/lib/SqlMapManager.ts @@ -1,7 +1,13 @@ import type { TableSqlMap } from '@eggjs/dal-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; + +const SQL_MAP_MANAGER_SLOT = Symbol('tegg:dal:sqlMapManager'); export class SqlMapManager { - static instance: SqlMapManager = new SqlMapManager(); + // Per-app: keyed by module name (collides across apps); resolved from scope. + static get instance(): SqlMapManager { + return TeggScope.resolve(SQL_MAP_MANAGER_SLOT, () => new SqlMapManager(), 'SqlMapManager.instance'); + } private sqlMaps: Map>; diff --git a/tegg/plugin/dal/src/lib/TableModelManager.ts b/tegg/plugin/dal/src/lib/TableModelManager.ts index 519cc1333a..af516b252f 100644 --- a/tegg/plugin/dal/src/lib/TableModelManager.ts +++ b/tegg/plugin/dal/src/lib/TableModelManager.ts @@ -1,7 +1,14 @@ import type { TableModel } from '@eggjs/dal-decorator'; +import { TeggScope } from '@eggjs/tegg-types'; + +const TABLE_MODEL_MANAGER_SLOT = Symbol('tegg:dal:tableModelManager'); export class TableModelManager { - static instance: TableModelManager = new TableModelManager(); + // Per-app: keyed by module name, which collides across apps; resolved from the + // active TeggScope bag so two apps never share table-model registrations. + static get instance(): TableModelManager { + return TeggScope.resolve(TABLE_MODEL_MANAGER_SLOT, () => new TableModelManager(), 'TableModelManager.instance'); + } private tableModels: Map>; diff --git a/tegg/plugin/dal/test/transaction.test.ts b/tegg/plugin/dal/test/transaction.test.ts index 6ca66610b0..5523006737 100644 --- a/tegg/plugin/dal/test/transaction.test.ts +++ b/tegg/plugin/dal/test/transaction.test.ts @@ -1,7 +1,6 @@ import { mm, type MockApplication } from '@eggjs/mock'; import { describe, afterEach, beforeAll, afterAll, it, expect } from 'vitest'; -import { MysqlDataSourceManager } from '../src/lib/MysqlDataSourceManager.ts'; import FooDAO from './fixtures/apps/dal-app/modules/dal/dal/dao/FooDAO.ts'; import { FooService } from './fixtures/apps/dal-app/modules/dal/FooService.ts'; import { getFixtures } from './utils.ts'; @@ -21,7 +20,7 @@ describe('plugin/dal/test/transaction.test.ts', () => { }); afterEach(async () => { - const dataSource = MysqlDataSourceManager.instance.get('dal', 'foo')!; + const dataSource = app.mysqlDataSourceManager.get('dal', 'foo')!; await dataSource.query('delete from egg_foo;'); }); diff --git a/tegg/plugin/eventbus/package.json b/tegg/plugin/eventbus/package.json index 1ea5986241..4f31cca93a 100644 --- a/tegg/plugin/eventbus/package.json +++ b/tegg/plugin/eventbus/package.json @@ -64,7 +64,8 @@ "@eggjs/lifecycle": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/module-common": "workspace:*", - "@eggjs/tegg-runtime": "workspace:*" + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*" }, "devDependencies": { "@eggjs/mock": "workspace:*", diff --git a/tegg/plugin/eventbus/src/app.ts b/tegg/plugin/eventbus/src/app.ts index 1b6f7ec7b3..cacbb0f5b6 100644 --- a/tegg/plugin/eventbus/src/app.ts +++ b/tegg/plugin/eventbus/src/app.ts @@ -1,3 +1,4 @@ +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { EventbusLoadUnitHook } from './lib/EventbusLoadUnitHook.ts'; @@ -18,17 +19,25 @@ export default class EventbusAppHook implements ILifecycleBoot { } configDidLoad(): void { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eventbusProtoHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.eventbusLoadUnitHook); + TeggScope.run(this.app._teggScopeBag, () => { + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eventbusProtoHook); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.eventbusLoadUnitHook); + }); } async didLoad(): Promise { await this.app.moduleHandler.ready(); - await this.eventHandlerProtoManager.register(); + // register() resolves the per-app EventHandler/EventContext singletons and + // installs the per-app context creator — must run in this app's scope. + await TeggScope.run(this.app._teggScopeBag, async () => { + await this.eventHandlerProtoManager.register(); + }); } async beforeClose(): Promise { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eventbusProtoHook); - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eventbusLoadUnitHook); + await TeggScope.run(this.app._teggScopeBag, async () => { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eventbusProtoHook); + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eventbusLoadUnitHook); + }); } } diff --git a/tegg/plugin/langchain/src/app.ts b/tegg/plugin/langchain/src/app.ts index c2d06493a8..da3d849d80 100644 --- a/tegg/plugin/langchain/src/app.ts +++ b/tegg/plugin/langchain/src/app.ts @@ -1,3 +1,4 @@ +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, IBoot } from 'egg'; import { BoundModelObjectHook } from './lib/boundModel/BoundModelObjectHook.ts'; @@ -21,17 +22,22 @@ export default class ModuleLangChainHook implements IBoot { this.#graphLoadUnitHook = new GraphLoadUnitHook(this.#app.eggPrototypeFactory as any); this.#boundModelObjectHook = new BoundModelObjectHook(); this.#graphPrototypeHook = new GraphPrototypeHook(); - this.#app.loadUnitLifecycleUtil.registerLifecycle(this.#graphLoadUnitHook); + // NOTE: graphLoadUnitHook registration moved to configWillLoad — the per-app + // TeggScope bag does not exist yet in the boot constructor. } configWillLoad(): void { - this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#graphObjectHook); - this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#boundModelObjectHook); - this.#app.eggObjectFactory.registerEggObjectCreateMethod( - CompiledStateGraphProto as any, - CompiledStateGraphObject.createObject, - ); - this.#app.eggPrototypeLifecycleUtil.registerLifecycle(this.#graphPrototypeHook); + // Lifecycle-util registrations must land in THIS app's scope. + TeggScope.run(this.#app._teggScopeBag, () => { + this.#app.loadUnitLifecycleUtil.registerLifecycle(this.#graphLoadUnitHook); + this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#graphObjectHook); + this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#boundModelObjectHook); + this.#app.eggObjectFactory.registerEggObjectCreateMethod( + CompiledStateGraphProto as any, + CompiledStateGraphObject.createObject, + ); + this.#app.eggPrototypeLifecycleUtil.registerLifecycle(this.#graphPrototypeHook); + }); } configDidLoad(): void { @@ -39,9 +45,11 @@ export default class ModuleLangChainHook implements IBoot { } async beforeClose(): Promise { - this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#graphObjectHook); - this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#boundModelObjectHook); - this.#app.loadUnitLifecycleUtil.deleteLifecycle(this.#graphLoadUnitHook); - this.#app.eggPrototypeLifecycleUtil.deleteLifecycle(this.#graphPrototypeHook); + await TeggScope.run(this.#app._teggScopeBag, async () => { + this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#graphObjectHook); + this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#boundModelObjectHook); + this.#app.loadUnitLifecycleUtil.deleteLifecycle(this.#graphLoadUnitHook); + this.#app.eggPrototypeLifecycleUtil.deleteLifecycle(this.#graphPrototypeHook); + }); } } diff --git a/tegg/plugin/mcp-proxy/src/app.ts b/tegg/plugin/mcp-proxy/src/app.ts index 653b334ee7..989a5a9686 100644 --- a/tegg/plugin/mcp-proxy/src/app.ts +++ b/tegg/plugin/mcp-proxy/src/app.ts @@ -1,4 +1,5 @@ import { MCPControllerRegister } from '@eggjs/controller-plugin/lib/impl/mcp/MCPControllerRegister'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application } from 'egg'; import { MCPProxyHook } from './index.ts'; @@ -11,7 +12,10 @@ export default class AppHook { } configWillLoad(): void { - MCPControllerRegister.addHook(MCPProxyHook); + // hooks is per-app (scope-backed); register into this app's scope. + TeggScope.run(this.agent._teggScopeBag, () => { + MCPControllerRegister.addHook(MCPProxyHook); + }); } async didLoad(): Promise { diff --git a/tegg/plugin/orm/package.json b/tegg/plugin/orm/package.json index 09e9b52b3a..51791ba186 100644 --- a/tegg/plugin/orm/package.json +++ b/tegg/plugin/orm/package.json @@ -73,6 +73,7 @@ "@eggjs/module-common": "workspace:*", "@eggjs/orm-decorator": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*", "leoric": "catalog:", "sdk-base": "catalog:" }, diff --git a/tegg/plugin/orm/src/app.ts b/tegg/plugin/orm/src/app.ts index ce3caf3d05..dc9d5c3542 100644 --- a/tegg/plugin/orm/src/app.ts +++ b/tegg/plugin/orm/src/app.ts @@ -1,4 +1,5 @@ import { MODEL_PROTO_IMPL_TYPE } from '@eggjs/orm-decorator'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { DataSourceManager } from './lib/DataSourceManager.ts'; @@ -32,9 +33,12 @@ export default class OrmAppBootHook implements ILifecycleBoot { } configWillLoad(): void { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.modelProtoHook); - this.app.eggObjectFactory.registerEggObjectCreateMethod(SingletonModelProto, SingletonModelObject.createObject); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.ormLoadUnitHook); + // Lifecycle-util registrations must land in THIS app's scope. + TeggScope.run(this.app._teggScopeBag, () => { + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.modelProtoHook); + this.app.eggObjectFactory.registerEggObjectCreateMethod(SingletonModelProto, SingletonModelObject.createObject); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.ormLoadUnitHook); + }); } configDidLoad(): void { @@ -50,10 +54,14 @@ export default class OrmAppBootHook implements ILifecycleBoot { async didLoad(): Promise { await this.app.moduleHandler.ready(); - await this.leoricRegister.register(); + await TeggScope.run(this.app._teggScopeBag, async () => { + await this.leoricRegister.register(); + }); } async beforeClose(): Promise { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.modelProtoHook); + await TeggScope.run(this.app._teggScopeBag, async () => { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.modelProtoHook); + }); } } diff --git a/tegg/plugin/schedule/package.json b/tegg/plugin/schedule/package.json index 533e7af473..a60ae1b483 100644 --- a/tegg/plugin/schedule/package.json +++ b/tegg/plugin/schedule/package.json @@ -68,6 +68,7 @@ "@eggjs/schedule-decorator": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*", "@eggjs/utils": "workspace:*" }, "devDependencies": { diff --git a/tegg/plugin/schedule/src/app.ts b/tegg/plugin/schedule/src/app.ts index 0a79cc9c40..10ddf34aea 100644 --- a/tegg/plugin/schedule/src/app.ts +++ b/tegg/plugin/schedule/src/app.ts @@ -1,3 +1,4 @@ +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { ScheduleManager } from './lib/ScheduleManager.ts'; @@ -21,15 +22,19 @@ export default class ScheduleAppBootHook implements ILifecycleBoot { } configWillLoad(): void { - this.app.loadUnitLifecycleUtil.registerLifecycle(this.scheduleWorkerLoadUnitHook); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.schedulePrototypeHook); + TeggScope.run(this.app._teggScopeBag, () => { + this.app.loadUnitLifecycleUtil.registerLifecycle(this.scheduleWorkerLoadUnitHook); + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.schedulePrototypeHook); + }); } async beforeClose(): Promise { // Unregister all schedules before deleting lifecycle hooks this.scheduleManager.unregisterAll(); - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.scheduleWorkerLoadUnitHook); - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.schedulePrototypeHook); + await TeggScope.run(this.app._teggScopeBag, async () => { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.scheduleWorkerLoadUnitHook); + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.schedulePrototypeHook); + }); } } diff --git a/tegg/plugin/tegg/package.json b/tegg/plugin/tegg/package.json index 8e885f6bda..2504406231 100644 --- a/tegg/plugin/tegg/package.json +++ b/tegg/plugin/tegg/package.json @@ -102,6 +102,8 @@ }, "devDependencies": { "@eggjs/core": "workspace:*", + "@eggjs/eventbus-decorator": "workspace:*", + "@eggjs/eventbus-plugin": "workspace:*", "@eggjs/mock": "workspace:*", "@eggjs/tegg": "workspace:*", "@eggjs/tegg-config": "workspace:*", diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 1e5a76f1f3..02d027d447 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -4,6 +4,7 @@ import './lib/AppLoadUnitInstance.ts'; import './lib/EggCompatibleObject.ts'; import { LoadUnitMultiInstanceProtoHook } from '@eggjs/metadata'; import { LoaderFactory } from '@eggjs/tegg-loader'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { CompatibleUtil } from './lib/CompatibleUtil.ts'; @@ -28,32 +29,44 @@ export default class TeggAppBoot implements ILifecycleBoot { } configWillLoad(): void { + // Establish this app's TeggScope BEFORE any dependent plugin + // (controller/aop/dal/eventbus) boots, so their boot hooks can resolve the + // per-app factories/managers from app._teggScopeBag. + this.app._teggScopeBag = TeggScope.createBag(); + TeggScope.registerScope(); this.app.config.coreMiddleware.push('teggCtxLifecycleMiddleware'); } configDidLoad(): void { this.eggContextHandler = new EggContextHandler(this.app); this.app.eggContextHandler = this.eggContextHandler; - this.eggContextHandler.register(); + // register() installs the per-app context callbacks into this app's scope. + TeggScope.run(this.app._teggScopeBag, () => { + this.eggContextHandler.register(); + }); this.app.moduleHandler = new ModuleHandler(this.app); } async didLoad(): Promise { hijackRunInBackground(this.app); - this.loadUnitMultiInstanceProtoHook = new LoadUnitMultiInstanceProtoHook(); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitMultiInstanceProtoHook); + // Load tegg objects within this app's factory scope so every factory/graph/ + // lifecycle-util mutation during boot reads/writes the per-app slots. + await TeggScope.run(this.app._teggScopeBag, async () => { + this.loadUnitMultiInstanceProtoHook = new LoadUnitMultiInstanceProtoHook(); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitMultiInstanceProtoHook); - // wait all file loaded, so app/ctx has all properties - this.eggQualifierProtoHook = new EggQualifierProtoHook(this.app); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.eggQualifierProtoHook); + // wait all file loaded, so app/ctx has all properties + this.eggQualifierProtoHook = new EggQualifierProtoHook(this.app); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.eggQualifierProtoHook); - this.configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.configSourceEggPrototypeHook); + this.configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.configSourceEggPrototypeHook); - // start load tegg objects - await this.app.moduleHandler.init(); - this.compatibleHook = new EggContextCompatibleHook(this.app.moduleHandler); - this.app.eggContextLifecycleUtil.registerLifecycle(this.compatibleHook); + // start load tegg objects + await this.app.moduleHandler.init(); + this.compatibleHook = new EggContextCompatibleHook(this.app.moduleHandler); + this.app.eggContextLifecycleUtil.registerLifecycle(this.compatibleHook); + }); } async loadMetadata(): Promise { @@ -63,20 +76,26 @@ export default class TeggAppBoot implements ILifecycleBoot { } async beforeClose(): Promise { - CompatibleUtil.clean(); - await this.app.moduleHandler.destroy(); - if (this.compatibleHook) { - this.app.eggContextLifecycleUtil.deleteLifecycle(this.compatibleHook); - } - if (this.eggQualifierProtoHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eggQualifierProtoHook); - } - if (this.configSourceEggPrototypeHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.configSourceEggPrototypeHook); - } - if (this.loadUnitMultiInstanceProtoHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook); - } - LoadUnitMultiInstanceProtoHook.clear(); + await TeggScope.run(this.app._teggScopeBag, async () => { + CompatibleUtil.clean(); + await this.app.moduleHandler.destroy(); + if (this.compatibleHook) { + this.app.eggContextLifecycleUtil.deleteLifecycle(this.compatibleHook); + } + if (this.eggQualifierProtoHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eggQualifierProtoHook); + } + if (this.configSourceEggPrototypeHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.configSourceEggPrototypeHook); + } + if (this.loadUnitMultiInstanceProtoHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook); + } + // per-app multi-instance proto set: cleared within this app's scope + LoadUnitMultiInstanceProtoHook.clear(); + }); + // The whole per-app scope (bag) is dropped with the app; release the scope + // counter so the strict-mode escape fuse reflects the live app count. + TeggScope.unregisterScope(); } } diff --git a/tegg/plugin/tegg/src/app/extend/application.ts b/tegg/plugin/tegg/src/app/extend/application.ts index 67a9d47a70..49a6c1c89c 100644 --- a/tegg/plugin/tegg/src/app/extend/application.ts +++ b/tegg/plugin/tegg/src/app/extend/application.ts @@ -18,6 +18,7 @@ import { LoadUnitInstanceLifecycleUtil, } from '@eggjs/tegg-runtime'; import type { RuntimeConfig } from '@eggjs/tegg-types'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application } from 'egg'; export default class TEggPluginApplication { @@ -98,19 +99,27 @@ export default class TEggPluginApplication { if (qualifiers) { qualifiers = Array.isArray(qualifiers) ? qualifiers : [qualifiers]; } - const eggObject = await EggContainerFactory.getOrCreateEggObjectFromClazz( - clazz as EggProtoImplClass, - name, - qualifiers as QualifierInfo[], - ); - return eggObject.obj as T; + const bag = (this as unknown as Application)._teggScopeBag; + const doWork = async (): Promise => { + const eggObject = await EggContainerFactory.getOrCreateEggObjectFromClazz( + clazz as EggProtoImplClass, + name, + qualifiers as QualifierInfo[], + ); + return eggObject.obj as T; + }; + return bag ? TeggScope.run(bag, doWork) : doWork(); } async getEggObjectFromName(name: string, qualifiers?: QualifierInfo | QualifierInfo[]): Promise { if (qualifiers) { qualifiers = Array.isArray(qualifiers) ? qualifiers : [qualifiers]; } - const eggObject = await EggContainerFactory.getOrCreateEggObjectFromName(name, qualifiers as QualifierInfo[]); - return eggObject.obj as T; + const bag = (this as unknown as Application)._teggScopeBag; + const doWork = async (): Promise => { + const eggObject = await EggContainerFactory.getOrCreateEggObjectFromName(name, qualifiers as QualifierInfo[]); + return eggObject.obj as T; + }; + return bag ? TeggScope.run(bag, doWork) : doWork(); } } diff --git a/tegg/plugin/tegg/src/app/extend/application.unittest.ts b/tegg/plugin/tegg/src/app/extend/application.unittest.ts index 94ffd36a60..07ab211300 100644 --- a/tegg/plugin/tegg/src/app/extend/application.unittest.ts +++ b/tegg/plugin/tegg/src/app/extend/application.unittest.ts @@ -1,4 +1,5 @@ import type { EggContext, EggContextLifecycleContext } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Context, Application } from 'egg'; import { EggContextImpl } from '../../lib/EggContextImpl.ts'; @@ -13,16 +14,19 @@ export default class TEggPluginApplicationUnittest { if (hasMockModuleContext) { throw new Error('should not call mockModuleContext twice.'); } - // @ts-expect-error mockContext is not typed - const ctx = this.mockContext(data) as Context; - const teggCtx = new EggContextImpl(ctx); - const lifecycle = {}; - TEGG_LIFECYCLE_CACHE.set(teggCtx, lifecycle); - if (teggCtx.init) { - await teggCtx.init(lifecycle); - } - hasMockModuleContext = true; - return ctx; + const doWork = async (): Promise => { + // @ts-expect-error mockContext is not typed + const ctx = this.mockContext(data) as Context; + const teggCtx = new EggContextImpl(ctx); + const lifecycle = {}; + TEGG_LIFECYCLE_CACHE.set(teggCtx, lifecycle); + if (teggCtx.init) { + await teggCtx.init(lifecycle); + } + hasMockModuleContext = true; + return ctx; + }; + return this._teggScopeBag ? TeggScope.run(this._teggScopeBag, doWork) : doWork(); } async destroyModuleContext(this: Application, ctx: Context): Promise { @@ -33,29 +37,37 @@ export default class TEggPluginApplicationUnittest { return; } const lifecycle = TEGG_LIFECYCLE_CACHE.get(teggCtx); - if (teggCtx.destroy && lifecycle) { - await teggCtx.destroy(lifecycle); - } + const doWork = async (): Promise => { + if (teggCtx.destroy && lifecycle) { + await teggCtx.destroy(lifecycle); + } + }; + return this._teggScopeBag ? TeggScope.run(this._teggScopeBag, doWork) : doWork(); } - async mockModuleContextScope(fn: (ctx: Context) => Promise, data?: any): Promise { + async mockModuleContextScope(this: Application, fn: (ctx: Context) => Promise, data?: any): Promise { if (hasMockModuleContext) { throw new Error( 'mockModuleContextScope can not use with mockModuleContext, should use mockModuleContextScope only.', ); } - // @ts-expect-error mockContextScope only exists in MockApplication - return this.mockContextScope(async (ctx: Context) => { - const teggCtx = new EggContextImpl(ctx); - const lifecycle = {}; - if (teggCtx.init) { - await teggCtx.init(lifecycle); - } - try { - return await fn(ctx); - } finally { - await teggCtx.destroy(lifecycle); - } - }, data); + const doWork = (): Promise => { + // @ts-expect-error mockContextScope only exists in MockApplication + return this.mockContextScope(async (ctx: Context) => { + const teggCtx = new EggContextImpl(ctx); + const lifecycle = {}; + if (teggCtx.init) { + await teggCtx.init(lifecycle); + } + try { + return await fn(ctx); + } finally { + await teggCtx.destroy(lifecycle); + } + }, data); + }; + // Run within this app's scope so app.module/ctx.module proxy resolution and + // getEggObject read the correct per-app factories. + return this._teggScopeBag ? TeggScope.run(this._teggScopeBag, doWork) : doWork(); } } diff --git a/tegg/plugin/tegg/src/app/extend/context.ts b/tegg/plugin/tegg/src/app/extend/context.ts index 023d5bc7dd..6c6fe42780 100644 --- a/tegg/plugin/tegg/src/app/extend/context.ts +++ b/tegg/plugin/tegg/src/app/extend/context.ts @@ -1,7 +1,7 @@ -import { type EggProtoImplClass, PrototypeUtil, type QualifierInfo } from '@eggjs/core-decorator'; -import type { EggPrototype } from '@eggjs/metadata'; +import { type EggProtoImplClass, type QualifierInfo } from '@eggjs/core-decorator'; import { TEGG_CONTEXT } from '@eggjs/module-common'; import type { EggContext as TEggContext } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Context } from 'egg'; import { ctxLifecycleMiddleware } from '../../lib/ctx_lifecycle_middleware.ts'; @@ -22,23 +22,24 @@ export default class TEggPluginContext { } async getEggObject(this: Context, clazz: EggProtoImplClass, name?: string): Promise { - const protoObj = PrototypeUtil.getClazzProto(clazz as EggProtoImplClass); - if (!protoObj) { - throw new Error(`can not get proto for clazz ${clazz.name}`); - } - const proto = protoObj as EggPrototype; - const eggObject = await this.app.eggContainerFactory.getOrCreateEggObject(proto, name ?? proto.name); - return eggObject.obj as T; + const app = this.app; + // Run within this app's scope so proto resolution uses the per-app class→proto + // map (multi-app safe) and ContextHandler/factories resolve the right app — + // even when called outside a request (e.g. the tegg-vitest runner). + return TeggScope.run(app._teggScopeBag, async () => { + const eggObject = await app.eggContainerFactory.getOrCreateEggObjectFromClazz(clazz as EggProtoImplClass, name); + return eggObject.obj as T; + }); } async getEggObjectFromName(this: Context, name: string, qualifiers?: QualifierInfo | QualifierInfo[]): Promise { if (qualifiers) { qualifiers = Array.isArray(qualifiers) ? qualifiers : [qualifiers]; } - const eggObject = await this.app.eggContainerFactory.getOrCreateEggObjectFromName( - name, - qualifiers as QualifierInfo[], - ); - return eggObject.obj as T; + const app = this.app; + return TeggScope.run(app._teggScopeBag, async () => { + const eggObject = await app.eggContainerFactory.getOrCreateEggObjectFromName(name, qualifiers as QualifierInfo[]); + return eggObject.obj as T; + }); } } diff --git a/tegg/plugin/tegg/src/lib/CompatibleUtil.ts b/tegg/plugin/tegg/src/lib/CompatibleUtil.ts index a7bcd8cca7..706e432b37 100644 --- a/tegg/plugin/tegg/src/lib/CompatibleUtil.ts +++ b/tegg/plugin/tegg/src/lib/CompatibleUtil.ts @@ -2,11 +2,22 @@ import { InitTypeQualifierAttribute, ObjectInitType } from '@eggjs/core-decorato import { type EggPrototype, EggPrototypeFactory } from '@eggjs/metadata'; import { ProxyUtil } from '@eggjs/tegg-common-util'; import { EggContainerFactory, type LoadUnitInstance } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; import type { Application, Context } from 'egg'; +const SINGLETON_PROTO_CACHE_SLOT = Symbol('tegg:plugin:singletonProtoCache'); +const REQUEST_PROTO_CACHE_SLOT = Symbol('tegg:plugin:requestProtoCache'); + export class CompatibleUtil { - static singletonProtoCache: Map = new Map(); - static requestProtoCache: Map = new Map(); + // Per-app proto-name caches. Two apps may share a proto NAME but have distinct + // protos, so these caches must be per-app (resolved from the active scope). + static get singletonProtoCache(): Map { + return TeggScope.resolve(SINGLETON_PROTO_CACHE_SLOT, () => new Map(), 'CompatibleUtil.singletonProtoCache'); + } + + static get requestProtoCache(): Map { + return TeggScope.resolve(REQUEST_PROTO_CACHE_SLOT, () => new Map(), 'CompatibleUtil.requestProtoCache'); + } static getSingletonProto(name: PropertyKey): EggPrototype { if (!this.singletonProtoCache.has(name)) { @@ -37,15 +48,19 @@ export class CompatibleUtil { private static singletonModuleProxyFactory(app: Application, loadUnitInstance: LoadUnitInstance) { let deprecated = false; return function (_: unknown, p: PropertyKey) { - const proto = CompatibleUtil.getSingletonProto(p); - const eggObj = EggContainerFactory.getEggObject(proto); - if (!deprecated) { - deprecated = true; - app.deprecate( - `[egg/module] Please use await app.getEggObject(clazzName) instead of app.${loadUnitInstance.name}.${String(p)}`, - ); - } - return eggObj.obj; + // `app.module.xxx` may be accessed outside any request ALS frame, so + // re-establish this app's scope synchronously before resolving the proto. + return TeggScope.run(app._teggScopeBag, () => { + const proto = CompatibleUtil.getSingletonProto(p); + const eggObj = EggContainerFactory.getEggObject(proto); + if (!deprecated) { + deprecated = true; + app.deprecate( + `[egg/module] Please use await app.getEggObject(clazzName) instead of app.${loadUnitInstance.name}.${String(p)}`, + ); + } + return eggObj.obj; + }); }; } @@ -65,15 +80,18 @@ export class CompatibleUtil { if (!holder[cacheKey]) { let deprecated = false; const getter = function (_: unknown, p: PropertyKey) { - const proto = CompatibleUtil.getRequestProto(p); - const eggObj = EggContainerFactory.getEggObject(proto, p); - if (!deprecated) { - deprecated = true; - ctx.app.deprecate( - `[egg/module] Please use await ctx.getEggObject(clazzName) instead of ctx.${loadUnitInstance.name}.${String(p)}`, - ); - } - return eggObj.obj; + // `ctx.module.xxx` access — re-establish this app's scope synchronously. + return TeggScope.run(ctx.app._teggScopeBag, () => { + const proto = CompatibleUtil.getRequestProto(p); + const eggObj = EggContainerFactory.getEggObject(proto, p); + if (!deprecated) { + deprecated = true; + ctx.app.deprecate( + `[egg/module] Please use await ctx.getEggObject(clazzName) instead of ctx.${loadUnitInstance.name}.${String(p)}`, + ); + } + return eggObj.obj; + }); }; holder[cacheKey] = ProxyUtil.safeProxy(loadUnitInstance, getter); } diff --git a/tegg/plugin/tegg/src/lib/ctx_lifecycle_middleware.ts b/tegg/plugin/tegg/src/lib/ctx_lifecycle_middleware.ts index 1e4087a9f9..73a600b475 100644 --- a/tegg/plugin/tegg/src/lib/ctx_lifecycle_middleware.ts +++ b/tegg/plugin/tegg/src/lib/ctx_lifecycle_middleware.ts @@ -1,6 +1,7 @@ import { ROOT_PROTO, TEGG_CONTEXT } from '@eggjs/module-common'; import { StreamUtil } from '@eggjs/tegg-common-util'; import type { EggContextLifecycleContext } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; // @ts-expect-error - no type declarations available import awaitFirst from 'await-first'; import type { Context, Next } from 'egg'; @@ -8,45 +9,49 @@ import type { Context, Next } from 'egg'; import { EggContextImpl } from './EggContextImpl.ts'; export async function ctxLifecycleMiddleware(ctx: Context, next: Next): Promise { - // should not recreate teggContext - if (ctx[TEGG_CONTEXT]) { - await next(); - return; - } + // Propagate this app's per-app factories through the async context of the whole + // request, so tegg object resolution during next() reads the correct app. + return TeggScope.run(ctx.app._teggScopeBag, async () => { + // should not recreate teggContext + if (ctx[TEGG_CONTEXT]) { + await next(); + return; + } - const lifecycleCtx: EggContextLifecycleContext = {}; - const teggCtx = new EggContextImpl(ctx); - // rootProto is set by tegg-controller-plugin global middleware(teggRootProto) - // is used in EggControllerHook - const rootProto = ctx[ROOT_PROTO]; - if (rootProto) { - teggCtx.set(ROOT_PROTO, rootProto); - } + const lifecycleCtx: EggContextLifecycleContext = {}; + const teggCtx = new EggContextImpl(ctx); + // rootProto is set by tegg-controller-plugin global middleware(teggRootProto) + // is used in EggControllerHook + const rootProto = ctx[ROOT_PROTO]; + if (rootProto) { + teggCtx.set(ROOT_PROTO, rootProto); + } - if (teggCtx.init) { - await teggCtx.init(lifecycleCtx); - } + if (teggCtx.init) { + await teggCtx.init(lifecycleCtx); + } - async function doDestroy() { - if (StreamUtil.isStream(ctx.response.body)) { + async function doDestroy() { + if (StreamUtil.isStream(ctx.response.body)) { + try { + await awaitFirst(ctx.response.body, ['close', 'error']); + } catch (error) { + ctx.res.destroy(error as Error); + } + } try { - await awaitFirst(ctx.response.body, ['close', 'error']); - } catch (error) { - ctx.res.destroy(error as Error); + if (teggCtx.destroy) { + await teggCtx.destroy(lifecycleCtx); + } + } catch (e: any) { + e.message = `[tegg/ctxLifecycleMiddleware] destroy tegg ctx failed: ${e.message}`; + ctx.logger.error(e); } } try { - if (teggCtx.destroy) { - await teggCtx.destroy(lifecycleCtx); - } - } catch (e: any) { - e.message = `[tegg/ctxLifecycleMiddleware] destroy tegg ctx failed: ${e.message}`; - ctx.logger.error(e); + await next(); + } finally { + doDestroy(); } - } - try { - await next(); - } finally { - doDestroy(); - } + }); } diff --git a/tegg/plugin/tegg/src/types.ts b/tegg/plugin/tegg/src/types.ts index c2d80ab664..549d22bcc3 100644 --- a/tegg/plugin/tegg/src/types.ts +++ b/tegg/plugin/tegg/src/types.ts @@ -19,6 +19,7 @@ import type { LoadUnitInstanceLifecycleUtil, EggContext as TEggContext, } from '@eggjs/tegg-runtime'; +import type { TeggScopeBag } from '@eggjs/tegg-types'; import type { EggContextHandler } from './lib/EggContextHandler.ts'; import type { ModuleHandler } from './lib/ModuleHandler.ts'; @@ -59,6 +60,13 @@ declare module 'egg' { // set on ModuleHandler.init() module: EggModule; + /** + * Per-app TeggScope bag, created in the tegg plugin's configWillLoad. Carries + * this app's per-app factories/managers/caches; boot, request, and unittest + * scopes wrap their work in `TeggScope.run(app._teggScopeBag, ...)`. + */ + _teggScopeBag: TeggScopeBag; + /** * Mock the module context, only for unittest */ diff --git a/tegg/plugin/tegg/test/MultiApp.test.ts b/tegg/plugin/tegg/test/MultiApp.test.ts new file mode 100644 index 0000000000..323041f149 --- /dev/null +++ b/tegg/plugin/tegg/test/MultiApp.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; + +import { mm } from '@eggjs/mock'; +import { describe, it } from 'vitest'; + +import { BackgroundCounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts'; +import { CounterProducer } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts'; +import { CounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts'; +import { getAppBaseDir } from './utils.ts'; + +async function waitFor(predicate: () => boolean, timeout = 2000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timeout'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('plugin/tegg/test/MultiApp.test.ts', () => { + it('should isolate singleton objects between two concurrent apps', async () => { + // Both apps load the SAME counter-module file; isolation means each gets its + // own CounterService singleton. Two apps alive => TeggScope strict mode is + // active, so any per-app access escaping to the process-default bag throws. + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); + await Promise.all([app1.ready(), app2.ready()]); + try { + const counter1 = await app1.getEggObject(CounterService); + const counter2 = await app2.getEggObject(CounterService); + assert.notStrictEqual(counter1, counter2, 'each app must have its own CounterService singleton'); + + counter1.increment(); + counter1.increment(); + assert.equal(counter1.getCount(), 2); + assert.equal(counter2.getCount(), 0, 'app2 singleton must not be affected by app1 mutations'); + + const counter1Again = await app1.getEggObject(CounterService); + assert.strictEqual(counter1Again, counter1, 'same instance returned within app1'); + assert.equal(counter1Again.getCount(), 2); + } finally { + await Promise.all([app1.close(), app2.close()]); + } + }); + + it('should isolate per-app data store (DB-like singleton state)', async () => { + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); + await Promise.all([app1.ready(), app2.ready()]); + try { + const counter1 = await app1.getEggObject(CounterService); + const counter2 = await app2.getEggObject(CounterService); + counter1.save('foo', 42); + assert.equal(counter1.load('foo'), 42); + assert.equal(counter2.load('foo'), undefined, 'app2 store must not see app1 data'); + } finally { + await Promise.all([app1.close(), app2.close()]); + } + }); + + it('should isolate EventBus emit/handler dispatch between two concurrent apps', async () => { + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); + await Promise.all([app1.ready(), app2.ready()]); + try { + const counter1 = await app1.getEggObject(CounterService); + const counter2 = await app2.getEggObject(CounterService); + + // emit from within app1's context — the per-app EventBus dispatches the + // handler in app1's scope, so it must hit app1's CounterService only. + await app1.mockModuleContextScope(async (ctx: any) => { + const producer1 = await ctx.getEggObject(CounterProducer); + producer1.emit(3); + }); + await waitFor(() => counter1.getEventCount() === 3); + + assert.equal(counter1.getEventCount(), 3, 'app1 handler should observe app1 emit'); + assert.equal(counter2.getEventCount(), 0, 'app2 must not observe app1 emit'); + } finally { + await Promise.all([app1.close(), app2.close()]); + } + }); + + it('should isolate background tasks between two concurrent apps', async () => { + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); + await Promise.all([app1.ready(), app2.ready()]); + try { + // Background task drains on context destroy (the timer/async escape point); + // its callback must resolve app1's CounterService. + await app1.mockModuleContextScope(async (ctx: any) => { + const bg = await ctx.getEggObject(BackgroundCounterService); + bg.schedule(2); + }); + + const counter1 = await app1.getEggObject(CounterService); + const counter2 = await app2.getEggObject(CounterService); + assert.equal(counter1.getCount(), 2, 'app1 background task should increment app1 counter'); + assert.equal(counter2.getCount(), 0, 'app2 must not be affected by app1 background task'); + } finally { + await Promise.all([app1.close(), app2.close()]); + } + }); + + it('should not leak state between sequential app lifecycles', async () => { + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + await app1.ready(); + const counter1 = await app1.getEggObject(CounterService); + counter1.increment(); + counter1.increment(); + counter1.increment(); + assert.equal(counter1.getCount(), 3); + await app1.close(); + + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + await app2.ready(); + try { + const counter2 = await app2.getEggObject(CounterService); + assert.equal(counter2.getCount(), 0, 'app2 must not inherit app1 state after sequential restart'); + } finally { + await app2.close(); + } + }); +}); diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/config.default.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/config.default.ts new file mode 100644 index 0000000000..0b592488fb --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/config.default.ts @@ -0,0 +1,5 @@ +import type { EggAppConfig } from 'egg'; + +export default function (): Partial { + return { keys: 'test key' }; +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/module.json b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/module.json new file mode 100644 index 0000000000..fdc9d6a3ca --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/module.json @@ -0,0 +1 @@ +[{ "path": "../../multi-app-isolation/modules/counter-module" }] diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/plugin.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/plugin.ts new file mode 100644 index 0000000000..d14110b64e --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/config/plugin.ts @@ -0,0 +1,6 @@ +export default { + teggConfig: { package: '@eggjs/tegg-config', enable: true }, + tegg: { package: '@eggjs/tegg-plugin', enable: true }, + eventbus: { package: '@eggjs/eventbus-plugin', enable: true }, + watcher: false, +}; diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/package.json b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/package.json new file mode 100644 index 0000000000..abeb0465e0 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation-b/package.json @@ -0,0 +1,4 @@ +{ + "name": "multi-app-isolation-b", + "type": "module" +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/config.default.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/config.default.ts new file mode 100644 index 0000000000..0b592488fb --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/config.default.ts @@ -0,0 +1,5 @@ +import type { EggAppConfig } from 'egg'; + +export default function (): Partial { + return { keys: 'test key' }; +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/module.json b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/module.json new file mode 100644 index 0000000000..7a99b49f01 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/module.json @@ -0,0 +1 @@ +[{ "path": "../modules/counter-module" }] diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/plugin.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/plugin.ts new file mode 100644 index 0000000000..d14110b64e --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/config/plugin.ts @@ -0,0 +1,6 @@ +export default { + teggConfig: { package: '@eggjs/tegg-config', enable: true }, + tegg: { package: '@eggjs/tegg-plugin', enable: true }, + eventbus: { package: '@eggjs/eventbus-plugin', enable: true }, + watcher: false, +}; diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts new file mode 100644 index 0000000000..7c4670deeb --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts @@ -0,0 +1,26 @@ +import { AccessLevel, BackgroundTaskHelper, ContextProto, Inject } from '@eggjs/tegg'; + +import { CounterService } from './CounterService.ts'; + +/** + * Context-scoped service that schedules a background task (timer/async escape + * point). The task callback resolves the per-app CounterService and must hit the + * scheduling app's instance, even though it runs after the request returns. + */ +@ContextProto({ accessLevel: AccessLevel.PUBLIC }) +export class BackgroundCounterService { + @Inject() + private readonly backgroundTaskHelper: BackgroundTaskHelper; + + @Inject() + private readonly counterService: CounterService; + + schedule(times: number): void { + const counterService = this.counterService; + this.backgroundTaskHelper.run(async () => { + for (let i = 0; i < times; i++) { + counterService.increment(); + } + }); + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts new file mode 100644 index 0000000000..f10fb7889b --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts @@ -0,0 +1,34 @@ +import { AccessLevel, Event, type EventBus, Inject, SingletonProto } from '@eggjs/tegg'; + +import { CounterService } from './CounterService.ts'; + +declare module '@eggjs/eventbus-decorator' { + interface Events { + counterEvent: (delta: number) => void; + } +} + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class CounterProducer { + @Inject() + private readonly eventBus: EventBus; + + emit(delta: number): void { + this.eventBus.emit('counterEvent', delta); + } +} + +/** + * Runs in the EventBus event context. Under multi-app, doEmit re-establishes the + * EMITTING app's scope, so this handler must resolve the EMITTING app's + * CounterService — never the other app's. + */ +@Event('counterEvent') +export class CounterEventHandler { + @Inject() + private readonly counterService: CounterService; + + async handle(delta: number): Promise { + this.counterService.onEvent(delta); + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts new file mode 100644 index 0000000000..118f05bc5a --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts @@ -0,0 +1,41 @@ +import { AccessLevel, SingletonProto } from '@eggjs/tegg'; + +/** + * Per-app singleton state. The SAME class is loaded by two apps; each app must + * get its OWN CounterService instance: + * - `count` — plain singleton state (the baseline isolation check) + * - `eventCount` — incremented by the EventBus handler (emit-path isolation) + * - `store` — an in-memory data store standing in for a DB / cache holder + * (per-app stateful-singleton isolation; real dal datasource + * isolation is covered by the dal-plugin tests) + */ +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class CounterService { + private count = 0; + private eventCount = 0; + private readonly store = new Map(); + + increment(): void { + this.count++; + } + + getCount(): number { + return this.count; + } + + onEvent(delta: number): void { + this.eventCount += delta; + } + + getEventCount(): number { + return this.eventCount; + } + + save(key: string, value: number): void { + this.store.set(key, value); + } + + load(key: string): number | undefined { + return this.store.get(key); + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/package.json b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/package.json new file mode 100644 index 0000000000..78c037d89e --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "counter-module", + "type": "module", + "eggModule": { + "name": "counterModule" + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/package.json b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/package.json new file mode 100644 index 0000000000..0bf1d12570 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/package.json @@ -0,0 +1,4 @@ +{ + "name": "multi-app-isolation", + "type": "module" +} diff --git a/tegg/standalone/standalone/package.json b/tegg/standalone/standalone/package.json index feeb03ad76..44f065f47e 100644 --- a/tegg/standalone/standalone/package.json +++ b/tegg/standalone/standalone/package.json @@ -50,7 +50,8 @@ "@eggjs/tegg": "workspace:*", "@eggjs/tegg-common-util": "workspace:*", "@eggjs/tegg-loader": "workspace:*", - "@eggjs/tegg-runtime": "workspace:*" + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*" }, "devDependencies": { "@eggjs/ajv-plugin": "workspace:*", diff --git a/tegg/standalone/standalone/src/EggModuleLoader.ts b/tegg/standalone/standalone/src/EggModuleLoader.ts index 1cd55e948d..9dea613ed9 100644 --- a/tegg/standalone/standalone/src/EggModuleLoader.ts +++ b/tegg/standalone/standalone/src/EggModuleLoader.ts @@ -9,6 +9,7 @@ import { import type { Logger } from '@eggjs/tegg'; import type { ModuleReference } from '@eggjs/tegg-common-util'; import { LoaderFactory } from '@eggjs/tegg-loader'; +import { TeggScope } from '@eggjs/tegg-types'; export interface EggModuleLoaderOptions { logger: Logger; @@ -64,19 +65,23 @@ export class EggModuleLoader { } static async preLoad(moduleReferences: readonly ModuleReference[], options: EggModuleLoaderOptions): Promise { - const loadUnits: LoadUnit[] = []; - const loaderCache = new Map(); - const globalGraph = (GlobalGraph.instance = await EggModuleLoader.generateAppGraph(moduleReferences, options)); - globalGraph.sort(); - const moduleConfigList = globalGraph.moduleConfigList; - for (const moduleConfig of moduleConfigList) { - const modulePath = moduleConfig.path; - const loader = loaderCache.get(modulePath)!; - const loadUnit = await LoadUnitFactory.createPreloadLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); - loadUnits.push(loadUnit); - } - for (const load of loadUnits) { - await load.preLoad?.(); - } + // Isolate preload in its own temporary scope so its graph/proto registrations + // do not leak into the process-default bag or any concurrent Runner. + await TeggScope.run(TeggScope.createBag(), async () => { + const loadUnits: LoadUnit[] = []; + const loaderCache = new Map(); + const globalGraph = (GlobalGraph.instance = await EggModuleLoader.generateAppGraph(moduleReferences, options)); + globalGraph.sort(); + const moduleConfigList = globalGraph.moduleConfigList; + for (const moduleConfig of moduleConfigList) { + const modulePath = moduleConfig.path; + const loader = loaderCache.get(modulePath)!; + const loadUnit = await LoadUnitFactory.createPreloadLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); + loadUnits.push(loadUnit); + } + for (const load of loadUnits) { + await load.preLoad?.(); + } + }); } } diff --git a/tegg/standalone/standalone/src/Runner.ts b/tegg/standalone/standalone/src/Runner.ts index cdb63ed797..53c718f28d 100644 --- a/tegg/standalone/standalone/src/Runner.ts +++ b/tegg/standalone/standalone/src/Runner.ts @@ -45,6 +45,8 @@ import { LoadUnitInstanceFactory, ModuleLoadUnitInstance, } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; +import type { TeggScopeBag } from '@eggjs/tegg-types'; import { CrosscutAdviceFactory } from '@eggjs/tegg/aop'; import { StandaloneUtil, type MainRunner } from '@eggjs/tegg/standalone'; @@ -95,13 +97,25 @@ export class Runner { loadUnitInstances: LoadUnitInstance[] = []; innerObjects: Record; + // This Runner's own per-app TeggScope bag — all factories/managers/graph/config + // names resolve here, so multiple Runners in one process stay isolated. + readonly scopeBag: TeggScopeBag; + constructor(cwd: string, options?: RunnerOptions) { this.cwd = cwd; this.env = options?.env; this.name = options?.name; this.options = options; + this.scopeBag = TeggScope.createBag(); + TeggScope.registerScope(); this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); this.moduleConfigs = {}; + TeggScope.run(this.scopeBag, () => { + this.initInnerObjectsAndConfigs(options); + }); + } + + private initInnerObjectsAndConfigs(options?: RunnerOptions): void { this.innerObjects = { moduleConfigs: [ { @@ -170,25 +184,27 @@ export class Runner { } async load(): Promise { - StandaloneContextHandler.register(); - LoadUnitFactory.registerLoadUnitCreator(StandaloneLoadUnitType, () => { - return new StandaloneLoadUnit(this.innerObjects); - }); - LoadUnitInstanceFactory.registerLoadUnitInstanceClass( - StandaloneLoadUnitType, - ModuleLoadUnitInstance.createModuleLoadUnitInstance, - ); - const standaloneLoadUnit = await LoadUnitFactory.createLoadUnit( - 'MockStandaloneLoadUnitPath', - StandaloneLoadUnitType, - { - async load(): Promise { - return []; + return TeggScope.run(this.scopeBag, async () => { + StandaloneContextHandler.register(); + LoadUnitFactory.registerLoadUnitCreator(StandaloneLoadUnitType, () => { + return new StandaloneLoadUnit(this.innerObjects); + }); + LoadUnitInstanceFactory.registerLoadUnitInstanceClass( + StandaloneLoadUnitType, + ModuleLoadUnitInstance.createModuleLoadUnitInstance, + ); + const standaloneLoadUnit = await LoadUnitFactory.createLoadUnit( + 'MockStandaloneLoadUnitPath', + StandaloneLoadUnitType, + { + async load(): Promise { + return []; + }, }, - }, - ); - const loadUnits = await this.loadUnitLoader.load(); - return [standaloneLoadUnit, ...loadUnits]; + ); + const loadUnits = await this.loadUnitLoader.load(); + return [standaloneLoadUnit, ...loadUnits]; + }); } static getModuleReferences(cwd: string, dependencies?: RunnerOptions['dependencies']): readonly ModuleReference[] { @@ -249,49 +265,60 @@ export class Runner { } async init(): Promise { - await this.initLoaderInstance(); + await TeggScope.run(this.scopeBag, async () => { + await this.initLoaderInstance(); - this.loadUnits = await this.load(); - const instances: LoadUnitInstance[] = []; - for (const loadUnit of this.loadUnits) { - const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); - instances.push(instance); - } - this.loadUnitInstances = instances; - const runnerClass = StandaloneUtil.getMainRunner(); - if (!runnerClass) { - throw new Error('not found runner class. Do you add @Runner decorator?'); - } - const proto = PrototypeUtil.getClazzProto(runnerClass); - if (!proto) { - throw new Error(`can not get proto for clazz ${runnerClass.name}`); - } - this.runnerProto = proto as EggPrototype; + this.loadUnits = await this.load(); + const instances: LoadUnitInstance[] = []; + for (const loadUnit of this.loadUnits) { + const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + instances.push(instance); + } + this.loadUnitInstances = instances; + const runnerClass = StandaloneUtil.getMainRunner(); + if (!runnerClass) { + throw new Error('not found runner class. Do you add @Runner decorator?'); + } + const proto = PrototypeUtil.getClazzProto(runnerClass); + if (!proto) { + throw new Error(`can not get proto for clazz ${runnerClass.name}`); + } + this.runnerProto = proto as EggPrototype; + }); } async run(aCtx?: EggContext): Promise { - const lifecycle = {}; - const ctx = aCtx || new StandaloneContext(); - return await ContextHandler.run(ctx, async () => { - if (ctx.init) { - await ctx.init(lifecycle); - } - const eggObject = await EggContainerFactory.getOrCreateEggObject(this.runnerProto); - const runner = eggObject.obj as MainRunner; - try { - return await runner.main(); - } finally { - if (ctx.destroy) { - ctx.destroy(lifecycle).catch((e) => { - e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; - console.warn(e); - }); + return TeggScope.run(this.scopeBag, async () => { + const lifecycle = {}; + const ctx = aCtx || new StandaloneContext(); + return await ContextHandler.run(ctx, async () => { + if (ctx.init) { + await ctx.init(lifecycle); } - } + const eggObject = await EggContainerFactory.getOrCreateEggObject(this.runnerProto); + const runner = eggObject.obj as MainRunner; + try { + return await runner.main(); + } finally { + if (ctx.destroy) { + ctx.destroy(lifecycle).catch((e) => { + e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; + console.warn(e); + }); + } + } + }); }); } async destroy(): Promise { + await TeggScope.run(this.scopeBag, async () => { + await this.doDestroy(); + }); + TeggScope.unregisterScope(); + } + + private async doDestroy(): Promise { if (this.loadUnitInstances) { for (const instance of this.loadUnitInstances) { await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); From 8777fb51865e30ece2990f718c40884942f47187 Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 23 Jun 2026 11:13:57 +0800 Subject: [PATCH 02/16] docs(tegg): document TeggScope multi-app isolation constraints Add a "Multi-App Isolation (TeggScope)" section to tegg/CLAUDE.md with the rules agents/devs must follow when editing tegg core/plugins: no new process-global mutable runtime state (back per-app state with a TeggScope slot), wrap plugin lifecycle-hook registration in TeggScope.run(app._teggScopeBag, ...), resolve egg objects per-app, handle detached escape points, and the strict-mode fuse. Also note the (negligible) performance characteristics. Add a concise pointer rule under Coding Conventions in AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 ++++ tegg/CLAUDE.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 30ce7bc48c..715ae583c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,11 @@ Then re-run tests. - keep file names lowercase with hyphens - keep public API changes deliberate and documented - use `oxfmt` and `oxlint --type-aware` conventions already present in the repo +- **tegg multi-app isolation**: do NOT introduce new process-global mutable + runtime state in `tegg/`; per-app state must be backed by a `TeggScope` slot, + and plugin lifecycle-hook registration must run inside + `TeggScope.run(app._teggScopeBag, ...)`. See the "Multi-App Isolation + (TeggScope)" section in `tegg/CLAUDE.md` for the full rules. ## TypeScript Global Types diff --git a/tegg/CLAUDE.md b/tegg/CLAUDE.md index 6ce219b27b..c599753dad 100644 --- a/tegg/CLAUDE.md +++ b/tegg/CLAUDE.md @@ -285,6 +285,86 @@ const impl = await eggObjectFactory.getEggObject( ); ``` +## Multi-App Isolation (TeggScope) — MUST follow + +Tegg supports multiple apps booting and serving requests **concurrently in one +process** without cross-talk. This is built on `TeggScope` +(`@eggjs/tegg-types`), a type-free `AsyncLocalStorage>`. +Each app owns a per-app "bag" (`app._teggScopeBag`); per-app state lives in +slots inside that bag, and the active bag is established with +`TeggScope.run(app._teggScopeBag, ...)`. Per-app singletons resolve via the same +old static call sites (e.g. `EggPrototypeFactory.instance`) — they now read the +current scope instead of a process global. + +When you touch tegg core/plugins, follow these rules: + +1. **Never add new process-global mutable runtime state.** A `static` field / + `Map` / singleton that holds per-app data WILL leak across concurrent apps. + If you need such state, back it with a `TeggScope` slot: + + ```ts + import { TeggScope } from '@eggjs/tegg-types'; + const X_SLOT = Symbol('tegg::'); // module-private, never exported + export class X { + static get instance(): X { + return TeggScope.resolve(X_SLOT, () => new X(), 'X.instance'); + } + } + ``` + + Import `TeggScope` **only** from `@eggjs/tegg-types`; never import another + package's slot. A package that imports `TeggScope` must declare + `@eggjs/tegg-types` as a direct dependency. + +2. **Shared, app-agnostic registries stay global.** Class/type-keyed maps + populated at import time with app-agnostic values (e.g. + `EggPrototypeCreatorFactory` creator map, `registerEggObjectCreateMethod`, + `registerLoadUnitInstanceClass`) must NOT be scoped. Only state that holds + per-app instances/data is scoped. (`LoadUnitFactory`'s creator map is + two-tier: a global base for import-time creators + a per-app overlay for + boot-time, app-capturing creators.) + +3. **Lifecycle-hook registration must run in the app scope.** Any plugin boot + that calls `app.{loadUnit,eggPrototype,eggObject,eggContext,loadUnitInstance}LifecycleUtil.registerLifecycle(hook)` + MUST wrap it in `TeggScope.run(this.app._teggScopeBag, () => { ... })` (and + the matching `deleteLifecycle` in `beforeClose`). The lifecycle utils are + per-app, so an unwrapped registration lands in the wrong bag and the hook + never fires during boot. Do **not** register lifecycle hooks in the boot + **constructor** — `app._teggScopeBag` does not exist yet; do it in + `configWillLoad`/`configDidLoad`/`didLoad`. + +4. **Resolve egg objects per-app.** To get a proto from a class, prefer + `EggPrototypeFactory.instance.getPrototypeByClazz(clazz)` (per-app) before + falling back to `PrototypeUtil.getClazzProto(clazz)` (a process-global slot + on the class, overwritten by concurrent boot). `ctx.getEggObject` / + `app.getEggObject` already do this and wrap in the app scope. + +5. **Escape points** — code that runs **detached** from the request must + re-establish the scope. Capture `const bag = TeggScope.current()` at + registration/scheduling and re-enter `TeggScope.run(bag, cb)` inside the + callback for: emitter listeners triggered later (`res.on('close')`, + `signal.addEventListener('abort')`), fire-and-forget `EventBus.emit` from a + detached context, and timers created outside a scope. Timers/promises created + **inside** an active scope inherit it automatically — no wrap needed. + +6. **Strict-mode fuse.** Under true multi-app (`> 1` live app) any access that + escapes to the process-default bag throws in dev / warns in prod. If you see + `[tegg] TeggScope escaped to the process-default bag`, you have an unwrapped + access — wrap the relevant boot/request/escape path in `TeggScope.run`. + +7. **Single app is unchanged.** With one app the default bag is used silently + and the fuse never fires, so existing single-app behavior and tests are + unaffected. Add multi-app regression coverage (two concurrent apps sharing a + module) when you change loader/runtime/lifecycle/eventbus behavior — see + `tegg/plugin/tegg/test/MultiApp.test.ts`. + +**Performance:** `TeggScope.resolve` adds ~8 ns/access and `TeggScope.run` +~5 ns/call over a plain static read (Node 22); egg already runs on +AsyncLocalStorage, so there is no new process-wide async penalty. The cost is +negligible relative to real request work. The per-app lifecycle-util facade is a +`Proxy` (~27 ns/call) — fine in practice; replace with an explicit delegating +object only if a future profile shows it matters. + ## Common Patterns ### Creating a New Core Package From 0814a0b8b1ed37be4ec52ee069f596d0ab375eab Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 23 Jun 2026 14:00:47 +0800 Subject: [PATCH 03/16] perf(tegg): converge per-app scope wrapping and speed up the hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce the per-app isolation overhead and the boilerplate `TeggScope.run(...)` wrapping, following review feedback. Convergence (less defensive boilerplate): - `app.*LifecycleUtil` getters are now PINNED to the app's scope bag via new `xxxLifecycleUtilFromBag(bag)` helpers, so plugin boot hooks register lifecycle hooks WITHOUT wrapping each call in `TeggScope.run`. Removed the run wraps from aop/dal/eventbus/orm/schedule/langchain boot hooks (kept only where a scope is genuinely required: moduleHandler.init/destroy, sdk-base construction, leoricRegister, registerLoadUnitCreator overlay, request/getEggObject paths, DAL manager teardown, standalone Runner). - `GlobalGraph.instanceFor(bag)` lets aop/controller register build hooks pinned to the owning app's graph (no run wrap). Performance: - lifecycle utils: replace the per-access Proxy facade with an explicit delegating object (no get-trap / bound-function allocation): ~27ns -> ~8ns per lifecycle-util call. - `TeggScope.resolve`/`getOr`: single `getStore()` per call; the in-scope common path never reaches the escape check, and single-app never escapes. Correctness (latent multi-app bugs exposed by the convergence): - Resolve egg objects by class via the per-app `EggPrototypeFactory.getPrototypeByClazz` (falling back to the global `PrototypeUtil.getClazzProto`) in EggContextEventBus, dynamic-inject EggObjectFactory and aop LoadUnitAopHook — the global class->proto map is shared and overwritten by concurrent apps. Docs: document the TeggScope constraints + performance notes in tegg/CLAUDE.md (added previously) still apply. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/aop-runtime/src/LoadUnitAopHook.ts | 6 +- .../src/EggObjectFactory.ts | 5 +- .../core/lifecycle/src/ScopedLifecycleUtil.ts | 82 +++++++++++++------ .../test/__snapshots__/index.test.ts.snap | 1 + tegg/core/metadata/src/model/EggPrototype.ts | 11 ++- tegg/core/metadata/src/model/LoadUnit.ts | 9 +- .../metadata/src/model/graph/GlobalGraph.ts | 9 ++ .../test/__snapshots__/index.test.ts.snap | 32 ++++++-- tegg/core/runtime/src/model/EggContext.ts | 11 ++- tegg/core/runtime/src/model/EggObject.ts | 9 +- .../runtime/src/model/LoadUnitInstance.ts | 11 ++- .../test/__snapshots__/index.test.ts.snap | 48 +++++++++-- .../test/__snapshots__/exports.test.ts.snap | 1 + .../test/__snapshots__/helper.test.ts.snap | 80 ++++++++++++++---- tegg/core/types/src/scope/TeggScope.ts | 20 +++-- tegg/plugin/aop/src/app.ts | 34 ++++---- tegg/plugin/controller/src/app.ts | 5 +- tegg/plugin/dal/src/app.ts | 28 +++---- tegg/plugin/eventbus/src/app.ts | 22 ++--- .../eventbus/src/lib/EggContextEventBus.ts | 5 +- tegg/plugin/langchain/src/app.ts | 32 ++++---- tegg/plugin/orm/src/app.ts | 15 ++-- tegg/plugin/schedule/package.json | 1 - tegg/plugin/schedule/src/app.ts | 14 ++-- .../plugin/tegg/src/app/extend/application.ts | 15 ++-- 25 files changed, 340 insertions(+), 166 deletions(-) diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index 4abad98fb3..0809c0c8de 100644 --- a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts +++ b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts @@ -1,6 +1,6 @@ import { AspectInfoUtil, AspectMetaBuilder, CrosscutAdviceFactory } from '@eggjs/aop-decorator'; import { PrototypeUtil } from '@eggjs/core-decorator'; -import { TeggError } from '@eggjs/metadata'; +import { EggPrototypeFactory, TeggError } from '@eggjs/metadata'; import type { EggPrototype, EggPrototypeWithClazz, @@ -29,7 +29,9 @@ export class LoadUnitAopHook implements LifecycleHookproto map (multi-app safe) over the global one. + const adviceProto = + EggPrototypeFactory.instance.getPrototypeByClazz(advice.clazz) ?? PrototypeUtil.getClazzProto(advice.clazz); if (!adviceProto) { throw TeggError.create(`Aop Advice(${advice.clazz.name}) not found in loadUnits`, 'advice_not_found'); } diff --git a/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts b/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts index 4bc3cb4981..b85cd104ef 100644 --- a/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts +++ b/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts @@ -1,5 +1,6 @@ import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator'; import { QualifierImplUtil } from '@eggjs/dynamic-inject'; +import { EggPrototypeFactory } from '@eggjs/metadata'; import type { EggContainerFactory } from '@eggjs/tegg-runtime'; import { AccessLevel } from '@eggjs/tegg-types'; import type { QualifierValue, EggAbstractClazz, EggObjectFactory as IEggObjectFactory } from '@eggjs/tegg-types'; @@ -19,7 +20,9 @@ export class EggObjectFactory implements IEggObjectFactory { if (!implClazz) { throw new Error(`has no impl for ${abstractClazz.name} with qualifier ${qualifierValue}`); } - const protoObj: any = PrototypeUtil.getClazzProto(implClazz); + // Prefer this app's class->proto map (multi-app safe) over the global one. + const protoObj: any = + EggPrototypeFactory.instance.getPrototypeByClazz(implClazz) ?? PrototypeUtil.getClazzProto(implClazz); if (!protoObj) { throw new Error(`can not get proto for clazz ${implClazz.name}`); } diff --git a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts index 52f20e8f67..183250a516 100644 --- a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts +++ b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts @@ -1,39 +1,71 @@ import type { LifecycleContext, LifecycleObject } from '@eggjs/tegg-types'; -import { TeggScope } from '@eggjs/tegg-types'; +import { TeggScope, type TeggScopeBag } from '@eggjs/tegg-types'; import { LifecycleUtil } from './LifycycleUtil.ts'; +/** + * Get-or-create the concrete per-app {@link LifecycleUtil} stored at `slot` in a + * specific bag. Used to **pin** a lifecycle util to a known app's bag + * (`app._teggScopeBag`) without depending on the active async scope — e.g. the + * `app.xxxLifecycleUtil` facades, so plugins can register hooks during boot + * WITHOUT wrapping every call in `TeggScope.run(...)`. + */ +export function lifecycleUtilFromBag>( + bag: TeggScopeBag, + slot: symbol, +): LifecycleUtil { + let util = bag.get(slot) as LifecycleUtil | undefined; + if (!util) { + util = new LifecycleUtil(); + bag.set(slot, util); + } + return util; +} + /** * Create a per-app {@link LifecycleUtil} facade backed by {@link TeggScope}. * - * The returned object has the exact same shape/type as a `LifecycleUtil`, but - * every property/method access transparently resolves the per-app instance from - * the active {@link TeggScope} bag (lazily created on first use), falling back to - * the process-default bag when no scope is active (single-app lazy default). + * The returned object has the same shape/type as a `LifecycleUtil`, but every + * method resolves the per-app instance from the active {@link TeggScope} bag (the + * SAME instance {@link lifecycleUtilFromBag} returns for that bag). This lets + * module-level lifecycle-util singletons (e.g. `EggPrototypeLifecycleUtil`) + * become per-app WITHOUT changing any call site, so hooks fired deep in + * metadata/runtime (where there is no `app` reference) hit the current app's util. * - * This lets module-level lifecycle-util singletons (e.g. `EggPrototypeLifecycleUtil`) - * become per-app WITHOUT changing any call site: hooks registered during one - * app's boot land in that app's util, and concurrent apps never cross-fire. + * It is an explicit delegating object (NOT a Proxy) — each call is one slot + * resolve + a direct method call, with no per-access trap or bound-function + * allocation. */ export function createScopedLifecycleUtil>( slot: symbol, desc: string, ): LifecycleUtil { - const resolve = (): LifecycleUtil => TeggScope.resolve(slot, () => new LifecycleUtil(), desc); - // The placeholder target keeps `instanceof LifecycleUtil` working; all real - // reads/writes are forwarded to the per-app instance resolved from the scope. - const placeholder = new LifecycleUtil(); - return new Proxy(placeholder, { - get(_target, prop) { - const real = resolve(); - const value = Reflect.get(real as object, prop, real); - return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(real) : value; - }, - set(_target, prop, value) { - return Reflect.set(resolve() as object, prop, value); - }, - has(_target, prop) { - return Reflect.has(resolve() as object, prop); - }, - }); + const get = (): LifecycleUtil => TeggScope.resolve(slot, () => new LifecycleUtil(), desc); + const facade: Pick< + LifecycleUtil, + | 'registerLifecycle' + | 'deleteLifecycle' + | 'getLifecycleList' + | 'registerObjectLifecycle' + | 'deleteObjectLifecycle' + | 'clearObjectLifecycle' + | 'getObjectLifecycleList' + | 'objectPreCreate' + | 'objectPostCreate' + | 'objectPreDestroy' + | 'getLifecycleHook' + > = { + registerLifecycle: (lifecycle) => get().registerLifecycle(lifecycle), + deleteLifecycle: (lifecycle) => get().deleteLifecycle(lifecycle), + getLifecycleList: () => get().getLifecycleList(), + registerObjectLifecycle: (obj, lifecycle) => get().registerObjectLifecycle(obj, lifecycle), + deleteObjectLifecycle: (obj, lifecycle) => get().deleteObjectLifecycle(obj, lifecycle), + clearObjectLifecycle: (obj) => get().clearObjectLifecycle(obj), + getObjectLifecycleList: (obj) => get().getObjectLifecycleList(obj), + objectPreCreate: (ctx, obj) => get().objectPreCreate(ctx, obj), + objectPostCreate: (ctx, obj) => get().objectPostCreate(ctx, obj), + objectPreDestroy: (ctx, obj) => get().objectPreDestroy(ctx, obj), + getLifecycleHook: (hookName, proto) => get().getLifecycleHook(hookName, proto), + }; + return facade as LifecycleUtil; } diff --git a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap index 8a0df25aa3..2d1ce3855d 100644 --- a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap @@ -12,5 +12,6 @@ exports[`should export stable 1`] = ` "LifecyclePreLoad": [Function], "LifecycleUtil": [Function], "createScopedLifecycleUtil": [Function], + "lifecycleUtilFromBag": [Function], } `; diff --git a/tegg/core/metadata/src/model/EggPrototype.ts b/tegg/core/metadata/src/model/EggPrototype.ts index 41e09e39b3..42946ffc41 100644 --- a/tegg/core/metadata/src/model/EggPrototype.ts +++ b/tegg/core/metadata/src/model/EggPrototype.ts @@ -1,5 +1,5 @@ -import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { EggPrototype, EggPrototypeLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; const EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:eggPrototypeLifecycleUtil'); @@ -12,3 +12,10 @@ export const EggPrototypeLifecycleUtil: LifecycleUtil { + return lifecycleUtilFromBag(bag, EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/metadata/src/model/LoadUnit.ts b/tegg/core/metadata/src/model/LoadUnit.ts index 5e7181e21c..b9842d7b40 100644 --- a/tegg/core/metadata/src/model/LoadUnit.ts +++ b/tegg/core/metadata/src/model/LoadUnit.ts @@ -1,5 +1,5 @@ -import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { LoadUnit, LoadUnitLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; const LOAD_UNIT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:loadUnitLifecycleUtil'); @@ -7,3 +7,8 @@ export const LoadUnitLifecycleUtil: LifecycleUtil(LOAD_UNIT_LIFECYCLE_UTIL_SLOT, 'LoadUnitLifecycleUtil'); + +/** Resolve this app's load-unit lifecycle util directly from its bag (no active scope needed). */ +export function loadUnitLifecycleUtilFromBag(bag: TeggScopeBag): LifecycleUtil { + return lifecycleUtilFromBag(bag, LOAD_UNIT_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/metadata/src/model/graph/GlobalGraph.ts b/tegg/core/metadata/src/model/graph/GlobalGraph.ts index ffdef72f97..c5cc64ec27 100644 --- a/tegg/core/metadata/src/model/graph/GlobalGraph.ts +++ b/tegg/core/metadata/src/model/graph/GlobalGraph.ts @@ -11,6 +11,7 @@ import { type ProtoDescriptor, type QualifierInfo, TeggScope, + type TeggScopeBag, } from '@eggjs/tegg-types'; import { EggPrototypeNotFound, MultiPrototypeFound } from '../../errors.ts'; @@ -87,6 +88,14 @@ export class GlobalGraph { } } + /** + * Resolve a specific app's graph directly from its bag (no active scope needed). + * Used by plugins to register build hooks onto the owning app's graph. + */ + static instanceFor(bag: TeggScopeBag): GlobalGraph | undefined { + return bag.get(GLOBAL_GRAPH_SLOT) as GlobalGraph | undefined; + } + constructor(options?: GlobalGraphOptions) { this.moduleGraph = new Graph(); this.protoGraph = new Graph(); diff --git a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap index e0ab483cc9..e5d5c04888 100644 --- a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap @@ -16,9 +16,18 @@ exports[`should export stable 1`] = ` "EggPrototypeCreatorFactory": [Function], "EggPrototypeFactory": [Function], "EggPrototypeImpl": [Function], - "EggPrototypeLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "EggPrototypeLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "EggPrototypeNotFound": [Function], "ErrorCodes": { @@ -31,9 +40,18 @@ exports[`should export stable 1`] = ` "GlobalModuleNodeBuilder": [Function], "IncompatibleProtoInject": [Function], "LoadUnitFactory": [Function], - "LoadUnitLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "LoadUnitLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "LoadUnitMultiInstanceProtoHook": [Function], "ModuleDependencyMeta": [Function], @@ -49,5 +67,7 @@ exports[`should export stable 1`] = ` }, "ProtoNode": [Function], "TeggError": [Function], + "eggPrototypeLifecycleUtilFromBag": [Function], + "loadUnitLifecycleUtilFromBag": [Function], } `; diff --git a/tegg/core/runtime/src/model/EggContext.ts b/tegg/core/runtime/src/model/EggContext.ts index 90b4b98b79..de830d57f2 100644 --- a/tegg/core/runtime/src/model/EggContext.ts +++ b/tegg/core/runtime/src/model/EggContext.ts @@ -1,5 +1,5 @@ -import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { EggRuntimeContext, EggContextLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { EggRuntimeContext, EggContextLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; const EGG_CONTEXT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggContextLifecycleUtil'); @@ -8,3 +8,10 @@ export const EggContextLifecycleUtil: LifecycleUtil { + return lifecycleUtilFromBag(bag, EGG_CONTEXT_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/runtime/src/model/EggObject.ts b/tegg/core/runtime/src/model/EggObject.ts index 1c7e7f282e..d90b0d1afb 100644 --- a/tegg/core/runtime/src/model/EggObject.ts +++ b/tegg/core/runtime/src/model/EggObject.ts @@ -1,5 +1,5 @@ -import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { EggObject, EggObjectLifeCycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { EggObject, EggObjectLifeCycleContext, TeggScopeBag } from '@eggjs/tegg-types'; const EGG_OBJECT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggObjectLifecycleUtil'); @@ -7,3 +7,8 @@ export const EggObjectLifecycleUtil: LifecycleUtil(EGG_OBJECT_LIFECYCLE_UTIL_SLOT, 'EggObjectLifecycleUtil'); + +/** Resolve this app's egg-object lifecycle util directly from its bag (no active scope needed). */ +export function eggObjectLifecycleUtilFromBag(bag: TeggScopeBag): LifecycleUtil { + return lifecycleUtilFromBag(bag, EGG_OBJECT_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/runtime/src/model/LoadUnitInstance.ts b/tegg/core/runtime/src/model/LoadUnitInstance.ts index c5cc47771c..b4677e95d3 100644 --- a/tegg/core/runtime/src/model/LoadUnitInstance.ts +++ b/tegg/core/runtime/src/model/LoadUnitInstance.ts @@ -1,5 +1,5 @@ -import { createScopedLifecycleUtil, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; const LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:loadUnitInstanceLifecycleUtil'); @@ -8,3 +8,10 @@ export const LoadUnitInstanceLifecycleUtil: LifecycleUtil { + return lifecycleUtilFromBag(bag, LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap index 1ad04c6be9..7de1eddc4d 100644 --- a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap @@ -8,15 +8,33 @@ exports[`should export stable 1`] = ` "ContextObjectGraph": [Function], "EggAlwaysNewObjectContainer": [Function], "EggContainerFactory": [Function], - "EggContextLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "EggContextLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "EggObjectFactory": [Function], "EggObjectImpl": [Function], - "EggObjectLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "EggObjectLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "EggObjectStatus": { "DESTROYED": "DESTROYED", @@ -27,10 +45,22 @@ exports[`should export stable 1`] = ` }, "EggObjectUtil": [Function], "LoadUnitInstanceFactory": [Function], - "LoadUnitInstanceLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "LoadUnitInstanceLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "ModuleLoadUnitInstance": [Function], + "eggContextLifecycleUtilFromBag": [Function], + "eggObjectLifecycleUtilFromBag": [Function], + "loadUnitInstanceLifecycleUtilFromBag": [Function], } `; diff --git a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap index 009a3c8e81..17641c1a71 100644 --- a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap @@ -233,6 +233,7 @@ exports[`should export stable 1`] = ` }, }, "createScopedLifecycleUtil": [Function], + "lifecycleUtilFromBag": [Function], "orm": { "Attribute": [Function], "AttributeMeta": [Function], diff --git a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap index 10a4332c53..8cce458575 100644 --- a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap @@ -13,9 +13,18 @@ exports[`should helper exports stable 1`] = ` "ContextObjectGraph": [Function], "EggAlwaysNewObjectContainer": [Function], "EggContainerFactory": [Function], - "EggContextLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "EggContextLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "EggLoadUnitType": { "APP": "APP", @@ -24,9 +33,18 @@ exports[`should helper exports stable 1`] = ` }, "EggObjectFactory": [Function], "EggObjectImpl": [Function], - "EggObjectLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "EggObjectLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "EggObjectStatus": { "DESTROYED": "DESTROYED", @@ -40,9 +58,18 @@ exports[`should helper exports stable 1`] = ` "EggPrototypeCreatorFactory": [Function], "EggPrototypeFactory": [Function], "EggPrototypeImpl": [Function], - "EggPrototypeLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "EggPrototypeLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "EggPrototypeNotFound": [Function], "ErrorCodes": { @@ -60,13 +87,31 @@ exports[`should helper exports stable 1`] = ` "IncompatibleProtoInject": [Function], "LoadUnitFactory": [Function], "LoadUnitInstanceFactory": [Function], - "LoadUnitInstanceLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "LoadUnitInstanceLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, - "LoadUnitLifecycleUtil": bound LifecycleUtil { - "lifecycleSet": Set {}, - "objLifecycleSet": Map {}, + "LoadUnitLifecycleUtil": { + "clearObjectLifecycle": [Function], + "deleteLifecycle": [Function], + "deleteObjectLifecycle": [Function], + "getLifecycleHook": [Function], + "getLifecycleList": [Function], + "getObjectLifecycleList": [Function], + "objectPostCreate": [Function], + "objectPreCreate": [Function], + "objectPreDestroy": [Function], + "registerLifecycle": [Function], + "registerObjectLifecycle": [Function], }, "LoadUnitMultiInstanceProtoHook": [Function], "LoaderFactory": [Function], @@ -97,5 +142,10 @@ exports[`should helper exports stable 1`] = ` "TEGG_MANIFEST_KEY": "tegg", "TeggError": [Function], "TimerUtil": [Function], + "eggContextLifecycleUtilFromBag": [Function], + "eggObjectLifecycleUtilFromBag": [Function], + "eggPrototypeLifecycleUtilFromBag": [Function], + "loadUnitInstanceLifecycleUtilFromBag": [Function], + "loadUnitLifecycleUtilFromBag": [Function], } `; diff --git a/tegg/core/types/src/scope/TeggScope.ts b/tegg/core/types/src/scope/TeggScope.ts index c80aab324f..ada4b97a19 100644 --- a/tegg/core/types/src/scope/TeggScope.ts +++ b/tegg/core/types/src/scope/TeggScope.ts @@ -118,10 +118,16 @@ export class TeggScope { * consistent in both scoped and default modes. */ static resolve(slot: symbol, create: () => T, desc: string): T { - if (!als.getStore() && TeggScope.isMultiApp) { + // Single `getStore()`; the in-scope path (the common case) never reaches the + // escape check. Single-app never escapes (isMultiApp is false). + const bag = als.getStore(); + if (bag) { + return TeggScope.#getOrCreate(bag, slot, create); + } + if (TeggScope.isMultiApp) { reportEscape(desc); } - return TeggScope.#getOrCreate(TeggScope.#activeBag(), slot, create); + return TeggScope.#getOrCreate((defaultBag ??= new Map()), slot, create); } /** @@ -130,11 +136,15 @@ export class TeggScope { * source of truth; `legacy()` only supplies the initial value before any set. */ static getOr(slot: symbol, legacy: () => T | undefined, desc: string): T | undefined { - if (!als.getStore() && TeggScope.isMultiApp) { + const bag = als.getStore(); + if (bag) { + return bag.has(slot) ? (bag.get(slot) as T) : legacy(); + } + if (TeggScope.isMultiApp) { reportEscape(desc); } - const bag = TeggScope.#activeBag(); - return bag.has(slot) ? (bag.get(slot) as T) : legacy(); + const d = (defaultBag ??= new Map()); + return d.has(slot) ? (d.get(slot) as T) : legacy(); } /** diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index a917da2b68..5c9fa8f5cc 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -9,7 +9,6 @@ import { pointCutGraphHook, } from '@eggjs/aop-runtime'; import { GlobalGraph } from '@eggjs/metadata'; -import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { AopContextHook } from './lib/AopContextHook.ts'; @@ -31,30 +30,27 @@ export default class AopAppHook implements ILifecycleBoot { } configDidLoad(): void { - TeggScope.run(this.app._teggScopeBag, () => { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); - this.app.eggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); - }); + // app.*LifecycleUtil getters are pinned to this app's scope bag, so hook + // registration does not need a TeggScope.run wrapper. + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); + this.app.eggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); } async didLoad(): Promise { await this.app.moduleHandler.ready(); - await TeggScope.run(this.app._teggScopeBag, async () => { - assert(GlobalGraph.instance, 'GlobalGraph.instance is not set'); - GlobalGraph.instance.registerBuildHook(crossCutGraphHook); - GlobalGraph.instance.registerBuildHook(pointCutGraphHook); - this.aopContextHook = new AopContextHook(this.app.moduleHandler); - this.app.eggContextLifecycleUtil.registerLifecycle(this.aopContextHook); - }); + const globalGraph = GlobalGraph.instanceFor(this.app._teggScopeBag); + assert(globalGraph, 'GlobalGraph.instance is not set'); + globalGraph.registerBuildHook(crossCutGraphHook); + globalGraph.registerBuildHook(pointCutGraphHook); + this.aopContextHook = new AopContextHook(this.app.moduleHandler); + this.app.eggContextLifecycleUtil.registerLifecycle(this.aopContextHook); } async beforeClose(): Promise { - await TeggScope.run(this.app._teggScopeBag, async () => { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eggPrototypeCrossCutHook); - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitAopHook); - this.app.eggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook); - this.app.eggContextLifecycleUtil.deleteLifecycle(this.aopContextHook); - }); + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eggPrototypeCrossCutHook); + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitAopHook); + this.app.eggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook); + this.app.eggContextLifecycleUtil.deleteLifecycle(this.aopContextHook); } } diff --git a/tegg/plugin/controller/src/app.ts b/tegg/plugin/controller/src/app.ts index 320755aba0..5f9b43f3fb 100644 --- a/tegg/plugin/controller/src/app.ts +++ b/tegg/plugin/controller/src/app.ts @@ -166,9 +166,8 @@ export default class ControllerAppBootHook implements ILifecycleBoot { } configDidLoad(): void { - TeggScope.run(this.app._teggScopeBag, () => { - GlobalGraph.instance?.registerBuildHook(middlewareGraphHook); - }); + // Pin to this app's graph (set later during didLoad) — no run wrap needed. + GlobalGraph.instanceFor(this.app._teggScopeBag)?.registerBuildHook(middlewareGraphHook); } async willReady(): Promise { diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index 5ce4243c75..56a53f32ce 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -22,24 +22,24 @@ export default class DalAppBootHook implements ILifecycleBoot { this.dalModuleLoadUnitHook = new DalModuleLoadUnitHook(this.app.config.env, this.app.moduleConfigs); this.dalTableEggPrototypeHook = new DalTableEggPrototypeHook(this.app.logger); this.transactionPrototypeHook = new TransactionPrototypeHook(this.app.moduleConfigs, this.app.logger); - TeggScope.run(this.app._teggScopeBag, () => { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.dalTableEggPrototypeHook); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.transactionPrototypeHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); - }); + // app.*LifecycleUtil getters are pinned to this app's scope bag — no run wrap needed. + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.dalTableEggPrototypeHook); + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.transactionPrototypeHook); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); } async beforeClose(): Promise { + if (this.dalTableEggPrototypeHook) { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.dalTableEggPrototypeHook); + } + if (this.dalModuleLoadUnitHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.dalModuleLoadUnitHook); + } + if (this.transactionPrototypeHook) { + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.transactionPrototypeHook); + } + // The per-app DAL managers are resolved/cleared within this app's scope. await TeggScope.run(this.app._teggScopeBag, async () => { - if (this.dalTableEggPrototypeHook) { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.dalTableEggPrototypeHook); - } - if (this.dalModuleLoadUnitHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.dalModuleLoadUnitHook); - } - if (this.transactionPrototypeHook) { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.transactionPrototypeHook); - } MysqlDataSourceManager.instance.clear(); SqlMapManager.instance.clear(); TableModelManager.instance.clear(); diff --git a/tegg/plugin/eventbus/src/app.ts b/tegg/plugin/eventbus/src/app.ts index cacbb0f5b6..7153df4fbf 100644 --- a/tegg/plugin/eventbus/src/app.ts +++ b/tegg/plugin/eventbus/src/app.ts @@ -1,4 +1,3 @@ -import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { EventbusLoadUnitHook } from './lib/EventbusLoadUnitHook.ts'; @@ -19,25 +18,20 @@ export default class EventbusAppHook implements ILifecycleBoot { } configDidLoad(): void { - TeggScope.run(this.app._teggScopeBag, () => { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eventbusProtoHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.eventbusLoadUnitHook); - }); + // app.*LifecycleUtil getters are pinned to this app's scope bag — no run wrap needed. + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eventbusProtoHook); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.eventbusLoadUnitHook); } async didLoad(): Promise { await this.app.moduleHandler.ready(); - // register() resolves the per-app EventHandler/EventContext singletons and - // installs the per-app context creator — must run in this app's scope. - await TeggScope.run(this.app._teggScopeBag, async () => { - await this.eventHandlerProtoManager.register(); - }); + // register() resolves the per-app singletons through app.getEggObject (which + // wraps in this app's scope itself), so no outer run wrap is needed. + await this.eventHandlerProtoManager.register(); } async beforeClose(): Promise { - await TeggScope.run(this.app._teggScopeBag, async () => { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eventbusProtoHook); - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eventbusLoadUnitHook); - }); + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eventbusProtoHook); + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eventbusLoadUnitHook); } } diff --git a/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts b/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts index 48395d1c03..0252b395bf 100644 --- a/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts +++ b/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts @@ -13,7 +13,10 @@ export class EggContextEventBus implements ContextEventBus { private corkId?: string; constructor(ctx: Context) { - const proto = PrototypeUtil.getClazzProto(SingletonEventBus) as EggPrototype; + // Prefer this app's class->proto map (multi-app safe); the global + // PrototypeUtil.getClazzProto is shared and overwritten by concurrent apps. + const proto = (ctx.app.eggPrototypeFactory.getPrototypeByClazz(SingletonEventBus) ?? + PrototypeUtil.getClazzProto(SingletonEventBus)) as EggPrototype; const eggObject = ctx.app.eggContainerFactory.getEggObject(proto, proto.name); this.context = ContextHandler.getContext()!; this.eventBus = eggObject.obj as SingletonEventBus; diff --git a/tegg/plugin/langchain/src/app.ts b/tegg/plugin/langchain/src/app.ts index da3d849d80..4d6edb2d1b 100644 --- a/tegg/plugin/langchain/src/app.ts +++ b/tegg/plugin/langchain/src/app.ts @@ -1,4 +1,3 @@ -import { TeggScope } from '@eggjs/tegg-types'; import type { Application, IBoot } from 'egg'; import { BoundModelObjectHook } from './lib/boundModel/BoundModelObjectHook.ts'; @@ -27,17 +26,16 @@ export default class ModuleLangChainHook implements IBoot { } configWillLoad(): void { - // Lifecycle-util registrations must land in THIS app's scope. - TeggScope.run(this.#app._teggScopeBag, () => { - this.#app.loadUnitLifecycleUtil.registerLifecycle(this.#graphLoadUnitHook); - this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#graphObjectHook); - this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#boundModelObjectHook); - this.#app.eggObjectFactory.registerEggObjectCreateMethod( - CompiledStateGraphProto as any, - CompiledStateGraphObject.createObject, - ); - this.#app.eggPrototypeLifecycleUtil.registerLifecycle(this.#graphPrototypeHook); - }); + // app.*LifecycleUtil getters are pinned to this app's scope bag, and + // registerEggObjectCreateMethod is a shared static registry — no run wrap needed. + this.#app.loadUnitLifecycleUtil.registerLifecycle(this.#graphLoadUnitHook); + this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#graphObjectHook); + this.#app.eggObjectLifecycleUtil.registerLifecycle(this.#boundModelObjectHook); + this.#app.eggObjectFactory.registerEggObjectCreateMethod( + CompiledStateGraphProto as any, + CompiledStateGraphObject.createObject, + ); + this.#app.eggPrototypeLifecycleUtil.registerLifecycle(this.#graphPrototypeHook); } configDidLoad(): void { @@ -45,11 +43,9 @@ export default class ModuleLangChainHook implements IBoot { } async beforeClose(): Promise { - await TeggScope.run(this.#app._teggScopeBag, async () => { - this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#graphObjectHook); - this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#boundModelObjectHook); - this.#app.loadUnitLifecycleUtil.deleteLifecycle(this.#graphLoadUnitHook); - this.#app.eggPrototypeLifecycleUtil.deleteLifecycle(this.#graphPrototypeHook); - }); + this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#graphObjectHook); + this.#app.eggObjectLifecycleUtil.deleteLifecycle(this.#boundModelObjectHook); + this.#app.loadUnitLifecycleUtil.deleteLifecycle(this.#graphLoadUnitHook); + this.#app.eggPrototypeLifecycleUtil.deleteLifecycle(this.#graphPrototypeHook); } } diff --git a/tegg/plugin/orm/src/app.ts b/tegg/plugin/orm/src/app.ts index dc9d5c3542..9b7d574bf9 100644 --- a/tegg/plugin/orm/src/app.ts +++ b/tegg/plugin/orm/src/app.ts @@ -33,12 +33,11 @@ export default class OrmAppBootHook implements ILifecycleBoot { } configWillLoad(): void { - // Lifecycle-util registrations must land in THIS app's scope. - TeggScope.run(this.app._teggScopeBag, () => { - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.modelProtoHook); - this.app.eggObjectFactory.registerEggObjectCreateMethod(SingletonModelProto, SingletonModelObject.createObject); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.ormLoadUnitHook); - }); + // app.*LifecycleUtil getters are pinned to this app's scope bag, and + // registerEggObjectCreateMethod is a shared static registry — no run wrap needed. + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.modelProtoHook); + this.app.eggObjectFactory.registerEggObjectCreateMethod(SingletonModelProto, SingletonModelObject.createObject); + this.app.loadUnitLifecycleUtil.registerLifecycle(this.ormLoadUnitHook); } configDidLoad(): void { @@ -60,8 +59,6 @@ export default class OrmAppBootHook implements ILifecycleBoot { } async beforeClose(): Promise { - await TeggScope.run(this.app._teggScopeBag, async () => { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.modelProtoHook); - }); + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.modelProtoHook); } } diff --git a/tegg/plugin/schedule/package.json b/tegg/plugin/schedule/package.json index a60ae1b483..533e7af473 100644 --- a/tegg/plugin/schedule/package.json +++ b/tegg/plugin/schedule/package.json @@ -68,7 +68,6 @@ "@eggjs/schedule-decorator": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", - "@eggjs/tegg-types": "workspace:*", "@eggjs/utils": "workspace:*" }, "devDependencies": { diff --git a/tegg/plugin/schedule/src/app.ts b/tegg/plugin/schedule/src/app.ts index 10ddf34aea..70975935ed 100644 --- a/tegg/plugin/schedule/src/app.ts +++ b/tegg/plugin/schedule/src/app.ts @@ -1,4 +1,3 @@ -import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { ScheduleManager } from './lib/ScheduleManager.ts'; @@ -22,19 +21,16 @@ export default class ScheduleAppBootHook implements ILifecycleBoot { } configWillLoad(): void { - TeggScope.run(this.app._teggScopeBag, () => { - this.app.loadUnitLifecycleUtil.registerLifecycle(this.scheduleWorkerLoadUnitHook); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.schedulePrototypeHook); - }); + // app.*LifecycleUtil getters are pinned to this app's scope bag — no run wrap needed. + this.app.loadUnitLifecycleUtil.registerLifecycle(this.scheduleWorkerLoadUnitHook); + this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.schedulePrototypeHook); } async beforeClose(): Promise { // Unregister all schedules before deleting lifecycle hooks this.scheduleManager.unregisterAll(); - await TeggScope.run(this.app._teggScopeBag, async () => { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.scheduleWorkerLoadUnitHook); - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.schedulePrototypeHook); - }); + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.scheduleWorkerLoadUnitHook); + this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.schedulePrototypeHook); } } diff --git a/tegg/plugin/tegg/src/app/extend/application.ts b/tegg/plugin/tegg/src/app/extend/application.ts index 49a6c1c89c..eb1eff6341 100644 --- a/tegg/plugin/tegg/src/app/extend/application.ts +++ b/tegg/plugin/tegg/src/app/extend/application.ts @@ -4,8 +4,10 @@ import { EggPrototypeCreatorFactory, EggPrototypeFactory, EggPrototypeLifecycleUtil, + eggPrototypeLifecycleUtilFromBag, LoadUnitFactory, LoadUnitLifecycleUtil, + loadUnitLifecycleUtilFromBag, } from '@eggjs/metadata'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { @@ -14,8 +16,11 @@ import { EggObjectFactory, LoadUnitInstanceFactory, EggContextLifecycleUtil, + eggContextLifecycleUtilFromBag, EggObjectLifecycleUtil, + eggObjectLifecycleUtilFromBag, LoadUnitInstanceLifecycleUtil, + loadUnitInstanceLifecycleUtilFromBag, } from '@eggjs/tegg-runtime'; import type { RuntimeConfig } from '@eggjs/tegg-types'; import { TeggScope } from '@eggjs/tegg-types'; @@ -35,7 +40,7 @@ export default class TEggPluginApplication { } get loadUnitLifecycleUtil(): typeof LoadUnitLifecycleUtil { - return LoadUnitLifecycleUtil; + return loadUnitLifecycleUtilFromBag((this as unknown as Application)._teggScopeBag); } get loadUnitFactory(): typeof LoadUnitFactory { @@ -51,7 +56,7 @@ export default class TEggPluginApplication { } get loadUnitInstanceLifecycleUtil(): typeof LoadUnitInstanceLifecycleUtil { - return LoadUnitInstanceLifecycleUtil; + return loadUnitInstanceLifecycleUtilFromBag((this as unknown as Application)._teggScopeBag); } get eggContainerFactory(): typeof EggContainerFactory { @@ -63,15 +68,15 @@ export default class TEggPluginApplication { } get eggPrototypeLifecycleUtil(): typeof EggPrototypeLifecycleUtil { - return EggPrototypeLifecycleUtil; + return eggPrototypeLifecycleUtilFromBag((this as unknown as Application)._teggScopeBag); } get eggContextLifecycleUtil(): typeof EggContextLifecycleUtil { - return EggContextLifecycleUtil; + return eggContextLifecycleUtilFromBag((this as unknown as Application)._teggScopeBag); } get eggObjectLifecycleUtil(): typeof EggObjectLifecycleUtil { - return EggObjectLifecycleUtil; + return eggObjectLifecycleUtilFromBag((this as unknown as Application)._teggScopeBag); } get abstractEggContext(): typeof AbstractEggContext { From babd8e165db1660a59f54971d8465e5b9125aee2 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 00:28:08 +0800 Subject: [PATCH 04/16] fix(tegg): drop unused @eggjs/tegg-types dep from aop/eventbus plugins The aop and eventbus plugins declared @eggjs/tegg-types in dependencies but never import it (aop resolves the per-app graph via app._teggScopeBag and GlobalGraph from @eggjs/metadata; eventbus scopes via app.getEggObject / eggPrototypeFactory.getPrototypeByClazz). tsdown's unplugin-unused fails the build with 'Unused 1 dependencies found', which broke the cnpmcore/examples E2E build step. Remove the dead dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/plugin/aop/package.json | 3 +-- tegg/plugin/eventbus/package.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tegg/plugin/aop/package.json b/tegg/plugin/aop/package.json index f5ce18f452..f8bfccdc7d 100644 --- a/tegg/plugin/aop/package.json +++ b/tegg/plugin/aop/package.json @@ -52,8 +52,7 @@ "@eggjs/lifecycle": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/module-common": "workspace:*", - "@eggjs/tegg-runtime": "workspace:*", - "@eggjs/tegg-types": "workspace:*" + "@eggjs/tegg-runtime": "workspace:*" }, "devDependencies": { "@eggjs/mock": "workspace:*", diff --git a/tegg/plugin/eventbus/package.json b/tegg/plugin/eventbus/package.json index c4d05a93af..8e4359033b 100644 --- a/tegg/plugin/eventbus/package.json +++ b/tegg/plugin/eventbus/package.json @@ -64,8 +64,7 @@ "@eggjs/lifecycle": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/module-common": "workspace:*", - "@eggjs/tegg-runtime": "workspace:*", - "@eggjs/tegg-types": "workspace:*" + "@eggjs/tegg-runtime": "workspace:*" }, "devDependencies": { "@eggjs/mock": "workspace:*", From 70e71d1f41fbc4c870bdcb5a581c9970f49b7c16 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 01:01:09 +0800 Subject: [PATCH 05/16] fix(tegg): resolve single-app state outside an explicit scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-app state (factories, GlobalGraph, the ContextHandler request-context callbacks, …) is installed into app._teggScopeBag at boot. But code that runs OUTSIDE an explicit TeggScope.run — a SingletonProto method called directly, a detached leoric SQL logger, a ContextProto method invoked in a test body — resolved the SEPARATE process-default bag, which never held that state. Boot state was therefore invisible to single-app call sites that did not go through a wrapped entry point. Pre-scoping these were process-global statics, so they were reachable from anywhere after boot. Restore that for single-app while keeping multi-app isolation: when EXACTLY ONE app is alive, the no-scope fallback now resolves to that app's bag (TeggScope tracks live scope bags in a Set instead of a bare counter). Zero apps (pure core unit tests / standalone helpers) or true multi-app keep using the process-default bag, and multi-app out-of-scope access still trips the escape fuse. Also make ContextHandler.getContext() return undefined instead of asserting when no callback is installed: 'no active context' is a valid read result (e.g. a singleton service or detached logger called with no request in flight). Multi-app escape detection is handled by the TeggScope fuse, not this assert. Fixes the @eggjs/orm-plugin regressions where leoric's SQL logger threw 'getContextCallback not set' (swallowed by leoric) so the SQL line was never logged, and the context-proto tests saw an undefined ctx. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/runtime/src/model/ContextHandler.ts | 7 +- tegg/core/types/src/scope/TeggScope.ts | 76 ++++++++++++------- tegg/plugin/tegg/src/app.ts | 6 +- tegg/standalone/standalone/src/Runner.ts | 4 +- 4 files changed, 59 insertions(+), 34 deletions(-) diff --git a/tegg/core/runtime/src/model/ContextHandler.ts b/tegg/core/runtime/src/model/ContextHandler.ts index 9c248e5332..c9d4a0782f 100644 --- a/tegg/core/runtime/src/model/ContextHandler.ts +++ b/tegg/core/runtime/src/model/ContextHandler.ts @@ -40,8 +40,13 @@ export class ContextHandler { } static getContext(): EggRuntimeContext | undefined { + // No installed callback means there is no active app/request context to + // read (e.g. a singleton service or detached logger called outside any + // scope). That is a valid "no context" state, so resolve to undefined + // instead of throwing — matching the pre-scoping global behavior where the + // process-wide callback stayed set after boot. Multi-app escape detection + // is handled by the TeggScope fuse on `callbacks()`, not by this assert. const cb = callbacks().getContextCallback; - assert(cb, 'getContextCallback not set'); return cb ? cb() : undefined; } diff --git a/tegg/core/types/src/scope/TeggScope.ts b/tegg/core/types/src/scope/TeggScope.ts index ada4b97a19..0337091f3c 100644 --- a/tegg/core/types/src/scope/TeggScope.ts +++ b/tegg/core/types/src/scope/TeggScope.ts @@ -16,23 +16,29 @@ export type TeggScopeBag = Map; const als = new AsyncLocalStorage(); /** - * Number of explicitly-established app scopes currently alive in the process. - * Incremented by every tegg app/Runner at boot ({@link TeggScope.registerScope}) - * and decremented at close ({@link TeggScope.unregisterScope}). + * Bags of the app scopes currently alive in the process. Each tegg app/Runner + * adds its bag at boot ({@link TeggScope.registerScope}) and removes it at close + * ({@link TeggScope.unregisterScope}). * - * Drives the strict-mode escape fuse: while MORE THAN ONE app is alive (true - * multi-app), falling back to the process-default bag is treated as a scope - * escape bug. With a single app (or none) the silent lazy default is kept so - * existing single-app code and tests are unaffected. + * The set size is the live-app count that drives the strict-mode escape fuse: + * while MORE THAN ONE app is alive (true multi-app), falling back to the + * process-default bag is treated as a scope escape bug. When EXACTLY ONE app is + * alive, that app's bag is also the no-scope fallback (see {@link + * TeggScope.#fallbackBag}) — single-app code running outside an explicit + * {@link TeggScope.run} (direct singleton/ContextProto calls, detached loggers) + * resolves the very slots the app populated at boot, matching the pre-scoping + * process-global behavior. With a single app (or none) the silent lazy default + * is kept so existing single-app code and tests are unaffected. */ -let explicitScopeCount = 0; +const liveScopeBags = new Set(); /** - * Process-wide default bag, used when no ALS scope is active. In single-app mode - * this IS the app's effective storage (the lazy default). Created on demand so - * every fallback slot lands in the SAME bag and stays cross-consistent (e.g. a - * factory created in the default bag references the lifecycle util also in the - * default bag). Tests may install/reset it explicitly. + * Process-wide default bag, used when NO app scope is the active/sole storage, + * i.e. zero apps alive (pure tegg-core unit tests / standalone helpers) or, under + * multi-app, the escape path. Created on demand so every fallback slot lands in + * the SAME bag and stays cross-consistent (e.g. a factory created in the default + * bag references the lifecycle util also in the default bag). Tests may + * install/reset it explicitly. */ let defaultBag: TeggScopeBag | undefined; @@ -83,26 +89,43 @@ export class TeggScope { return new Map(); } - /** Mark that an explicit per-app scope has been established (boot). */ - static registerScope(): void { - explicitScopeCount++; + /** + * Mark that an explicit per-app scope has been established (boot), tracking the + * app's bag so that — while it is the sole live app — no-scope access resolves + * to it instead of a separate process-default bag. + */ + static registerScope(bag: TeggScopeBag): void { + liveScopeBags.add(bag); } /** Mark that a previously-established per-app scope has been torn down (close). */ - static unregisterScope(): void { - if (explicitScopeCount > 0) { - explicitScopeCount--; - } + static unregisterScope(bag: TeggScopeBag): void { + liveScopeBags.delete(bag); } /** True when more than one app scope is alive — genuine multi-app mode. */ static get isMultiApp(): boolean { - return explicitScopeCount > 1; + return liveScopeBags.size > 1; } /** Number of live explicit app scopes. */ static get scopeCount(): number { - return explicitScopeCount; + return liveScopeBags.size; + } + + /** + * The bag used when NO ALS scope is active. With exactly one app alive, that + * app's bag is the single-app effective storage, so out-of-scope access sees + * the slots boot populated. Zero apps (pure unit tests / standalone helpers) — + * or, under multi-app, the reported escape path — use the process-default bag. + */ + static #fallbackBag(): TeggScopeBag { + if (liveScopeBags.size === 1) { + for (const bag of liveScopeBags) { + return bag; + } + } + return (defaultBag ??= new Map()); } /** @@ -127,7 +150,7 @@ export class TeggScope { if (TeggScope.isMultiApp) { reportEscape(desc); } - return TeggScope.#getOrCreate((defaultBag ??= new Map()), slot, create); + return TeggScope.#getOrCreate(TeggScope.#fallbackBag(), slot, create); } /** @@ -143,7 +166,7 @@ export class TeggScope { if (TeggScope.isMultiApp) { reportEscape(desc); } - const d = (defaultBag ??= new Map()); + const d = TeggScope.#fallbackBag(); return d.has(slot) ? (d.get(slot) as T) : legacy(); } @@ -186,10 +209,7 @@ export class TeggScope { if (store) { return store; } - if (!defaultBag) { - defaultBag = new Map(); - } - return defaultBag; + return TeggScope.#fallbackBag(); } static #getOrCreate(bag: TeggScopeBag, slot: symbol, create: () => T): T { diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 02d027d447..e76b2dabc7 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -33,7 +33,7 @@ export default class TeggAppBoot implements ILifecycleBoot { // (controller/aop/dal/eventbus) boots, so their boot hooks can resolve the // per-app factories/managers from app._teggScopeBag. this.app._teggScopeBag = TeggScope.createBag(); - TeggScope.registerScope(); + TeggScope.registerScope(this.app._teggScopeBag); this.app.config.coreMiddleware.push('teggCtxLifecycleMiddleware'); } @@ -95,7 +95,7 @@ export default class TeggAppBoot implements ILifecycleBoot { LoadUnitMultiInstanceProtoHook.clear(); }); // The whole per-app scope (bag) is dropped with the app; release the scope - // counter so the strict-mode escape fuse reflects the live app count. - TeggScope.unregisterScope(); + // so the strict-mode escape fuse reflects the live app count. + TeggScope.unregisterScope(this.app._teggScopeBag); } } diff --git a/tegg/standalone/standalone/src/Runner.ts b/tegg/standalone/standalone/src/Runner.ts index 53c718f28d..7245100d8a 100644 --- a/tegg/standalone/standalone/src/Runner.ts +++ b/tegg/standalone/standalone/src/Runner.ts @@ -107,7 +107,7 @@ export class Runner { this.name = options?.name; this.options = options; this.scopeBag = TeggScope.createBag(); - TeggScope.registerScope(); + TeggScope.registerScope(this.scopeBag); this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); this.moduleConfigs = {}; TeggScope.run(this.scopeBag, () => { @@ -315,7 +315,7 @@ export class Runner { await TeggScope.run(this.scopeBag, async () => { await this.doDestroy(); }); - TeggScope.unregisterScope(); + TeggScope.unregisterScope(this.scopeBag); } private async doDestroy(): Promise { From 30caefbb48e7a9aa1ccbc992a9f17cad6bb8012e Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 01:04:49 +0800 Subject: [PATCH 06/16] refactor(tegg): address review feedback on TeggScope facades - TeggScope.set: report an escape when it writes to the process-default bag under multi-app with no active scope, matching resolve()/getOr() so a missed scope wrap that mutates shared state is caught. - GlobalGraph / HTTPControllerRegister / MCPControllerRegister: drop the dead #legacyInstance fallback. TeggScope.set always returns true and writes to the active/sole-app/default bag, so the 'if (!set(...)) #legacyInstance = value' branch never ran and the getter's legacy() was always undefined. The bag is the single source of truth; simplify the getter/setter accordingly. - context.ts getEggObject/getEggObjectFromName: guard app._teggScopeBag before TeggScope.run, consistent with application.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/metadata/src/model/graph/GlobalGraph.ts | 16 ++++++---------- tegg/core/types/src/scope/TeggScope.ts | 7 +++++++ .../src/lib/impl/http/HTTPControllerRegister.ts | 12 ++---------- .../src/lib/impl/mcp/MCPControllerRegister.ts | 12 ++---------- tegg/plugin/tegg/src/app/extend/context.ts | 14 ++++++++++---- 5 files changed, 27 insertions(+), 34 deletions(-) diff --git a/tegg/core/metadata/src/model/graph/GlobalGraph.ts b/tegg/core/metadata/src/model/graph/GlobalGraph.ts index c5cc64ec27..917c16c6ba 100644 --- a/tegg/core/metadata/src/model/graph/GlobalGraph.ts +++ b/tegg/core/metadata/src/model/graph/GlobalGraph.ts @@ -71,21 +71,17 @@ export class GlobalGraph { private buildHooks: GlobalGraphBuildHook[]; /** - * The per-app graph instance used in ModuleLoadUnit, backed by TeggScope. - * In a scope, the active app's bag is the source of truth (undefined until the - * loader assigns it during boot); with no scope, falls back to a module-level - * legacy var (single-app / metadata-only paths). Call sites stay unchanged. + * The per-app graph instance used in ModuleLoadUnit, backed by TeggScope: the + * active app's bag (or, with no scope, the sole-app / process-default bag) is + * the single source of truth — undefined until the loader assigns it during + * boot. Call sites stay unchanged. */ - static #legacyInstance?: GlobalGraph; - static get instance(): GlobalGraph | undefined { - return TeggScope.getOr(GLOBAL_GRAPH_SLOT, () => GlobalGraph.#legacyInstance, 'GlobalGraph.instance'); + return TeggScope.getOr(GLOBAL_GRAPH_SLOT, () => undefined, 'GlobalGraph.instance'); } static set instance(value: GlobalGraph | undefined) { - if (!TeggScope.set(GLOBAL_GRAPH_SLOT, value)) { - GlobalGraph.#legacyInstance = value; - } + TeggScope.set(GLOBAL_GRAPH_SLOT, value); } /** diff --git a/tegg/core/types/src/scope/TeggScope.ts b/tegg/core/types/src/scope/TeggScope.ts index 0337091f3c..b596f88c62 100644 --- a/tegg/core/types/src/scope/TeggScope.ts +++ b/tegg/core/types/src/scope/TeggScope.ts @@ -173,8 +173,15 @@ export class TeggScope { /** * Write a slot into the active bag (scoped, or the single process-default bag). * Always succeeds; returns true for symmetry with earlier call sites. + * + * Like {@link TeggScope.resolve} / {@link TeggScope.getOr}, a write that escapes + * to the process-default bag under multi-app mode is reported, so a missed + * scope wrap that silently mutates shared state is caught instead of leaking. */ static set(slot: symbol, value: unknown): boolean { + if (!als.getStore() && TeggScope.isMultiApp) { + reportEscape(slot.toString()); + } TeggScope.#activeBag().set(slot, value); return true; } diff --git a/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts b/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts index db8e485d65..2b11d24d5d 100644 --- a/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/http/HTTPControllerRegister.ts @@ -21,20 +21,12 @@ const HTTP_CONTROLLER_REGISTER_SLOT = Symbol('tegg:controller:httpControllerRegi export class HTTPControllerRegister implements ControllerRegister { // Per-app: the register accumulates protos and binds to one app's router, so // it must be per-app (resolved from the active TeggScope bag). - static #legacyInstance?: HTTPControllerRegister; - static get instance(): HTTPControllerRegister | undefined { - return TeggScope.getOr( - HTTP_CONTROLLER_REGISTER_SLOT, - () => HTTPControllerRegister.#legacyInstance, - 'HTTPControllerRegister.instance', - ); + return TeggScope.getOr(HTTP_CONTROLLER_REGISTER_SLOT, () => undefined, 'HTTPControllerRegister.instance'); } static set instance(value: HTTPControllerRegister | undefined) { - if (!TeggScope.set(HTTP_CONTROLLER_REGISTER_SLOT, value)) { - HTTPControllerRegister.#legacyInstance = value; - } + TeggScope.set(HTTP_CONTROLLER_REGISTER_SLOT, value); } private readonly router: Router; diff --git a/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts b/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts index 273cb10346..0d26f4bb06 100644 --- a/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts +++ b/tegg/plugin/controller/src/lib/impl/mcp/MCPControllerRegister.ts @@ -97,20 +97,12 @@ class InnerSSEServerTransport extends SSEServerTransport { export class MCPControllerRegister implements ControllerRegister { // Per-app: holds this app's MCP transports/servers/timers, so it is resolved // from the active TeggScope bag rather than a process-global singleton. - static #legacyInstance?: MCPControllerRegister; - static get instance(): MCPControllerRegister | undefined { - return TeggScope.getOr( - MCP_CONTROLLER_REGISTER_SLOT, - () => MCPControllerRegister.#legacyInstance, - 'MCPControllerRegister.instance', - ); + return TeggScope.getOr(MCP_CONTROLLER_REGISTER_SLOT, () => undefined, 'MCPControllerRegister.instance'); } static set instance(value: MCPControllerRegister | undefined) { - if (!TeggScope.set(MCP_CONTROLLER_REGISTER_SLOT, value)) { - MCPControllerRegister.#legacyInstance = value; - } + TeggScope.set(MCP_CONTROLLER_REGISTER_SLOT, value); } readonly app: Application; diff --git a/tegg/plugin/tegg/src/app/extend/context.ts b/tegg/plugin/tegg/src/app/extend/context.ts index 6c6fe42780..891c5c4faf 100644 --- a/tegg/plugin/tegg/src/app/extend/context.ts +++ b/tegg/plugin/tegg/src/app/extend/context.ts @@ -26,10 +26,14 @@ export default class TEggPluginContext { // Run within this app's scope so proto resolution uses the per-app class→proto // map (multi-app safe) and ContextHandler/factories resolve the right app — // even when called outside a request (e.g. the tegg-vitest runner). - return TeggScope.run(app._teggScopeBag, async () => { + const bag = app._teggScopeBag; + const doWork = async (): Promise => { const eggObject = await app.eggContainerFactory.getOrCreateEggObjectFromClazz(clazz as EggProtoImplClass, name); return eggObject.obj as T; - }); + }; + // Defensive (consistent with application.ts): fall back to the ambient scope + // if the bag is not yet established. + return bag ? TeggScope.run(bag, doWork) : doWork(); } async getEggObjectFromName(this: Context, name: string, qualifiers?: QualifierInfo | QualifierInfo[]): Promise { @@ -37,9 +41,11 @@ export default class TEggPluginContext { qualifiers = Array.isArray(qualifiers) ? qualifiers : [qualifiers]; } const app = this.app; - return TeggScope.run(app._teggScopeBag, async () => { + const bag = app._teggScopeBag; + const doWork = async (): Promise => { const eggObject = await app.eggContainerFactory.getOrCreateEggObjectFromName(name, qualifiers as QualifierInfo[]); return eggObject.obj as T; - }); + }; + return bag ? TeggScope.run(bag, doWork) : doWork(); } } From 87cc0891fe60c4ff16232013348fd6e1495937c0 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 01:40:46 +0800 Subject: [PATCH 07/16] test(tegg): cover TeggScope scope/fallback/escape behavior Add unit coverage for the multi-app scope primitive: run/current, live-scope tracking + isMultiApp, resolve memoization, the no-scope fallbacks (zero apps -> process-default bag; exactly one app -> that app's bag so out-of-scope access sees boot state), getOr legacy fallback, set, and the strict-mode escape fuse that throws on out-of-scope access under multi-app. Regression coverage for the single-app-state fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/types/test/TeggScope.test.ts | 140 +++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tegg/core/types/test/TeggScope.test.ts diff --git a/tegg/core/types/test/TeggScope.test.ts b/tegg/core/types/test/TeggScope.test.ts new file mode 100644 index 0000000000..adb6c0ee90 --- /dev/null +++ b/tegg/core/types/test/TeggScope.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { TeggScope, type TeggScopeBag } from '../src/index.ts'; + +// Track scopes registered within a test so afterEach can always return the +// process-global state (live scope bags + default bag) to a clean baseline, +// regardless of how the test exits. +const registered: TeggScopeBag[] = []; + +function register(): TeggScopeBag { + const bag = TeggScope.createBag(); + TeggScope.registerScope(bag); + registered.push(bag); + return bag; +} + +afterEach(() => { + for (const bag of registered.splice(0)) { + TeggScope.unregisterScope(bag); + } + TeggScope._resetDefaultBag(); +}); + +describe('TeggScope', () => { + const SLOT = Symbol('tegg:test:slot'); + + it('run/current expose the active bag', () => { + assert.equal(TeggScope.current(), undefined); + const bag = TeggScope.createBag(); + const seen = TeggScope.run(bag, () => TeggScope.current()); + assert.equal(seen, bag); + // scope ends with the callback + assert.equal(TeggScope.current(), undefined); + }); + + it('tracks live scope count and multi-app mode', () => { + assert.equal(TeggScope.scopeCount, 0); + assert.equal(TeggScope.isMultiApp, false); + register(); + assert.equal(TeggScope.scopeCount, 1); + assert.equal(TeggScope.isMultiApp, false); + register(); + assert.equal(TeggScope.scopeCount, 2); + assert.equal(TeggScope.isMultiApp, true); + }); + + it('resolve memoizes within the active bag', () => { + const bag = TeggScope.createBag(); + let created = 0; + const a = TeggScope.run(bag, () => TeggScope.resolve(SLOT, () => ++created, 'test')); + const b = TeggScope.run(bag, () => TeggScope.resolve(SLOT, () => ++created, 'test')); + assert.equal(a, 1); + assert.equal(b, 1); + assert.equal(created, 1); + assert.equal(bag.get(SLOT), 1); + }); + + it('with no scope and no app, resolve/getOr/set use the process-default bag', () => { + assert.equal(TeggScope._getDefaultBag(), undefined); + const v = TeggScope.resolve(SLOT, () => 'created', 'test'); + assert.equal(v, 'created'); + // memoized in the lazily-created default bag + assert.equal(TeggScope._getDefaultBag()?.get(SLOT), 'created'); + assert.equal( + TeggScope.resolve(SLOT, () => 'other', 'test'), + 'created', + ); + + TeggScope.set(SLOT, 'updated'); + assert.equal( + TeggScope.getOr(SLOT, () => 'legacy', 'test'), + 'updated', + ); + }); + + it('getOr returns the legacy fallback only until a value is set', () => { + assert.equal( + TeggScope.getOr(SLOT, () => 'legacy', 'test'), + 'legacy', + ); + TeggScope.set(SLOT, 'real'); + assert.equal( + TeggScope.getOr(SLOT, () => 'legacy', 'test'), + 'real', + ); + }); + + it('with exactly one app alive, out-of-scope access resolves that app bag', () => { + const bag = register(); + // populate the app bag from inside its scope (as boot does) + TeggScope.run(bag, () => { + TeggScope.set(SLOT, 'app-state'); + }); + // ...and read it back WITHOUT an active scope (single-app fallback) + assert.equal( + TeggScope.getOr(SLOT, () => undefined, 'test'), + 'app-state', + ); + assert.equal( + TeggScope.resolve(SLOT, () => 'fresh', 'test'), + 'app-state', + ); + // the sole-app bag is the storage, not a separate default bag + assert.equal(TeggScope._getDefaultBag(), undefined); + assert.equal(bag.get(SLOT), 'app-state'); + }); + + it('out-of-scope writes also land in the sole app bag', () => { + const bag = register(); + TeggScope.set(SLOT, 'written-out-of-scope'); + assert.equal(bag.get(SLOT), 'written-out-of-scope'); + assert.equal( + TeggScope.run(bag, () => TeggScope.getOr(SLOT, () => undefined, 'test')), + 'written-out-of-scope', + ); + }); + + describe('strict-mode escape fuse (multi-app)', () => { + it('throws on out-of-scope resolve/getOr/set when more than one app is alive', () => { + register(); + register(); + assert.equal(TeggScope.isMultiApp, true); + expect(() => TeggScope.resolve(SLOT, () => 1, 'resolve')).toThrow(/escaped to the process-default bag/); + expect(() => TeggScope.getOr(SLOT, () => 1, 'getOr')).toThrow(/escaped to the process-default bag/); + expect(() => TeggScope.set(SLOT, 1)).toThrow(/escaped to the process-default bag/); + }); + + it('does not throw for in-scope access under multi-app', () => { + const bag = register(); + register(); + const v = TeggScope.run(bag, () => { + TeggScope.set(SLOT, 'scoped'); + return TeggScope.resolve(SLOT, () => 'fresh', 'resolve'); + }); + assert.equal(v, 'scoped'); + }); + }); +}); From 061f23dca6e565c300f16b768f35098e7e916425 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 08:51:56 +0800 Subject: [PATCH 08/16] refactor(tegg): simplify multi-app scoping boilerplate Behavior-preserving simplifications across the multi-app PR (all verified by the full tegg suite): - TeggScope: add runMaybe(bag, fn) and route the 8 copy-pasted `bag ? run(bag, fn) : fn()` sites through it; inline the single-caller #activeBag into set() (one getStore, mirrors resolve/getOr); drop the unused _setDefaultBag. - EggPrototypeFactory.getPrototypeByClazzOrGlobal: centralize the rule-4 `getPrototypeByClazz(clazz) ?? PrototypeUtil.getClazzProto(clazz)` fallback so the 4 call sites (aop-runtime, dynamic-inject-runtime, runtime, eventbus) stop re-implementing it; drop the now-unused PrototypeUtil imports/casts. - lifecycle: add defineScopedLifecycleUtil(slot, desc) returning [util, fromBag] and collapse the repeated declare-slot + createScopedLifecycleUtil + lifecycleUtilFromBag boilerplate in the 5 model files. - EggPrototypeFactory: use the canonical EggPrototypeWithClazz cast instead of an ad-hoc `as unknown as { clazz }`. - controller: drop the dead willReady MCP block (connectStatelessStreamTransport is a no-op; transports are per-request now) and its scope wrap; remove the redundant ControllerMetadataManager constructor and a self-nested csrf Array.isArray re-test. - standalone Runner: one uniform runInScope() helper across all 5 scope entries. - orm/mcp-proxy: collapse trivial single-statement scope-run closures. Net -56 lines; no public API or runtime behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/aop-runtime/src/LoadUnitAopHook.ts | 5 +-- .../src/EggObjectFactory.ts | 6 +-- .../eventbus-runtime/src/SingletonEventBus.ts | 2 +- .../core/lifecycle/src/ScopedLifecycleUtil.ts | 16 ++++++++ .../test/__snapshots__/index.test.ts.snap | 1 + .../src/factory/EggPrototypeFactory.ts | 24 +++++++++-- tegg/core/metadata/src/model/EggPrototype.ts | 22 +++------- tegg/core/metadata/src/model/LoadUnit.ts | 15 ++----- .../src/factory/EggContainerFactory.ts | 8 +--- tegg/core/runtime/src/model/EggContext.ts | 22 +++------- tegg/core/runtime/src/model/EggObject.ts | 15 ++----- .../runtime/src/model/LoadUnitInstance.ts | 22 +++------- tegg/core/types/src/scope/TeggScope.ts | 41 ++++++++----------- tegg/plugin/controller/src/app.ts | 18 +------- .../src/lib/ControllerMetadataManager.ts | 4 -- .../eventbus/src/lib/EggContextEventBus.ts | 6 +-- tegg/plugin/mcp-proxy/src/app.ts | 4 +- tegg/plugin/orm/src/app.ts | 4 +- .../plugin/tegg/src/app/extend/application.ts | 4 +- .../src/app/extend/application.unittest.ts | 6 +-- tegg/plugin/tegg/src/app/extend/context.ts | 6 +-- tegg/standalone/standalone/src/Runner.ts | 19 +++++---- 22 files changed, 107 insertions(+), 163 deletions(-) diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index 0809c0c8de..6d8ff70f06 100644 --- a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts +++ b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts @@ -1,5 +1,4 @@ import { AspectInfoUtil, AspectMetaBuilder, CrosscutAdviceFactory } from '@eggjs/aop-decorator'; -import { PrototypeUtil } from '@eggjs/core-decorator'; import { EggPrototypeFactory, TeggError } from '@eggjs/metadata'; import type { EggPrototype, @@ -29,9 +28,7 @@ export class LoadUnitAopHook implements LifecycleHookproto map (multi-app safe) over the global one. - const adviceProto = - EggPrototypeFactory.instance.getPrototypeByClazz(advice.clazz) ?? PrototypeUtil.getClazzProto(advice.clazz); + const adviceProto = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(advice.clazz); if (!adviceProto) { throw TeggError.create(`Aop Advice(${advice.clazz.name}) not found in loadUnits`, 'advice_not_found'); } diff --git a/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts b/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts index b85cd104ef..8a31b15115 100644 --- a/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts +++ b/tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts @@ -1,4 +1,4 @@ -import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator'; +import { SingletonProto } from '@eggjs/core-decorator'; import { QualifierImplUtil } from '@eggjs/dynamic-inject'; import { EggPrototypeFactory } from '@eggjs/metadata'; import type { EggContainerFactory } from '@eggjs/tegg-runtime'; @@ -20,9 +20,7 @@ export class EggObjectFactory implements IEggObjectFactory { if (!implClazz) { throw new Error(`has no impl for ${abstractClazz.name} with qualifier ${qualifierValue}`); } - // Prefer this app's class->proto map (multi-app safe) over the global one. - const protoObj: any = - EggPrototypeFactory.instance.getPrototypeByClazz(implClazz) ?? PrototypeUtil.getClazzProto(implClazz); + const protoObj: any = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(implClazz); if (!protoObj) { throw new Error(`can not get proto for clazz ${implClazz.name}`); } diff --git a/tegg/core/eventbus-runtime/src/SingletonEventBus.ts b/tegg/core/eventbus-runtime/src/SingletonEventBus.ts index ff8ba81ae2..2747aeb79e 100644 --- a/tegg/core/eventbus-runtime/src/SingletonEventBus.ts +++ b/tegg/core/eventbus-runtime/src/SingletonEventBus.ts @@ -183,6 +183,6 @@ export class SingletonEventBus implements EventBus, EventWaiter { } this.doOnceEmit(event, args); }); - await (bag ? TeggScope.run(bag, doRun) : doRun()); + await TeggScope.runMaybe(bag, doRun); } } diff --git a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts index 183250a516..e94564d713 100644 --- a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts +++ b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts @@ -69,3 +69,19 @@ export function createScopedLifecycleUtil; } + +/** + * Define a per-app scoped lifecycle util for `slot` in one call: returns a tuple + * of the scoped util facade and its `fromBag(bag)` resolver, both bound to the + * SAME slot. Collapses the per-package `declare slot` + `createScopedLifecycleUtil` + * + `lifecycleUtilFromBag` boilerplate to a single destructured export. + */ +export function defineScopedLifecycleUtil>( + slot: symbol, + desc: string, +): readonly [LifecycleUtil, (bag: TeggScopeBag) => LifecycleUtil] { + return [ + createScopedLifecycleUtil(slot, desc), + (bag: TeggScopeBag) => lifecycleUtilFromBag(bag, slot), + ] as const; +} diff --git a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap index 2d1ce3855d..f293394cad 100644 --- a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap @@ -12,6 +12,7 @@ exports[`should export stable 1`] = ` "LifecyclePreLoad": [Function], "LifecycleUtil": [Function], "createScopedLifecycleUtil": [Function], + "defineScopedLifecycleUtil": [Function], "lifecycleUtilFromBag": [Function], } `; diff --git a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts index 95e93438de..400b8e2c76 100644 --- a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts +++ b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts @@ -1,7 +1,15 @@ +import { PrototypeUtil } from '@eggjs/core-decorator'; import { FrameworkErrorFormatter } from '@eggjs/errors'; import { MapUtil } from '@eggjs/tegg-common-util'; import { AccessLevel, TeggScope } from '@eggjs/tegg-types'; -import type { EggProtoImplClass, EggPrototypeName, EggPrototype, LoadUnit, QualifierInfo } from '@eggjs/tegg-types'; +import type { + EggProtoImplClass, + EggPrototypeName, + EggPrototype, + EggPrototypeWithClazz, + LoadUnit, + QualifierInfo, +} from '@eggjs/tegg-types'; import { EggPrototypeNotFound, MultiPrototypeFound } from '../errors.ts'; @@ -34,7 +42,7 @@ export class EggPrototypeFactory { private clazzProtoMap: WeakMap = new WeakMap(); public registerPrototype(proto: EggPrototype, loadUnit: LoadUnit): void { - const clazz = (proto as unknown as { clazz?: EggProtoImplClass }).clazz; + const clazz = (proto as EggPrototypeWithClazz).clazz; if (clazz) { this.clazzProtoMap.set(clazz, proto); } @@ -46,7 +54,7 @@ export class EggPrototypeFactory { } public deletePrototype(proto: EggPrototype, loadUnit: LoadUnit): void { - const clazz = (proto as unknown as { clazz?: EggProtoImplClass }).clazz; + const clazz = (proto as EggPrototypeWithClazz).clazz; if (clazz) { this.clazzProtoMap.delete(clazz); } @@ -71,6 +79,16 @@ export class EggPrototypeFactory { return this.clazzProtoMap.get(clazz); } + /** + * Resolve a proto by class from THIS app's registry, falling back to the + * process-global `PrototypeUtil.getClazzProto()` map. Centralizes the rule-4 + * multi-app fallback so each call site does not re-implement (and risk + * forgetting) it. + */ + public getPrototypeByClazzOrGlobal(clazz: EggProtoImplClass): EggPrototype | undefined { + return this.getPrototypeByClazz(clazz) ?? (PrototypeUtil.getClazzProto(clazz) as EggPrototype | undefined); + } + public getPrototype(name: PropertyKey, loadUnit?: LoadUnit, qualifiers?: QualifierInfo[]): EggPrototype { qualifiers = qualifiers || []; const protos = this.doGetPrototype(name, qualifiers, loadUnit); diff --git a/tegg/core/metadata/src/model/EggPrototype.ts b/tegg/core/metadata/src/model/EggPrototype.ts index 42946ffc41..d8993af99e 100644 --- a/tegg/core/metadata/src/model/EggPrototype.ts +++ b/tegg/core/metadata/src/model/EggPrototype.ts @@ -1,21 +1,11 @@ -import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { EggPrototype, EggPrototypeLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; - -const EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:eggPrototypeLifecycleUtil'); +import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; +import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; /** * Per-app prototype lifecycle util, backed by TeggScope. Hooks registered during * one app's boot stay isolated to that app under concurrent multi-app. */ -export const EggPrototypeLifecycleUtil: LifecycleUtil = - createScopedLifecycleUtil( - EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT, - 'EggPrototypeLifecycleUtil', - ); - -/** Resolve this app's prototype lifecycle util directly from its bag (no active scope needed). */ -export function eggPrototypeLifecycleUtilFromBag( - bag: TeggScopeBag, -): LifecycleUtil { - return lifecycleUtilFromBag(bag, EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT); -} +export const [EggPrototypeLifecycleUtil, eggPrototypeLifecycleUtilFromBag] = defineScopedLifecycleUtil< + EggPrototypeLifecycleContext, + EggPrototype +>(Symbol('tegg:metadata:eggPrototypeLifecycleUtil'), 'EggPrototypeLifecycleUtil'); diff --git a/tegg/core/metadata/src/model/LoadUnit.ts b/tegg/core/metadata/src/model/LoadUnit.ts index b9842d7b40..2eef9b2b7f 100644 --- a/tegg/core/metadata/src/model/LoadUnit.ts +++ b/tegg/core/metadata/src/model/LoadUnit.ts @@ -1,14 +1,7 @@ -import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { LoadUnit, LoadUnitLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; +import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; +import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; -const LOAD_UNIT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:loadUnitLifecycleUtil'); - -export const LoadUnitLifecycleUtil: LifecycleUtil = createScopedLifecycleUtil< +export const [LoadUnitLifecycleUtil, loadUnitLifecycleUtilFromBag] = defineScopedLifecycleUtil< LoadUnitLifecycleContext, LoadUnit ->(LOAD_UNIT_LIFECYCLE_UTIL_SLOT, 'LoadUnitLifecycleUtil'); - -/** Resolve this app's load-unit lifecycle util directly from its bag (no active scope needed). */ -export function loadUnitLifecycleUtilFromBag(bag: TeggScopeBag): LifecycleUtil { - return lifecycleUtilFromBag(bag, LOAD_UNIT_LIFECYCLE_UTIL_SLOT); -} +>(Symbol('tegg:metadata:loadUnitLifecycleUtil'), 'LoadUnitLifecycleUtil'); diff --git a/tegg/core/runtime/src/factory/EggContainerFactory.ts b/tegg/core/runtime/src/factory/EggContainerFactory.ts index 5dc4d02d44..e53f7454da 100644 --- a/tegg/core/runtime/src/factory/EggContainerFactory.ts +++ b/tegg/core/runtime/src/factory/EggContainerFactory.ts @@ -85,14 +85,10 @@ export class EggContainerFactory { name?: EggObjectName, qualifiers?: QualifierInfo[], ): Promise { - const isMultiInstance = PrototypeUtil.isEggMultiInstancePrototype(clazz as EggProtoImplClass); - // Prefer the CURRENT app's class→proto map over the global - // PrototypeUtil.getClazzProto(), which is shared across apps and overwritten - // by concurrent multi-app boot (last writer wins). + const isMultiInstance = PrototypeUtil.isEggMultiInstancePrototype(clazz); let proto: EggPrototype | undefined = isMultiInstance ? undefined - : (EggPrototypeFactory.instance.getPrototypeByClazz(clazz as EggProtoImplClass) ?? - (PrototypeUtil.getClazzProto(clazz as EggProtoImplClass) as EggPrototype | undefined)); + : EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(clazz); debug('getOrCreateEggObjectFromClazz:%o, isMultiInstance:%s, proto:%o', clazz.name, isMultiInstance, !!proto); if (isMultiInstance) { const defaultName = NameUtil.getClassName(clazz as EggProtoImplClass); diff --git a/tegg/core/runtime/src/model/EggContext.ts b/tegg/core/runtime/src/model/EggContext.ts index de830d57f2..6f9411ddcb 100644 --- a/tegg/core/runtime/src/model/EggContext.ts +++ b/tegg/core/runtime/src/model/EggContext.ts @@ -1,17 +1,7 @@ -import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { EggRuntimeContext, EggContextLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; +import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; +import type { EggRuntimeContext, EggContextLifecycleContext } from '@eggjs/tegg-types'; -const EGG_CONTEXT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggContextLifecycleUtil'); - -export const EggContextLifecycleUtil: LifecycleUtil = - createScopedLifecycleUtil( - EGG_CONTEXT_LIFECYCLE_UTIL_SLOT, - 'EggContextLifecycleUtil', - ); - -/** Resolve this app's egg-context lifecycle util directly from its bag (no active scope needed). */ -export function eggContextLifecycleUtilFromBag( - bag: TeggScopeBag, -): LifecycleUtil { - return lifecycleUtilFromBag(bag, EGG_CONTEXT_LIFECYCLE_UTIL_SLOT); -} +export const [EggContextLifecycleUtil, eggContextLifecycleUtilFromBag] = defineScopedLifecycleUtil< + EggContextLifecycleContext, + EggRuntimeContext +>(Symbol('tegg:runtime:eggContextLifecycleUtil'), 'EggContextLifecycleUtil'); diff --git a/tegg/core/runtime/src/model/EggObject.ts b/tegg/core/runtime/src/model/EggObject.ts index d90b0d1afb..bc7d3c8a7e 100644 --- a/tegg/core/runtime/src/model/EggObject.ts +++ b/tegg/core/runtime/src/model/EggObject.ts @@ -1,14 +1,7 @@ -import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { EggObject, EggObjectLifeCycleContext, TeggScopeBag } from '@eggjs/tegg-types'; +import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; +import type { EggObject, EggObjectLifeCycleContext } from '@eggjs/tegg-types'; -const EGG_OBJECT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggObjectLifecycleUtil'); - -export const EggObjectLifecycleUtil: LifecycleUtil = createScopedLifecycleUtil< +export const [EggObjectLifecycleUtil, eggObjectLifecycleUtilFromBag] = defineScopedLifecycleUtil< EggObjectLifeCycleContext, EggObject ->(EGG_OBJECT_LIFECYCLE_UTIL_SLOT, 'EggObjectLifecycleUtil'); - -/** Resolve this app's egg-object lifecycle util directly from its bag (no active scope needed). */ -export function eggObjectLifecycleUtilFromBag(bag: TeggScopeBag): LifecycleUtil { - return lifecycleUtilFromBag(bag, EGG_OBJECT_LIFECYCLE_UTIL_SLOT); -} +>(Symbol('tegg:runtime:eggObjectLifecycleUtil'), 'EggObjectLifecycleUtil'); diff --git a/tegg/core/runtime/src/model/LoadUnitInstance.ts b/tegg/core/runtime/src/model/LoadUnitInstance.ts index b4677e95d3..e189ab2157 100644 --- a/tegg/core/runtime/src/model/LoadUnitInstance.ts +++ b/tegg/core/runtime/src/model/LoadUnitInstance.ts @@ -1,17 +1,7 @@ -import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; -import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; +import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; +import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext } from '@eggjs/tegg-types'; -const LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:loadUnitInstanceLifecycleUtil'); - -export const LoadUnitInstanceLifecycleUtil: LifecycleUtil = - createScopedLifecycleUtil( - LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT, - 'LoadUnitInstanceLifecycleUtil', - ); - -/** Resolve this app's load-unit-instance lifecycle util directly from its bag (no active scope needed). */ -export function loadUnitInstanceLifecycleUtilFromBag( - bag: TeggScopeBag, -): LifecycleUtil { - return lifecycleUtilFromBag(bag, LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT); -} +export const [LoadUnitInstanceLifecycleUtil, loadUnitInstanceLifecycleUtilFromBag] = defineScopedLifecycleUtil< + LoadUnitInstanceLifecycleContext, + LoadUnitInstance +>(Symbol('tegg:runtime:loadUnitInstanceLifecycleUtil'), 'LoadUnitInstanceLifecycleUtil'); diff --git a/tegg/core/types/src/scope/TeggScope.ts b/tegg/core/types/src/scope/TeggScope.ts index b596f88c62..342b45e319 100644 --- a/tegg/core/types/src/scope/TeggScope.ts +++ b/tegg/core/types/src/scope/TeggScope.ts @@ -84,6 +84,15 @@ export class TeggScope { return als.run(bag, fn); } + /** + * Run `fn` in `bag`'s scope when a bag is provided, otherwise run it directly + * in whatever scope is already active (the bag may not be established yet during + * very early boot). Centralizes the per-app `bag ? run(bag, fn) : fn()` idiom. + */ + static runMaybe(bag: TeggScopeBag | undefined, fn: () => R): R { + return bag ? als.run(bag, fn) : fn(); + } + /** Create a fresh, empty per-app bag. */ static createBag(): TeggScopeBag { return new Map(); @@ -179,22 +188,18 @@ export class TeggScope { * scope wrap that silently mutates shared state is caught instead of leaking. */ static set(slot: symbol, value: unknown): boolean { - if (!als.getStore() && TeggScope.isMultiApp) { + const bag = als.getStore(); + if (bag) { + bag.set(slot, value); + return true; + } + if (TeggScope.isMultiApp) { reportEscape(slot.toString()); } - TeggScope.#activeBag().set(slot, value); + TeggScope.#fallbackBag().set(slot, value); return true; } - /** - * @internal Install an explicit process-default bag (test harnesses that run - * outside any {@link TeggScope.run}). Lets deprecated static accesses resolve - * to the same per-test bag. - */ - static _setDefaultBag(bag: TeggScopeBag | undefined): void { - defaultBag = bag; - } - /** @internal Reset the process-default bag (e.g. between tests). */ static _resetDefaultBag(): void { defaultBag = undefined; @@ -205,20 +210,6 @@ export class TeggScope { return defaultBag; } - /** - * The active bag: the current ALS scope's bag, or the single lazily-created - * process-default bag when no scope is active. Routing every no-scope fallback - * (resolve / getOr / set / context callbacks) through ONE bag keeps all slots - * mutually consistent in single-app / test paths. - */ - static #activeBag(): TeggScopeBag { - const store = als.getStore(); - if (store) { - return store; - } - return TeggScope.#fallbackBag(); - } - static #getOrCreate(bag: TeggScopeBag, slot: symbol, create: () => T): T { if (!bag.has(slot)) { bag.set(slot, create()); diff --git a/tegg/plugin/controller/src/app.ts b/tegg/plugin/controller/src/app.ts index 5f9b43f3fb..54932ff72c 100644 --- a/tegg/plugin/controller/src/app.ts +++ b/tegg/plugin/controller/src/app.ts @@ -110,9 +110,7 @@ export default class ControllerAppBootHook implements ILifecycleBoot { this.app.config.mcp.sseMessagePath, this.app.config.mcp.streamPath, this.app.config.mcp.statelessStreamPath, - ...(Array.isArray(this.app.config.security.csrf.ignore) - ? this.app.config.security.csrf.ignore - : [this.app.config.security.csrf.ignore]), + ...this.app.config.security.csrf.ignore, ]; } } else { @@ -170,20 +168,6 @@ export default class ControllerAppBootHook implements ILifecycleBoot { GlobalGraph.instanceFor(this.app._teggScopeBag)?.registerBuildHook(middlewareGraphHook); } - async willReady(): Promise { - if (this.mcpEnable()) { - await TeggScope.run(this.app._teggScopeBag, async () => { - await MCPControllerRegister.connectStatelessStreamTransport(); - const names = MCPControllerRegister.instance?.mcpConfig.getMultipleServerNames(); - if (names && names.length > 0) { - for (const name of names) { - await MCPControllerRegister.connectStatelessStreamTransport(name); - } - } - }); - } - } - mcpEnable(): boolean { return !!this.app.plugins.mcpProxy?.enable; } diff --git a/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts b/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts index e74f0ca43a..1ce228ff11 100644 --- a/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts +++ b/tegg/plugin/controller/src/lib/ControllerMetadataManager.ts @@ -17,10 +17,6 @@ export class ControllerMetadataManager { ); } - constructor() { - this.controllers = new Map(); - } - addController(metadata: ControllerMetadata): void { const typeControllers = MapUtil.getOrStore(this.controllers, metadata.type, []); // 1.check controller name diff --git a/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts b/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts index 0252b395bf..34d66931c7 100644 --- a/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts +++ b/tegg/plugin/eventbus/src/lib/EggContextEventBus.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; -import { PrototypeUtil } from '@eggjs/core-decorator'; import { type Events, CORK_ID, type ContextEventBus, type Arguments } from '@eggjs/eventbus-decorator'; import { SingletonEventBus } from '@eggjs/eventbus-runtime'; import type { EggPrototype } from '@eggjs/metadata'; @@ -13,10 +12,7 @@ export class EggContextEventBus implements ContextEventBus { private corkId?: string; constructor(ctx: Context) { - // Prefer this app's class->proto map (multi-app safe); the global - // PrototypeUtil.getClazzProto is shared and overwritten by concurrent apps. - const proto = (ctx.app.eggPrototypeFactory.getPrototypeByClazz(SingletonEventBus) ?? - PrototypeUtil.getClazzProto(SingletonEventBus)) as EggPrototype; + const proto = ctx.app.eggPrototypeFactory.getPrototypeByClazzOrGlobal(SingletonEventBus) as EggPrototype; const eggObject = ctx.app.eggContainerFactory.getEggObject(proto, proto.name); this.context = ContextHandler.getContext()!; this.eventBus = eggObject.obj as SingletonEventBus; diff --git a/tegg/plugin/mcp-proxy/src/app.ts b/tegg/plugin/mcp-proxy/src/app.ts index 989a5a9686..adf2a33425 100644 --- a/tegg/plugin/mcp-proxy/src/app.ts +++ b/tegg/plugin/mcp-proxy/src/app.ts @@ -13,9 +13,7 @@ export default class AppHook { configWillLoad(): void { // hooks is per-app (scope-backed); register into this app's scope. - TeggScope.run(this.agent._teggScopeBag, () => { - MCPControllerRegister.addHook(MCPProxyHook); - }); + TeggScope.run(this.agent._teggScopeBag, () => MCPControllerRegister.addHook(MCPProxyHook)); } async didLoad(): Promise { diff --git a/tegg/plugin/orm/src/app.ts b/tegg/plugin/orm/src/app.ts index 9b7d574bf9..fafca09748 100644 --- a/tegg/plugin/orm/src/app.ts +++ b/tegg/plugin/orm/src/app.ts @@ -53,9 +53,7 @@ export default class OrmAppBootHook implements ILifecycleBoot { async didLoad(): Promise { await this.app.moduleHandler.ready(); - await TeggScope.run(this.app._teggScopeBag, async () => { - await this.leoricRegister.register(); - }); + await TeggScope.run(this.app._teggScopeBag, () => this.leoricRegister.register()); } async beforeClose(): Promise { diff --git a/tegg/plugin/tegg/src/app/extend/application.ts b/tegg/plugin/tegg/src/app/extend/application.ts index eb1eff6341..6914b85f84 100644 --- a/tegg/plugin/tegg/src/app/extend/application.ts +++ b/tegg/plugin/tegg/src/app/extend/application.ts @@ -113,7 +113,7 @@ export default class TEggPluginApplication { ); return eggObject.obj as T; }; - return bag ? TeggScope.run(bag, doWork) : doWork(); + return TeggScope.runMaybe(bag, doWork); } async getEggObjectFromName(name: string, qualifiers?: QualifierInfo | QualifierInfo[]): Promise { @@ -125,6 +125,6 @@ export default class TEggPluginApplication { const eggObject = await EggContainerFactory.getOrCreateEggObjectFromName(name, qualifiers as QualifierInfo[]); return eggObject.obj as T; }; - return bag ? TeggScope.run(bag, doWork) : doWork(); + return TeggScope.runMaybe(bag, doWork); } } diff --git a/tegg/plugin/tegg/src/app/extend/application.unittest.ts b/tegg/plugin/tegg/src/app/extend/application.unittest.ts index 07ab211300..8c56d51fb5 100644 --- a/tegg/plugin/tegg/src/app/extend/application.unittest.ts +++ b/tegg/plugin/tegg/src/app/extend/application.unittest.ts @@ -26,7 +26,7 @@ export default class TEggPluginApplicationUnittest { hasMockModuleContext = true; return ctx; }; - return this._teggScopeBag ? TeggScope.run(this._teggScopeBag, doWork) : doWork(); + return TeggScope.runMaybe(this._teggScopeBag, doWork); } async destroyModuleContext(this: Application, ctx: Context): Promise { @@ -42,7 +42,7 @@ export default class TEggPluginApplicationUnittest { await teggCtx.destroy(lifecycle); } }; - return this._teggScopeBag ? TeggScope.run(this._teggScopeBag, doWork) : doWork(); + return TeggScope.runMaybe(this._teggScopeBag, doWork); } async mockModuleContextScope(this: Application, fn: (ctx: Context) => Promise, data?: any): Promise { @@ -68,6 +68,6 @@ export default class TEggPluginApplicationUnittest { }; // Run within this app's scope so app.module/ctx.module proxy resolution and // getEggObject read the correct per-app factories. - return this._teggScopeBag ? TeggScope.run(this._teggScopeBag, doWork) : doWork(); + return TeggScope.runMaybe(this._teggScopeBag, doWork); } } diff --git a/tegg/plugin/tegg/src/app/extend/context.ts b/tegg/plugin/tegg/src/app/extend/context.ts index 891c5c4faf..91f06ff56a 100644 --- a/tegg/plugin/tegg/src/app/extend/context.ts +++ b/tegg/plugin/tegg/src/app/extend/context.ts @@ -31,9 +31,7 @@ export default class TEggPluginContext { const eggObject = await app.eggContainerFactory.getOrCreateEggObjectFromClazz(clazz as EggProtoImplClass, name); return eggObject.obj as T; }; - // Defensive (consistent with application.ts): fall back to the ambient scope - // if the bag is not yet established. - return bag ? TeggScope.run(bag, doWork) : doWork(); + return TeggScope.runMaybe(bag, doWork); } async getEggObjectFromName(this: Context, name: string, qualifiers?: QualifierInfo | QualifierInfo[]): Promise { @@ -46,6 +44,6 @@ export default class TEggPluginContext { const eggObject = await app.eggContainerFactory.getOrCreateEggObjectFromName(name, qualifiers as QualifierInfo[]); return eggObject.obj as T; }; - return bag ? TeggScope.run(bag, doWork) : doWork(); + return TeggScope.runMaybe(bag, doWork); } } diff --git a/tegg/standalone/standalone/src/Runner.ts b/tegg/standalone/standalone/src/Runner.ts index 7245100d8a..dbf09ed9e0 100644 --- a/tegg/standalone/standalone/src/Runner.ts +++ b/tegg/standalone/standalone/src/Runner.ts @@ -110,9 +110,12 @@ export class Runner { TeggScope.registerScope(this.scopeBag); this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); this.moduleConfigs = {}; - TeggScope.run(this.scopeBag, () => { - this.initInnerObjectsAndConfigs(options); - }); + this.runInScope(() => this.initInnerObjectsAndConfigs(options)); + } + + /** Run `fn` within THIS Runner's per-app scope so factories/managers resolve here. */ + private runInScope(fn: () => R): R { + return TeggScope.run(this.scopeBag, fn); } private initInnerObjectsAndConfigs(options?: RunnerOptions): void { @@ -184,7 +187,7 @@ export class Runner { } async load(): Promise { - return TeggScope.run(this.scopeBag, async () => { + return this.runInScope(async () => { StandaloneContextHandler.register(); LoadUnitFactory.registerLoadUnitCreator(StandaloneLoadUnitType, () => { return new StandaloneLoadUnit(this.innerObjects); @@ -265,7 +268,7 @@ export class Runner { } async init(): Promise { - await TeggScope.run(this.scopeBag, async () => { + await this.runInScope(async () => { await this.initLoaderInstance(); this.loadUnits = await this.load(); @@ -288,7 +291,7 @@ export class Runner { } async run(aCtx?: EggContext): Promise { - return TeggScope.run(this.scopeBag, async () => { + return this.runInScope(async () => { const lifecycle = {}; const ctx = aCtx || new StandaloneContext(); return await ContextHandler.run(ctx, async () => { @@ -312,9 +315,7 @@ export class Runner { } async destroy(): Promise { - await TeggScope.run(this.scopeBag, async () => { - await this.doDestroy(); - }); + await this.runInScope(() => this.doDestroy()); TeggScope.unregisterScope(this.scopeBag); } From acd255833e39f956d96394840b029960f918edbb Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 09:28:24 +0800 Subject: [PATCH 09/16] fix(tegg): close multi-app scope-isolation gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real correctness bugs from an adversarial review of the multi-app PR: - langchain: GraphLoadUnitHook captured app.eggPrototypeFactory in the boot CONSTRUCTOR (runs before any app scope exists), so under concurrent multi-app it resolved another live app's factory (cross-app proto pollution) or tripped the strict-mode escape fuse on a 3rd app's boot. Resolve EggPrototypeFactory .instance lazily inside preCreate, which runs within this app's TeggScope. - standalone Runner: TeggScope.registerScope ran in the constructor but unregisterScope only on destroy()'s success path, leaking the scope into liveScopeBags on construction/init/destroy failure — a single leak flips isMultiApp on and breaks the sole-app fallback + escape fuse for every later Runner. Now: destroy() unregisters in a finally, the constructor unregisters on failure, and main() tears the Runner down when init() throws. - plugin/tegg beforeClose: moved unregisterScope into a finally so a throwing moduleHandler.destroy() no longer leaks the app scope. All verified by the tegg suite (langchain/standalone/tegg + core/plugins green). Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/plugin/langchain/src/app.ts | 2 +- .../src/lib/graph/GraphLoadUnitHook.ts | 12 +++-- tegg/plugin/tegg/src/app.ts | 46 ++++++++++--------- tegg/standalone/standalone/src/Runner.ts | 22 +++++++-- tegg/standalone/standalone/src/main.ts | 5 ++ 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/tegg/plugin/langchain/src/app.ts b/tegg/plugin/langchain/src/app.ts index 4d6edb2d1b..dec291303d 100644 --- a/tegg/plugin/langchain/src/app.ts +++ b/tegg/plugin/langchain/src/app.ts @@ -18,7 +18,7 @@ export default class ModuleLangChainHook implements IBoot { constructor(app: Application) { this.#app = app; this.#graphObjectHook = new GraphObjectHook(); - this.#graphLoadUnitHook = new GraphLoadUnitHook(this.#app.eggPrototypeFactory as any); + this.#graphLoadUnitHook = new GraphLoadUnitHook(); this.#boundModelObjectHook = new BoundModelObjectHook(); this.#graphPrototypeHook = new GraphPrototypeHook(); // NOTE: graphLoadUnitHook registration moved to configWillLoad — the per-app diff --git a/tegg/plugin/langchain/src/lib/graph/GraphLoadUnitHook.ts b/tegg/plugin/langchain/src/lib/graph/GraphLoadUnitHook.ts index 893f369c93..1d8aa0ce0e 100644 --- a/tegg/plugin/langchain/src/lib/graph/GraphLoadUnitHook.ts +++ b/tegg/plugin/langchain/src/lib/graph/GraphLoadUnitHook.ts @@ -14,18 +14,20 @@ import * as z from 'zod/v4'; import { CompiledStateGraphProto } from './CompiledStateGraphProto.ts'; export class GraphLoadUnitHook implements LifecycleHook { - private readonly eggPrototypeFactory: EggPrototypeFactory; clazzMap: Map; graphCompiledNameMap: Map = new Map(); tools: Map; - constructor(eggPrototypeFactory: EggPrototypeFactory) { - this.eggPrototypeFactory = eggPrototypeFactory; + constructor() { this.clazzMap = GraphInfoUtil.getAllGraphMetadata(); this.tools = GraphToolInfoUtil.getAllGraphToolMetadata(); } async preCreate(ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + // preCreate fires inside this app's TeggScope (the tegg loadUnit init), so + // resolve the per-app factory lazily here rather than capturing it at + // construction (the boot constructor runs before any app scope exists). + const eggPrototypeFactory = EggPrototypeFactory.instance; const clazzList = await ctx.loader.load(); for (const clazz of clazzList) { const meta = this.clazzMap.get(clazz as EggProtoImplClass); @@ -39,7 +41,7 @@ export class GraphLoadUnitHook implements LifecycleHook { - await TeggScope.run(this.app._teggScopeBag, async () => { - CompatibleUtil.clean(); - await this.app.moduleHandler.destroy(); - if (this.compatibleHook) { - this.app.eggContextLifecycleUtil.deleteLifecycle(this.compatibleHook); - } - if (this.eggQualifierProtoHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eggQualifierProtoHook); - } - if (this.configSourceEggPrototypeHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.configSourceEggPrototypeHook); - } - if (this.loadUnitMultiInstanceProtoHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook); - } - // per-app multi-instance proto set: cleared within this app's scope - LoadUnitMultiInstanceProtoHook.clear(); - }); - // The whole per-app scope (bag) is dropped with the app; release the scope - // so the strict-mode escape fuse reflects the live app count. - TeggScope.unregisterScope(this.app._teggScopeBag); + try { + await TeggScope.run(this.app._teggScopeBag, async () => { + CompatibleUtil.clean(); + await this.app.moduleHandler.destroy(); + if (this.compatibleHook) { + this.app.eggContextLifecycleUtil.deleteLifecycle(this.compatibleHook); + } + if (this.eggQualifierProtoHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eggQualifierProtoHook); + } + if (this.configSourceEggPrototypeHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.configSourceEggPrototypeHook); + } + if (this.loadUnitMultiInstanceProtoHook) { + this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook); + } + // per-app multi-instance proto set: cleared within this app's scope + LoadUnitMultiInstanceProtoHook.clear(); + }); + } finally { + // The whole per-app scope (bag) is dropped with the app; release the scope + // so the strict-mode escape fuse reflects the live app count even if the + // cleanup above throws. + TeggScope.unregisterScope(this.app._teggScopeBag); + } } } diff --git a/tegg/standalone/standalone/src/Runner.ts b/tegg/standalone/standalone/src/Runner.ts index dbf09ed9e0..e5461a4e9f 100644 --- a/tegg/standalone/standalone/src/Runner.ts +++ b/tegg/standalone/standalone/src/Runner.ts @@ -108,9 +108,16 @@ export class Runner { this.options = options; this.scopeBag = TeggScope.createBag(); TeggScope.registerScope(this.scopeBag); - this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); - this.moduleConfigs = {}; - this.runInScope(() => this.initInnerObjectsAndConfigs(options)); + try { + this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); + this.moduleConfigs = {}; + this.runInScope(() => this.initInnerObjectsAndConfigs(options)); + } catch (e) { + // Construction failed after the scope was registered; release it so the + // never-returned Runner does not leak into liveScopeBags. + TeggScope.unregisterScope(this.scopeBag); + throw e; + } } /** Run `fn` within THIS Runner's per-app scope so factories/managers resolve here. */ @@ -315,8 +322,13 @@ export class Runner { } async destroy(): Promise { - await this.runInScope(() => this.doDestroy()); - TeggScope.unregisterScope(this.scopeBag); + try { + await this.runInScope(() => this.doDestroy()); + } finally { + // Always release the scope, even if doDestroy rejects, so liveScopeBags + // (and thus isMultiApp / the sole-app fallback) never leaks a dead Runner. + TeggScope.unregisterScope(this.scopeBag); + } } private async doDestroy(): Promise { diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index 9192f80dc3..b4a29d8028 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -19,6 +19,11 @@ export async function main(cwd: string, options?: RunnerOptions): Prom if (e instanceof Error) { e.message = `[tegg/standalone] bootstrap tegg failed: ${e.message}`; } + // Boot failed and run()'s finally below is never reached, so tear down here + // to release this Runner's TeggScope so it does not leak into liveScopeBags. + await runner.destroy().catch(() => { + /* swallow: surface the original boot error */ + }); throw e; } try { From e8d0f039735cc522fab5d5f88a3d95392c0c50ef Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 09:35:55 +0800 Subject: [PATCH 10/16] fix(tegg): revert destructured lifecycle-util exports (isolatedDeclarations) `export const [Util, utilFromBag] = defineScopedLifecycleUtil(...)` (a binding pattern) is rejected by the repo's --isolatedDeclarations d.ts emit (TS9019), breaking the build + both E2E suites. Restore the explicit `export const Util: LifecycleUtil<...> = createScopedLifecycleUtil(...)` + `export function utilFromBag(...)` form in the 5 model files and drop the unused defineScopedLifecycleUtil helper. The other simplifications are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/lifecycle/src/ScopedLifecycleUtil.ts | 16 -------------- .../test/__snapshots__/index.test.ts.snap | 1 - tegg/core/metadata/src/model/EggPrototype.ts | 22 ++++++++++++++----- tegg/core/metadata/src/model/LoadUnit.ts | 15 +++++++++---- tegg/core/runtime/src/model/EggContext.ts | 22 ++++++++++++++----- tegg/core/runtime/src/model/EggObject.ts | 15 +++++++++---- .../runtime/src/model/LoadUnitInstance.ts | 22 ++++++++++++++----- 7 files changed, 70 insertions(+), 43 deletions(-) diff --git a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts index e94564d713..183250a516 100644 --- a/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts +++ b/tegg/core/lifecycle/src/ScopedLifecycleUtil.ts @@ -69,19 +69,3 @@ export function createScopedLifecycleUtil; } - -/** - * Define a per-app scoped lifecycle util for `slot` in one call: returns a tuple - * of the scoped util facade and its `fromBag(bag)` resolver, both bound to the - * SAME slot. Collapses the per-package `declare slot` + `createScopedLifecycleUtil` - * + `lifecycleUtilFromBag` boilerplate to a single destructured export. - */ -export function defineScopedLifecycleUtil>( - slot: symbol, - desc: string, -): readonly [LifecycleUtil, (bag: TeggScopeBag) => LifecycleUtil] { - return [ - createScopedLifecycleUtil(slot, desc), - (bag: TeggScopeBag) => lifecycleUtilFromBag(bag, slot), - ] as const; -} diff --git a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap index f293394cad..2d1ce3855d 100644 --- a/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap @@ -12,7 +12,6 @@ exports[`should export stable 1`] = ` "LifecyclePreLoad": [Function], "LifecycleUtil": [Function], "createScopedLifecycleUtil": [Function], - "defineScopedLifecycleUtil": [Function], "lifecycleUtilFromBag": [Function], } `; diff --git a/tegg/core/metadata/src/model/EggPrototype.ts b/tegg/core/metadata/src/model/EggPrototype.ts index d8993af99e..42946ffc41 100644 --- a/tegg/core/metadata/src/model/EggPrototype.ts +++ b/tegg/core/metadata/src/model/EggPrototype.ts @@ -1,11 +1,21 @@ -import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; -import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { EggPrototype, EggPrototypeLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; + +const EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:eggPrototypeLifecycleUtil'); /** * Per-app prototype lifecycle util, backed by TeggScope. Hooks registered during * one app's boot stay isolated to that app under concurrent multi-app. */ -export const [EggPrototypeLifecycleUtil, eggPrototypeLifecycleUtilFromBag] = defineScopedLifecycleUtil< - EggPrototypeLifecycleContext, - EggPrototype ->(Symbol('tegg:metadata:eggPrototypeLifecycleUtil'), 'EggPrototypeLifecycleUtil'); +export const EggPrototypeLifecycleUtil: LifecycleUtil = + createScopedLifecycleUtil( + EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT, + 'EggPrototypeLifecycleUtil', + ); + +/** Resolve this app's prototype lifecycle util directly from its bag (no active scope needed). */ +export function eggPrototypeLifecycleUtilFromBag( + bag: TeggScopeBag, +): LifecycleUtil { + return lifecycleUtilFromBag(bag, EGG_PROTOTYPE_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/metadata/src/model/LoadUnit.ts b/tegg/core/metadata/src/model/LoadUnit.ts index 2eef9b2b7f..b9842d7b40 100644 --- a/tegg/core/metadata/src/model/LoadUnit.ts +++ b/tegg/core/metadata/src/model/LoadUnit.ts @@ -1,7 +1,14 @@ -import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; -import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { LoadUnit, LoadUnitLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; -export const [LoadUnitLifecycleUtil, loadUnitLifecycleUtilFromBag] = defineScopedLifecycleUtil< +const LOAD_UNIT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:metadata:loadUnitLifecycleUtil'); + +export const LoadUnitLifecycleUtil: LifecycleUtil = createScopedLifecycleUtil< LoadUnitLifecycleContext, LoadUnit ->(Symbol('tegg:metadata:loadUnitLifecycleUtil'), 'LoadUnitLifecycleUtil'); +>(LOAD_UNIT_LIFECYCLE_UTIL_SLOT, 'LoadUnitLifecycleUtil'); + +/** Resolve this app's load-unit lifecycle util directly from its bag (no active scope needed). */ +export function loadUnitLifecycleUtilFromBag(bag: TeggScopeBag): LifecycleUtil { + return lifecycleUtilFromBag(bag, LOAD_UNIT_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/runtime/src/model/EggContext.ts b/tegg/core/runtime/src/model/EggContext.ts index 6f9411ddcb..de830d57f2 100644 --- a/tegg/core/runtime/src/model/EggContext.ts +++ b/tegg/core/runtime/src/model/EggContext.ts @@ -1,7 +1,17 @@ -import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; -import type { EggRuntimeContext, EggContextLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { EggRuntimeContext, EggContextLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; -export const [EggContextLifecycleUtil, eggContextLifecycleUtilFromBag] = defineScopedLifecycleUtil< - EggContextLifecycleContext, - EggRuntimeContext ->(Symbol('tegg:runtime:eggContextLifecycleUtil'), 'EggContextLifecycleUtil'); +const EGG_CONTEXT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggContextLifecycleUtil'); + +export const EggContextLifecycleUtil: LifecycleUtil = + createScopedLifecycleUtil( + EGG_CONTEXT_LIFECYCLE_UTIL_SLOT, + 'EggContextLifecycleUtil', + ); + +/** Resolve this app's egg-context lifecycle util directly from its bag (no active scope needed). */ +export function eggContextLifecycleUtilFromBag( + bag: TeggScopeBag, +): LifecycleUtil { + return lifecycleUtilFromBag(bag, EGG_CONTEXT_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/runtime/src/model/EggObject.ts b/tegg/core/runtime/src/model/EggObject.ts index bc7d3c8a7e..d90b0d1afb 100644 --- a/tegg/core/runtime/src/model/EggObject.ts +++ b/tegg/core/runtime/src/model/EggObject.ts @@ -1,7 +1,14 @@ -import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; -import type { EggObject, EggObjectLifeCycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { EggObject, EggObjectLifeCycleContext, TeggScopeBag } from '@eggjs/tegg-types'; -export const [EggObjectLifecycleUtil, eggObjectLifecycleUtilFromBag] = defineScopedLifecycleUtil< +const EGG_OBJECT_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:eggObjectLifecycleUtil'); + +export const EggObjectLifecycleUtil: LifecycleUtil = createScopedLifecycleUtil< EggObjectLifeCycleContext, EggObject ->(Symbol('tegg:runtime:eggObjectLifecycleUtil'), 'EggObjectLifecycleUtil'); +>(EGG_OBJECT_LIFECYCLE_UTIL_SLOT, 'EggObjectLifecycleUtil'); + +/** Resolve this app's egg-object lifecycle util directly from its bag (no active scope needed). */ +export function eggObjectLifecycleUtilFromBag(bag: TeggScopeBag): LifecycleUtil { + return lifecycleUtilFromBag(bag, EGG_OBJECT_LIFECYCLE_UTIL_SLOT); +} diff --git a/tegg/core/runtime/src/model/LoadUnitInstance.ts b/tegg/core/runtime/src/model/LoadUnitInstance.ts index e189ab2157..b4677e95d3 100644 --- a/tegg/core/runtime/src/model/LoadUnitInstance.ts +++ b/tegg/core/runtime/src/model/LoadUnitInstance.ts @@ -1,7 +1,17 @@ -import { defineScopedLifecycleUtil } from '@eggjs/lifecycle'; -import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext } from '@eggjs/tegg-types'; +import { createScopedLifecycleUtil, lifecycleUtilFromBag, type LifecycleUtil } from '@eggjs/lifecycle'; +import type { LoadUnitInstance, LoadUnitInstanceLifecycleContext, TeggScopeBag } from '@eggjs/tegg-types'; -export const [LoadUnitInstanceLifecycleUtil, loadUnitInstanceLifecycleUtilFromBag] = defineScopedLifecycleUtil< - LoadUnitInstanceLifecycleContext, - LoadUnitInstance ->(Symbol('tegg:runtime:loadUnitInstanceLifecycleUtil'), 'LoadUnitInstanceLifecycleUtil'); +const LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT = Symbol('tegg:runtime:loadUnitInstanceLifecycleUtil'); + +export const LoadUnitInstanceLifecycleUtil: LifecycleUtil = + createScopedLifecycleUtil( + LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT, + 'LoadUnitInstanceLifecycleUtil', + ); + +/** Resolve this app's load-unit-instance lifecycle util directly from its bag (no active scope needed). */ +export function loadUnitInstanceLifecycleUtilFromBag( + bag: TeggScopeBag, +): LifecycleUtil { + return lifecycleUtilFromBag(bag, LOAD_UNIT_INSTANCE_LIFECYCLE_UTIL_SLOT); +} From 35f5d0be2992af97f7b92607f64a300959584e1d Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 15:17:04 +0800 Subject: [PATCH 11/16] fix(tegg): scope teggConfig configNames in the per-app TeggScope bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit teggConfig boots before the tegg plugin (tegg depends on teggConfig), so it is the first tegg lifecycle to read/write the per-app scoped `configNames`. The tegg plugin only creates `app._teggScopeBag` in its own `configWillLoad` — too late for teggConfig's `#loadModuleConfigs` read. Under concurrent multi-app boot that read escaped to the process-default bag once a second app was live, tripping the strict-mode fuse. teggConfig now creates `app._teggScopeBag` if absent and runs its configNames set/read/clear inside `TeggScope.run`; the tegg plugin reuses the bag via `??=`. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/plugin/config/package.json | 1 + tegg/plugin/config/src/app.ts | 40 +++++++++++++++++++++++++-------- tegg/plugin/tegg/src/app.ts | 7 ++++-- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/tegg/plugin/config/package.json b/tegg/plugin/config/package.json index bb08ae525c..bc804ed837 100644 --- a/tegg/plugin/config/package.json +++ b/tegg/plugin/config/package.json @@ -53,6 +53,7 @@ "dependencies": { "@eggjs/tegg-common-util": "workspace:*", "@eggjs/tegg-loader": "workspace:*", + "@eggjs/tegg-types": "workspace:*", "@eggjs/utils": "workspace:*" }, "devDependencies": { diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index dbc6d86069..4346a65f91 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -6,29 +6,49 @@ import { ModuleConfigUtil } from '@eggjs/tegg-common-util'; import type { ModuleReference } from '@eggjs/tegg-common-util'; import { TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import { TeggScope, type TeggScopeBag } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { ModuleScanner } from './lib/ModuleScanner.ts'; const debug = debuglog('egg/tegg/plugin/config/app'); +// `_teggScopeBag` is declared by the tegg plugin's type augmentation, which this +// (lower-layer) plugin does not import; carry the shape locally instead. +type AppWithScope = Application & { _teggScopeBag?: TeggScopeBag }; + export default class App implements ILifecycleBoot { - private readonly app: Application; + private readonly app: AppWithScope; constructor(app: Application) { - this.app = app; - const configNames = this.app.loader.getTypeFiles('module'); - ModuleConfigUtil.setConfigNames(configNames); + this.app = app as AppWithScope; + // teggConfig boots BEFORE the tegg plugin (tegg depends on teggConfig), so it + // is the FIRST tegg lifecycle to touch the per-app scoped `configNames`. The + // tegg plugin owns `app._teggScopeBag` but only creates it in its own + // configWillLoad — too late for the read in `#loadModuleConfigs` below. Create + // the bag here if absent (the tegg plugin reuses it via `??=`) and run every + // configNames access inside it; otherwise, under concurrent multi-app boot, + // the access escapes to the process-default bag and the strict-mode fuse + // throws (cross-talk between apps). + this.app._teggScopeBag ??= TeggScope.createBag(); + TeggScope.run(this.app._teggScopeBag, () => { + const configNames = this.app.loader.getTypeFiles('module'); + ModuleConfigUtil.setConfigNames(configNames); + }); } configWillLoad(): void { - this.#scanModuleReferences(); - this.#loadModuleConfigs(); + TeggScope.runMaybe(this.app._teggScopeBag, () => { + this.#scanModuleReferences(); + this.#loadModuleConfigs(); + }); } async loadMetadata(): Promise { - this.#scanModuleReferences(); - this.#loadModuleConfigs(); + TeggScope.runMaybe(this.app._teggScopeBag, () => { + this.#scanModuleReferences(); + this.#loadModuleConfigs(); + }); } #scanModuleReferences(): void { @@ -113,6 +133,8 @@ export default class App implements ILifecycleBoot { } async beforeClose(): Promise { - ModuleConfigUtil.setConfigNames(undefined); + TeggScope.runMaybe(this.app._teggScopeBag, () => { + ModuleConfigUtil.setConfigNames(undefined); + }); } } diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index fd5bc9812c..63afdd4563 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -31,8 +31,11 @@ export default class TeggAppBoot implements ILifecycleBoot { configWillLoad(): void { // Establish this app's TeggScope BEFORE any dependent plugin // (controller/aop/dal/eventbus) boots, so their boot hooks can resolve the - // per-app factories/managers from app._teggScopeBag. - this.app._teggScopeBag = TeggScope.createBag(); + // per-app factories/managers from app._teggScopeBag. The teggConfig plugin + // boots before us and may have already created the bag (it needs the scope + // for its own configNames loading) — reuse it rather than overwrite, so the + // configNames it wrote stays reachable. + this.app._teggScopeBag ??= TeggScope.createBag(); TeggScope.registerScope(this.app._teggScopeBag); this.app.config.coreMiddleware.push('teggCtxLifecycleMiddleware'); } From b87a2b8d1da0f38b682d55fa8346e61cfc9fca08 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 15:17:10 +0800 Subject: [PATCH 12/16] test(tegg): cover concurrent multi-app boot under vitest parallel Add MultiAppParallel.test.ts: `describe.concurrent` boots several `mm.app` instances at once (`cache: false` so same-baseDir bodies get distinct apps), each asserting per-app isolation of its singleton, data store and eventbus dispatch. Unlike MultiApp.test.ts (two different baseDirs driven sequentially), this reproduces same-baseDir concurrent boot, which surfaced the teggConfig configNames scope escape fixed in the previous commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugin/tegg/test/MultiAppParallel.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tegg/plugin/tegg/test/MultiAppParallel.test.ts diff --git a/tegg/plugin/tegg/test/MultiAppParallel.test.ts b/tegg/plugin/tegg/test/MultiAppParallel.test.ts new file mode 100644 index 0000000000..31a525e8ff --- /dev/null +++ b/tegg/plugin/tegg/test/MultiAppParallel.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; + +import { mm } from '@eggjs/mock'; +import { describe, it } from 'vitest'; + +import { CounterProducer } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts'; +import { CounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts'; +import { getAppBaseDir } from './utils.ts'; + +async function waitFor(predicate: () => boolean, timeout = 5000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timeout'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** + * Parallel sibling to MultiApp.test.ts. + * + * Where MultiApp.test.ts drives the two apps SEQUENTIALLY inside one `it`, this + * file uses `describe.concurrent` so vitest runs every `it` body AT THE SAME + * TIME: many apps boot, mutate, dispatch events and tear down concurrently. This + * mirrors how the suite runs under vitest's default parallel config + * (`pool: 'threads'`, `isolate: false`) — multiple `mm.app` instances alive in + * one process at once. + * + * Each `it` boots its OWN app with `cache: false`: `mm.app` caches by `baseDir` + * (see `createApp` in `@eggjs/mock`), so without it the concurrent bodies that + * share a `baseDir` would all receive the SAME cached instance. With `cache: + * false` every call yields a distinct app + its own `TeggScope` bag. While ≥2 + * apps are alive the strict-mode escape fuse is active throughout, so any per-app + * access that escaped its scope under concurrency would THROW rather than pass. + * + * The per-app increment / emit deltas are all distinct, so a cross-talk bug + * surfaces as a wrong total instead of coincidentally matching the expectation. + */ +const WORKERS = [ + { name: 'A', dir: 'multi-app-isolation', bumps: 1, emit: 11 }, + { name: 'B', dir: 'multi-app-isolation-b', bumps: 2, emit: 22 }, + { name: 'C', dir: 'multi-app-isolation', bumps: 3, emit: 33 }, + { name: 'D', dir: 'multi-app-isolation-b', bumps: 4, emit: 44 }, +] as const; + +describe.concurrent('plugin/tegg/test/MultiAppParallel.test.ts', () => { + for (const w of WORKERS) { + it(`app ${w.name}: isolates singleton + store + eventbus under concurrent boot`, async () => { + // cache:false => a fresh app even when another worker shares this baseDir. + const app = mm.app({ baseDir: getAppBaseDir(w.dir), cache: false }); + await app.ready(); + try { + const counter = await app.getEggObject(CounterService); + + // 1) plain singleton state + for (let i = 0; i < w.bumps; i++) { + counter.increment(); + } + + // 2) DB-like per-app data store + counter.save(w.name, w.emit); + + // 3) eventbus emit-path: emit inside THIS app's context. doEmit must + // re-establish THIS app's scope, so the handler hits THIS app's + // CounterService — never a concurrently-booted sibling's. + await app.mockModuleContextScope(async (ctx: any) => { + const producer = await ctx.getEggObject(CounterProducer); + producer.emit(w.emit); + }); + await waitFor(() => counter.getEventCount() === w.emit); + + // Yield so every other concurrent app interleaves between mutate and + // assert — maximizing the chance a scope leak would be observed. + await new Promise((resolve) => setTimeout(resolve, 30)); + + assert.equal(counter.getCount(), w.bumps, `app ${w.name} must observe only its own ${w.bumps} increments`); + assert.equal(counter.getEventCount(), w.emit, `app ${w.name} must observe only its own emit (${w.emit})`); + assert.equal(counter.load(w.name), w.emit, `app ${w.name} store must keep only its own value`); + + const again = await app.getEggObject(CounterService); + assert.strictEqual(again, counter, `app ${w.name} must return the same singleton instance`); + } finally { + await app.close(); + } + }); + } +}); From e582e5bc3eeb73900e422272373975246910e1c5 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 21:20:27 +0800 Subject: [PATCH 13/16] fix(tegg): correct standalone preload loader + scope Runner proto lookup - EggModuleLoader.preLoad built a `loaderCache` it never populated, so `loaderCache.get(modulePath)!` passed `undefined` into createPreloadLoadUnit; create the loader inline like `load()` does. - Runner resolved the main runner proto via the process-global `PrototypeUtil.getClazzProto`; use the per-app `EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal` so concurrent Runners don't read clobbered class metadata. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/standalone/standalone/src/EggModuleLoader.ts | 12 ++---------- tegg/standalone/standalone/src/Runner.ts | 6 ++++-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tegg/standalone/standalone/src/EggModuleLoader.ts b/tegg/standalone/standalone/src/EggModuleLoader.ts index 9dea613ed9..87dbdf3dfb 100644 --- a/tegg/standalone/standalone/src/EggModuleLoader.ts +++ b/tegg/standalone/standalone/src/EggModuleLoader.ts @@ -1,11 +1,4 @@ -import { - EggLoadUnitType, - GlobalGraph, - type Loader, - type LoadUnit, - LoadUnitFactory, - ModuleDescriptorDumper, -} from '@eggjs/metadata'; +import { EggLoadUnitType, GlobalGraph, type LoadUnit, LoadUnitFactory, ModuleDescriptorDumper } from '@eggjs/metadata'; import type { Logger } from '@eggjs/tegg'; import type { ModuleReference } from '@eggjs/tegg-common-util'; import { LoaderFactory } from '@eggjs/tegg-loader'; @@ -69,13 +62,12 @@ export class EggModuleLoader { // do not leak into the process-default bag or any concurrent Runner. await TeggScope.run(TeggScope.createBag(), async () => { const loadUnits: LoadUnit[] = []; - const loaderCache = new Map(); const globalGraph = (GlobalGraph.instance = await EggModuleLoader.generateAppGraph(moduleReferences, options)); globalGraph.sort(); const moduleConfigList = globalGraph.moduleConfigList; for (const moduleConfig of moduleConfigList) { const modulePath = moduleConfig.path; - const loader = loaderCache.get(modulePath)!; + const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE); const loadUnit = await LoadUnitFactory.createPreloadLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); loadUnits.push(loadUnit); } diff --git a/tegg/standalone/standalone/src/Runner.ts b/tegg/standalone/standalone/src/Runner.ts index e5461a4e9f..f1ca5e734b 100644 --- a/tegg/standalone/standalone/src/Runner.ts +++ b/tegg/standalone/standalone/src/Runner.ts @@ -15,6 +15,7 @@ import { } from '@eggjs/dal-plugin'; import { type EggPrototype, + EggPrototypeFactory, EggPrototypeLifecycleUtil, GlobalGraph, type LoadUnit, @@ -24,7 +25,6 @@ import { } from '@eggjs/metadata'; import { type EggProtoImplClass, - PrototypeUtil, type ModuleConfigHolder, ModuleConfigs, ConfigSourceQualifierAttribute, @@ -289,7 +289,9 @@ export class Runner { if (!runnerClass) { throw new Error('not found runner class. Do you add @Runner decorator?'); } - const proto = PrototypeUtil.getClazzProto(runnerClass); + // Prefer the per-app scoped lookup so parallel Runners don't fall back to + // process-global class metadata when a per-scope prototype is registered. + const proto = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(runnerClass); if (!proto) { throw new Error(`can not get proto for clazz ${runnerClass.name}`); } From 3923892e359987c56a79a735d5afb9517248fb9f Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 21:20:30 +0800 Subject: [PATCH 14/16] fix(tegg): close remaining per-app scope/teardown gaps - MysqlDataSourceManager.clear() now also clears the `dataSources` map so a closed app releases its datasource objects (the map was left populated). - mcp-proxy proxy handlers run detached from the request; re-enter the owning app's TeggScope bag before reading the scope-backed MCPControllerRegister.hooks. - application.unittest `hasMockModuleContext` was a process-global guard; move it into a per-app TeggScope slot so concurrent multi-app tests don't block each other. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dal/src/lib/MysqlDataSourceManager.ts | 4 ++ tegg/plugin/mcp-proxy/src/index.ts | 28 ++++++++---- .../src/app/extend/application.unittest.ts | 44 ++++++++++++------- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts index 44786827e4..a73f925fdc 100644 --- a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts +++ b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts @@ -58,6 +58,10 @@ export class MysqlDataSourceManager { } clear(): void { + // Release this app's datasource references on teardown. (Both maps are + // per-app; dropping them lets the MysqlDataSource objects be collected + // instead of lingering after the owning app closes.) + this.dataSources.clear(); this.dataSourceIndices.clear(); } diff --git a/tegg/plugin/mcp-proxy/src/index.ts b/tegg/plugin/mcp-proxy/src/index.ts index a993692c4a..eab0811d97 100644 --- a/tegg/plugin/mcp-proxy/src/index.ts +++ b/tegg/plugin/mcp-proxy/src/index.ts @@ -6,7 +6,7 @@ import url from 'node:url'; import { MCPControllerRegister } from '@eggjs/controller-plugin/lib/impl/mcp/MCPControllerRegister'; import type { MCPControllerHook } from '@eggjs/controller-plugin/lib/impl/mcp/MCPControllerRegister'; -import { MCPProtocols } from '@eggjs/tegg-types'; +import { MCPProtocols, TeggScope, type TeggScopeBag } from '@eggjs/tegg-types'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; // @ts-expect-error await-event is not typed @@ -60,11 +60,16 @@ export const MCPProxyHook: MCPControllerHook = { (self.app as any).mcpProxy.setProxyHandler(MCPProtocols.SSE, async (req: any, res: any) => { const sessionId = querystring.parse(url.parse(req.url!).query ?? '').sessionId as string; const ctx = self.app.createContext(req, res) as unknown as Context; - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { - await hook.preProxy?.(ctx, req, res); + // This proxy handler runs detached from the request, so re-enter the + // owning app's scope before reading the scope-backed hook list. + const bag = (self.app as { _teggScopeBag?: TeggScopeBag })._teggScopeBag; + await TeggScope.runMaybe(bag, async () => { + if (MCPControllerRegister.hooks.length > 0) { + for (const hook of MCPControllerRegister.hooks) { + await hook.preProxy?.(ctx, req, res); + } } - } + }); let transport: SSEServerTransport; const existingTransport = self.transports[sessionId]; if (existingTransport instanceof SSEServerTransport) { @@ -135,11 +140,16 @@ export const MCPProxyHook: MCPControllerHook = { mw = compose([mw, self.globalMiddlewares]); } const ctx = self.app.createContext(req, res) as unknown as Context; - if (MCPControllerRegister.hooks.length > 0) { - for (const hook of MCPControllerRegister.hooks) { - await hook.preProxy?.(ctx, req, res); + // Detached proxy handler — re-enter the owning app's scope before + // reading the scope-backed hook list. + const bag = (self.app as { _teggScopeBag?: TeggScopeBag })._teggScopeBag; + await TeggScope.runMaybe(bag, async () => { + if (MCPControllerRegister.hooks.length > 0) { + for (const hook of MCPControllerRegister.hooks) { + await hook.preProxy?.(ctx, req, res); + } } - } + }); const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId) { res.writeHead(500, { 'content-type': 'application/json' }); diff --git a/tegg/plugin/tegg/src/app/extend/application.unittest.ts b/tegg/plugin/tegg/src/app/extend/application.unittest.ts index 8c56d51fb5..5c00474ae1 100644 --- a/tegg/plugin/tegg/src/app/extend/application.unittest.ts +++ b/tegg/plugin/tegg/src/app/extend/application.unittest.ts @@ -6,15 +6,26 @@ import { EggContextImpl } from '../../lib/EggContextImpl.ts'; const TEGG_LIFECYCLE_CACHE: Map = new Map(); -let hasMockModuleContext = false; +// Per-app: the "is a mock module context already open?" guard. Kept in a +// TeggScope slot (not a module-global) so concurrent multi-app tests don't block +// each other — each app's guard lives in its own bag. +const HAS_MOCK_MODULE_CONTEXT_SLOT = Symbol('tegg:plugin:hasMockModuleContext'); + +function getHasMockModuleContext(): boolean { + return TeggScope.getOr(HAS_MOCK_MODULE_CONTEXT_SLOT, () => false, 'hasMockModuleContext') ?? false; +} + +function setHasMockModuleContext(value: boolean): void { + TeggScope.set(HAS_MOCK_MODULE_CONTEXT_SLOT, value); +} export default class TEggPluginApplicationUnittest { async mockModuleContext(this: Application, data?: any): Promise { this.deprecate('app.mockModuleContext is deprecated, use mockModuleContextScope.'); - if (hasMockModuleContext) { - throw new Error('should not call mockModuleContext twice.'); - } const doWork = async (): Promise => { + if (getHasMockModuleContext()) { + throw new Error('should not call mockModuleContext twice.'); + } // @ts-expect-error mockContext is not typed const ctx = this.mockContext(data) as Context; const teggCtx = new EggContextImpl(ctx); @@ -23,21 +34,20 @@ export default class TEggPluginApplicationUnittest { if (teggCtx.init) { await teggCtx.init(lifecycle); } - hasMockModuleContext = true; + setHasMockModuleContext(true); return ctx; }; return TeggScope.runMaybe(this._teggScopeBag, doWork); } async destroyModuleContext(this: Application, ctx: Context): Promise { - hasMockModuleContext = false; - - const teggCtx = ctx.teggContext; - if (!teggCtx) { - return; - } - const lifecycle = TEGG_LIFECYCLE_CACHE.get(teggCtx); const doWork = async (): Promise => { + setHasMockModuleContext(false); + const teggCtx = ctx.teggContext; + if (!teggCtx) { + return; + } + const lifecycle = TEGG_LIFECYCLE_CACHE.get(teggCtx); if (teggCtx.destroy && lifecycle) { await teggCtx.destroy(lifecycle); } @@ -46,12 +56,12 @@ export default class TEggPluginApplicationUnittest { } async mockModuleContextScope(this: Application, fn: (ctx: Context) => Promise, data?: any): Promise { - if (hasMockModuleContext) { - throw new Error( - 'mockModuleContextScope can not use with mockModuleContext, should use mockModuleContextScope only.', - ); - } const doWork = (): Promise => { + if (getHasMockModuleContext()) { + throw new Error( + 'mockModuleContextScope can not use with mockModuleContext, should use mockModuleContextScope only.', + ); + } // @ts-expect-error mockContextScope only exists in MockApplication return this.mockContextScope(async (ctx: Context) => { const teggCtx = new EggContextImpl(ctx); From 45482bda60379b1bcb6009fe1662548a5f3dcef4 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 21:20:32 +0800 Subject: [PATCH 15/16] test(tegg): tighten multi-app fixtures - MultiApp.test.ts: close app1 in a `finally` so an early assertion failure cannot leak it into later cases. - BackgroundCounterService fixture: yield (setImmediate) before incrementing so the background-task isolation check truly runs across an async boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/plugin/tegg/test/MultiApp.test.ts | 15 +++++++++------ .../counter-module/BackgroundCounterService.ts | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tegg/plugin/tegg/test/MultiApp.test.ts b/tegg/plugin/tegg/test/MultiApp.test.ts index 323041f149..76fd32117c 100644 --- a/tegg/plugin/tegg/test/MultiApp.test.ts +++ b/tegg/plugin/tegg/test/MultiApp.test.ts @@ -106,12 +106,15 @@ describe('plugin/tegg/test/MultiApp.test.ts', () => { it('should not leak state between sequential app lifecycles', async () => { const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); await app1.ready(); - const counter1 = await app1.getEggObject(CounterService); - counter1.increment(); - counter1.increment(); - counter1.increment(); - assert.equal(counter1.getCount(), 3); - await app1.close(); + try { + const counter1 = await app1.getEggObject(CounterService); + counter1.increment(); + counter1.increment(); + counter1.increment(); + assert.equal(counter1.getCount(), 3); + } finally { + await app1.close(); + } const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); await app2.ready(); diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts index 7c4670deeb..b8ecbe57dd 100644 --- a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts @@ -18,6 +18,10 @@ export class BackgroundCounterService { schedule(times: number): void { const counterService = this.counterService; this.backgroundTaskHelper.run(async () => { + // Yield first so the increments run AFTER the scheduling context has + // exited — i.e. truly across the async/background boundary, which is what + // the multi-app background-task isolation test means to exercise. + await new Promise((resolve) => setImmediate(resolve)); for (let i = 0; i < times; i++) { counterService.increment(); } From 84d47627cec240f8f15fcc5805961796dbef3f70 Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 21:20:34 +0800 Subject: [PATCH 16/16] docs(tegg): match lifecycle-util guidance to the shipped facade app.*LifecycleUtil getters are bag-pinned, so hook registration through them needs no TeggScope.run wrap, and the facade is an explicit delegating object, not a Proxy. Update tegg/CLAUDE.md Rule 3 + the performance note and the AGENTS.md summary accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 ++++-- tegg/CLAUDE.md | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 715ae583c2..26f4db6950 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,10 @@ Then re-run tests. - keep public API changes deliberate and documented - use `oxfmt` and `oxlint --type-aware` conventions already present in the repo - **tegg multi-app isolation**: do NOT introduce new process-global mutable - runtime state in `tegg/`; per-app state must be backed by a `TeggScope` slot, - and plugin lifecycle-hook registration must run inside + runtime state in `tegg/`; per-app state must be backed by a `TeggScope` slot. + Hooks registered through the bag-pinned `app.*LifecycleUtil` getters need no + extra wrap; detached/escape-point access (timers, emitter listeners, proxy + handlers, module-level lifecycle-util statics) must run inside `TeggScope.run(app._teggScopeBag, ...)`. See the "Multi-App Isolation (TeggScope)" section in `tegg/CLAUDE.md` for the full rules. diff --git a/tegg/CLAUDE.md b/tegg/CLAUDE.md index c599753dad..2923149c0a 100644 --- a/tegg/CLAUDE.md +++ b/tegg/CLAUDE.md @@ -324,14 +324,17 @@ When you touch tegg core/plugins, follow these rules: two-tier: a global base for import-time creators + a per-app overlay for boot-time, app-capturing creators.) -3. **Lifecycle-hook registration must run in the app scope.** Any plugin boot - that calls `app.{loadUnit,eggPrototype,eggObject,eggContext,loadUnitInstance}LifecycleUtil.registerLifecycle(hook)` - MUST wrap it in `TeggScope.run(this.app._teggScopeBag, () => { ... })` (and - the matching `deleteLifecycle` in `beforeClose`). The lifecycle utils are - per-app, so an unwrapped registration lands in the wrong bag and the hook - never fires during boot. Do **not** register lifecycle hooks in the boot - **constructor** — `app._teggScopeBag` does not exist yet; do it in - `configWillLoad`/`configDidLoad`/`didLoad`. +3. **Lifecycle-hook registration via `app.*LifecycleUtil` is bag-pinned.** + Calling `app.{loadUnit,eggPrototype,eggObject,eggContext,loadUnitInstance}LifecycleUtil.registerLifecycle(hook)` + (and the matching `deleteLifecycle` in `beforeClose`) does **not** need a + `TeggScope.run` wrap — these app getters are pinned to this app's bag (via + `xxxLifecycleUtilFromBag`), so they resolve the correct per-app util even with + no active scope. Wrapping is still fine when the same block does other + scope-dependent work (as the tegg plugin's own boot does). Do **not** register + lifecycle hooks in the boot **constructor** — `app._teggScopeBag` does not + exist yet; do it in `configWillLoad`/`configDidLoad`/`didLoad`. (Accessing a + lifecycle util through a module-level static instead of `app.*LifecycleUtil` + still needs an active scope.) 4. **Resolve egg objects per-app.** To get a proto from a class, prefer `EggPrototypeFactory.instance.getPrototypeByClazz(clazz)` (per-app) before @@ -361,9 +364,9 @@ When you touch tegg core/plugins, follow these rules: **Performance:** `TeggScope.resolve` adds ~8 ns/access and `TeggScope.run` ~5 ns/call over a plain static read (Node 22); egg already runs on AsyncLocalStorage, so there is no new process-wide async penalty. The cost is -negligible relative to real request work. The per-app lifecycle-util facade is a -`Proxy` (~27 ns/call) — fine in practice; replace with an explicit delegating -object only if a future profile shows it matters. +negligible relative to real request work. The per-app lifecycle-util facade is an +explicit delegating object (not a `Proxy`) — each method is a direct slot-resolve +plus a method call, with no per-access trap or bound-function allocation. ## Common Patterns