From 574ecbb40cce7e70ca9a9ccc90b30b7ef2582dbb Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 04:11:39 +0000 Subject: [PATCH] fix(devframe): dedup wire-service install across contexts sharing an RPC host A single wire service declared on a definition could register its RPC twice and trip DF0021 when the definition is mounted into two contexts backed by one RPC host (e.g. a kit mounting it alongside another context, so both iterate def.services). The install-dedup guard was per services-host instance, so the second context re-ran the service factory and re-registered its RPC. Key the dedup registry by the shared RPC host so the first install wins across sibling contexts; a sibling hit reuses the cached API without re-running setup, while a genuine same-host re-install still warns DF0066. --- .../src/node/__tests__/services.test.ts | 32 +++++++++++++ packages/devframe/src/node/host-services.ts | 47 +++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts index d56283d79..8fe08ed5b 100644 --- a/packages/devframe/src/node/__tests__/services.test.ts +++ b/packages/devframe/src/node/__tests__/services.test.ts @@ -205,6 +205,38 @@ describe('wire services (install / ready)', () => { expect(warn.mock.calls.flat().join('\n')).toContain('DF0066') }) + it('installs a service once across two contexts that share one RPC host (no DF0021)', async () => { + const { ctx } = await createCtx() + const { ctx: ctx2 } = await createCtx() + // A kit/hub can mount a definition into two contexts backed by a single + // RPC host; both then iterate `def.services` and install the same service. + ;(ctx2 as { rpc: unknown }).rpc = ctx.rpc + + let setupRuns = 0 + const declare = () => defineTestService({ + setup: (scoped) => { + setupRuns++ + scoped.rpc.register({ name: 'ping', handler: () => 'pong' }) + return { ok: true } + }, + }) + + void ctx.services.install(declare()) + await ctx.services.ready() + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + void ctx2.services.install(declare()) + await expect(ctx2.services.ready()).resolves.toBeUndefined() + + // The factory ran once; the RPC is registered once and stays callable. + expect(setupRuns).toBe(1) + expect(warn.mock.calls.flat().join('\n')).not.toContain('DF0021') + await expect((ctx.rpc.invokeLocal as (m: string) => Promise)('test:svc:ping')).resolves.toBe('pong') + // Both contexts expose the service API for in-process `get`. + expect(ctx.services.get('@test/svc')).toEqual({ ok: true }) + expect(ctx2.services.get('@test/svc')).toEqual({ ok: true }) + }) + it('skips an optional descriptor whose package cannot be imported', async () => { const { ctx } = await createCtx() const install = ctx.services.install({ package: '@test/does-not-exist' }) diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts index a6d2a5538..c26d6d77f 100644 --- a/packages/devframe/src/node/host-services.ts +++ b/packages/devframe/src/node/host-services.ts @@ -16,6 +16,18 @@ import { deepMergeOptionSets, expandResolveFrom, importServicePackage, satisfies const debug = createDebug('devframe:services') +/** + * Per-RPC-host registry of the services already installed against it, keyed + * by the RPC host object. A single wire service can be declared from more + * than one devframe context that shares one RPC host (e.g. a kit that mounts + * a definition alongside another context, so both iterate `def.services`). + * The per-instance `installed` guard can't see across those sibling hosts, so + * the second context would re-run the service factory and re-register its RPC, + * hitting DF0021. Sharing the registry by RPC host makes the first install win + * across every context on that host. + */ +const installedByRpcHost = new WeakMap>() + interface PendingServiceEntry { input: DevframeServiceInput resolveFrom?: string | null @@ -72,11 +84,29 @@ export class DevframeServicesHostImpl implements DevframeServicesHost { private services = new Map() private listeners = new Map void>>() private pending = new Map() - private installed = new Map() + private localInstalled = new Map() private readyPromise: Promise | undefined constructor(private context?: DevframeNodeContext) {} + /** + * The install-dedup registry, shared across every services host that shares + * this host's RPC host so the first install of a package wins across sibling + * contexts. Falls back to a per-instance map when there is no RPC host (a + * context-less host only exercises the in-process `provide`/`get` tier). + */ + private get installed(): Map { + const rpc = this.context?.rpc as object | undefined + if (!rpc) + return this.localInstalled + let registry = installedByRpcHost.get(rpc) + if (!registry) { + registry = new Map() + installedByRpcHost.set(rpc, registry) + } + return registry + } + provide(id: ID, service: DevframeServiceOf): () => void { const key = id as string if (this.services.has(key)) @@ -190,8 +220,17 @@ export class DevframeServicesHostImpl implements DevframeServicesHost { private async installPackage(pkg: string, entries: PendingServiceEntry[]): Promise { // Dedup: first installation wins; a later install's options are ignored. if (this.installed.has(pkg)) { - diagnostics.DF0066({ package: pkg }) - return this.installed.get(pkg) + const api = this.installed.get(pkg) + // A sibling context sharing this RPC host already constructed the + // service. Expose the cached API here too, but skip re-running the + // factory (which would re-register its RPC and hit DF0021). Only a + // re-install on the same host (it already provides it) is the noisy + // duplicate DF0066 warns about. + if (this.services.has(pkg)) + diagnostics.DF0066({ package: pkg }) + else + this.provide(pkg, api as DevframeServiceOf) + return api } const definitions = entries.filter(entry => isServiceDefinition(entry.input)) @@ -211,7 +250,7 @@ export class DevframeServicesHostImpl implements DevframeServicesHost { debug('installing service %s@%s (scope %s)', def.package, def.version, def.scope) const scoped = this.context.scope(def.scope) const api = await def.setup(scoped, options === undefined ? {} : { options }) - this.installed.set(def.package, api) + this.installed.set(pkg, api) this.provide(def.package, api as DevframeServiceOf) await this.advertise(def) return api