diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index e2406837ca..40c93fe9ad 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -120,18 +120,20 @@ describe('global test invariant host', () => { const releaseDelayed = deferred() const order: string[] = [] let delayedCompanion: TestInvariantCompanion | undefined + const companionNestedApply = vi.fn(function companionNestedApply() {}) await withFakeCompanions( (path, index) => async () => { const companion: TestInvariantCompanion = { name: `test-invariant-${index}`, inject: ['invariants'], - async apply() { + async apply(companionCtx) { order.push(`companion-start:${path}`) if (index === 0) { delayedStarted.resolve() await releaseDelayed.promise } + if (index === 1) await companionCtx.plugin(companionNestedApply) order.push(`companion-active:${path}`) return () => {} }, @@ -173,6 +175,7 @@ describe('global test invariant host', () => { expect(targetFiber.state).toBe(FiberState.ACTIVE) expect(targetApply).toHaveBeenCalledOnce() expect(nestedApply).toHaveBeenCalledOnce() + expect(companionNestedApply).toHaveBeenCalledOnce() const targetIndex = order.indexOf('target') expect(targetIndex).toBeGreaterThan(-1) expect(order.slice(0, targetIndex)).toHaveLength(Object.keys(testInvariantCompanions).length * 2) @@ -187,6 +190,52 @@ describe('global test invariant host', () => { ) }) + it('holds plugins registered on a root-derived context until companion readiness', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const rootApply = vi.fn(function rootApply() {}) + const derivedApply = vi.fn(function derivedApply() {}) + const derived = ctx.extend() + .isolate('testInvariantDerived') + .intercept('testInvariantDerived', {}) + + const rootFiber = ctx.plugin(rootApply) + const derivedFiber = derived.plugin(derivedApply) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(rootApply).not.toHaveBeenCalled() + expect(derivedApply).not.toHaveBeenCalled() + expect(derivedFiber.inject).toEqual({ + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + releaseDelayed.resolve() + await Promise.all([rootFiber, derivedFiber]) + expect(rootFiber.state).toBe(FiberState.ACTIVE) + expect(derivedFiber.state).toBe(FiberState.ACTIVE) + expect(rootApply).toHaveBeenCalledOnce() + expect(derivedApply).toHaveBeenCalledOnce() + }, + ) + }) + it.each(['load', 'startup'] as const)( 'rejects a target when a lazy companion fails during %s without starting the target', async (phase) => { diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 00f24e6d2f..2ba460a9da 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -46,6 +46,7 @@ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ interface InvariantHost { readonly byCallback: ReadonlyMap + readonly barrierOwners: WeakSet readonly ready: Promise } @@ -65,13 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) if (existing !== undefined) { - return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + return hasBarrierOwner(host, this.ctx) ? existing : joinInvariantStartup(existing, host.ready) } - // Nested plugins run inside a target that already crossed the root barrier. - // Adding the same root-owned dependency there would make child lifecycle - // depend on an unrelated isolation scope and can deadlock companion startup. - if (this.ctx !== root) return originalPlugin.call(this, plugin, config, getOuterStack) + // Causal descendants of a gated target have already crossed the barrier. + // Host service and companion descendants also bypass it so their own startup + // cannot depend on the readiness they are responsible for providing. + if (hasBarrierOwner(host, this.ctx)) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) const fiber = originalPlugin.call( @@ -80,6 +83,7 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) + host.barrierOwners.add(fiber.ctx.fiber) return joinInvariantStartup(fiber, host.ready) } @@ -120,11 +124,13 @@ export function testInvariantCompanionPaths(testPath: string): string[] { function startInvariantHost(root: Context): InvariantHost { const byCallback = new Map() + const barrierOwners = new WeakSet() const mount = (plugin: Plugin, config?: unknown): PluginFiber => { const fiber = originalPlugin.call(root.registry, plugin, config) const callback = root.registry.resolve(plugin) if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin') byCallback.set(callback, fiber) + barrierOwners.add(fiber.ctx.fiber) return fiber } @@ -157,11 +163,21 @@ function startInvariantHost(root: Context): InvariantHost { await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path))) root.provide(TEST_INVARIANT_READY_SERVICE, true) }) - const host = { byCallback, ready } + const host = { byCallback, barrierOwners, ready } hosts.set(root, host) return host } +function hasBarrierOwner(host: InvariantHost, ctx: Context): boolean { + let fiber = ctx.fiber + while (true) { + if (host.barrierOwners.has(fiber)) return true + const parent = fiber.parent.fiber + if (parent === fiber) return false + fiber = parent + } +} + async function requireActive(fiber: PluginFiber, label: string): Promise { await fiber.await() if (fiber.state !== FiberState.ACTIVE) {