From ed5a82f93072da451f4d187fcaf61a9aed8476b8 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 01:04:21 +0800 Subject: [PATCH 1/3] Fix invariant config validation readiness --- scripts/test-invariants.spec.ts | 32 +++++++++++++++++++++++++++++++- scripts/test-invariants.ts | 23 +++++++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index edea360095..d2582d3c68 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState, Service } from 'cordis' +import { Context, FiberState, Service, ValidationError } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import z from 'schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' @@ -136,6 +137,35 @@ describe('global test invariant host', () => { expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) + it('preserves config validation failures without starting the rejected plugin', async () => { + const ctx = new Context() + const apply = vi.fn(function invalidConfigApply() { + throw new Error('invalid plugin apply executed') + }) + const plugin = { + apply, + Config: z.object({ + requiredValue: z.string().required(), + }), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError: unknown = await fiber.then( + () => undefined, + (error: unknown) => error, + ) + expect(firstError).toBeInstanceOf(ValidationError) + expect(firstError).toHaveProperty('message', expect.stringMatching(/requiredValue/)) + await ctx.plugin(TestInvariantProbe) + const secondError: unknown = await fiber.then( + () => undefined, + (error: unknown) => error, + ) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 8ebf7a6243..a0fc6f295e 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -75,7 +75,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge if (hasBarrierOwner(host, this.ctx)) { return originalPlugin.call(this, plugin, config, getOuterStack) } - if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) + if (callback === undefined) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } const fiber = originalPlugin.call( this, @@ -84,7 +86,7 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge getOuterStack, ) host.barrierOwners.add(fiber.ctx.fiber) - return joinInvariantStartup(fiber, host.ready) + return joinInvariantStartup(fiber, host.ready, true) } /** @@ -204,8 +206,21 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi } } -function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { - const readiness = invariantReady.then(() => fiber.await()) +function joinInvariantStartup( + fiber: PluginFiber, + invariantReady: Promise, + disposePendingFailure = false, +): PluginFiber { + const initialized = disposePendingFailure + ? fiber.await().catch(async (error: unknown) => { + // Config validation is the only failure recorded while a gated fiber + // is still PENDING. Dispose it before readiness publication can + // refresh the rejected fiber with its uninitialized config. + if (fiber.state === FiberState.PENDING) await fiber.dispose() + throw error + }) + : Promise.resolve() + const readiness = initialized.then(() => invariantReady).then(() => fiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined From 9853e74d0e2111f621430757ca1778d5d6ec7a13 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 01:18:10 +0800 Subject: [PATCH 2/3] Close invariant readiness validation race --- scripts/test-invariants.spec.ts | 82 ++++++++++++++++++++++++++------- scripts/test-invariants.ts | 13 +++--- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index d2582d3c68..b119f9657b 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -33,6 +33,28 @@ function deferred(): { readonly promise: Promise; readonly resolve: () => return { promise, resolve } } +function requiredConfig() { + return z.object({ + requiredValue: z.string().required(), + }) +} + +function invalidConfigApply(): never { + throw new Error('invalid plugin apply executed') +} + +async function rejectionOf(fiber: ReturnType): Promise { + return fiber.then( + () => undefined, + (error: unknown) => error, + ) +} + +function expectRequiredConfigValidation(error: unknown): void { + expect(error).toBeInstanceOf(ValidationError) + expect(error).toHaveProperty('message', expect.stringMatching(/requiredValue/)) +} + async function withFakeCompanions( create: (path: string, index: number) => () => Promise, run: () => Promise, @@ -139,33 +161,61 @@ describe('global test invariant host', () => { it('preserves config validation failures without starting the rejected plugin', async () => { const ctx = new Context() - const apply = vi.fn(function invalidConfigApply() { - throw new Error('invalid plugin apply executed') - }) + const apply = vi.fn(invalidConfigApply) const plugin = { apply, - Config: z.object({ - requiredValue: z.string().required(), - }), + Config: requiredConfig(), } const fiber = ctx.plugin(plugin, {}) - const firstError: unknown = await fiber.then( - () => undefined, - (error: unknown) => error, - ) - expect(firstError).toBeInstanceOf(ValidationError) - expect(firstError).toHaveProperty('message', expect.stringMatching(/requiredValue/)) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) await ctx.plugin(TestInvariantProbe) - const secondError: unknown = await fiber.then( - () => undefined, - (error: unknown) => error, - ) + const secondError = await rejectionOf(fiber) expect(secondError).toBe(firstError) expect(fiber.state).toBe(FiberState.DISPOSED) expect(apply).not.toHaveBeenCalled() }) + it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const apply = vi.fn(invalidConfigApply) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: z.intersect([ + z.transform(z.any(), () => { + queueMicrotask(() => { + disposeQueuedReadiness = ctx.provide(TEST_INVARIANT_READY_SERVICE, true) + }) + return {} + }, true), + requiredConfig(), + ]), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + + await started + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + disposeQueuedReadiness() + release() + await ctx.plugin(TestInvariantProbe) + + const secondError = await rejectionOf(fiber) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }, + ) + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a0fc6f295e..649e680cc2 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -85,8 +85,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) + const initiallyPending = fiber.state === FiberState.PENDING host.barrierOwners.add(fiber.ctx.fiber) - return joinInvariantStartup(fiber, host.ready, true) + return joinInvariantStartup(fiber, host.ready, initiallyPending) } /** @@ -209,14 +210,14 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi function joinInvariantStartup( fiber: PluginFiber, invariantReady: Promise, - disposePendingFailure = false, + disposeInitialFailure = false, ): PluginFiber { - const initialized = disposePendingFailure + const initialized = disposeInitialFailure ? fiber.await().catch(async (error: unknown) => { // Config validation is the only failure recorded while a gated fiber - // is still PENDING. Dispose it before readiness publication can - // refresh the rejected fiber with its uninitialized config. - if (fiber.state === FiberState.PENDING) await fiber.dispose() + // is initially PENDING. Dispose it even if queued readiness publication + // changes its state before this rejection handler runs. + await fiber.dispose() throw error }) : Promise.resolve() From a4747f43bdda74955771a2e3cb236d5db74c1aaa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 01:28:34 +0800 Subject: [PATCH 3/3] Probe invariant readiness on raw fibers --- scripts/test-invariants.spec.ts | 67 ++++++++++++++++++++++++++++++--- scripts/test-invariants.ts | 12 ++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index b119f9657b..2c00bc7439 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -39,6 +39,18 @@ function requiredConfig() { }) } +function queuedReadinessConfig( + ctx: Context, + onPublished: (dispose: () => void) => void, +) { + return z.transform(z.any(), () => { + queueMicrotask(() => { + onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) + }) + return {} + }, true) +} + function invalidConfigApply(): never { throw new Error('invalid plugin apply executed') } @@ -186,12 +198,9 @@ describe('global test invariant host', () => { const plugin = { apply, Config: z.intersect([ - z.transform(z.any(), () => { - queueMicrotask(() => { - disposeQueuedReadiness = ctx.provide(TEST_INVARIANT_READY_SERVICE, true) - }) - return {} - }, true), + queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), requiredConfig(), ]), } @@ -216,6 +225,52 @@ describe('global test invariant host', () => { ) }) + it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const failure = new Error('valid plugin apply failed') + const applied = deferred() + const apply = vi.fn(function validConfigApply() { + applied.resolve() + throw failure + }) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), + } + + const fiber = ctx.plugin(plugin, {}) + const returnedError = rejectionOf(fiber) + try { + await Promise.all([started, applied.promise]) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness() + release() + + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + } finally { + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness?.() + release() + } + }, + ) + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 649e680cc2..9705f590de 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -85,7 +85,7 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) - const initiallyPending = fiber.state === FiberState.PENDING + const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING host.barrierOwners.add(fiber.ctx.fiber) return joinInvariantStartup(fiber, host.ready, initiallyPending) } @@ -212,16 +212,20 @@ function joinInvariantStartup( invariantReady: Promise, disposeInitialFailure = false, ): PluginFiber { + // RegistryService returns a thenable wrapper whose context still points to + // the raw Fiber. Calling inherited await() on the wrapper would return and + // assimilate that thenable, accidentally following later plugin startup. + const rawFiber = fiber.ctx.fiber const initialized = disposeInitialFailure - ? fiber.await().catch(async (error: unknown) => { + ? rawFiber.await().catch(async (error: unknown) => { // Config validation is the only failure recorded while a gated fiber // is initially PENDING. Dispose it even if queued readiness publication // changes its state before this rejection handler runs. - await fiber.dispose() + await rawFiber.dispose() throw error }) : Promise.resolve() - const readiness = initialized.then(() => invariantReady).then(() => fiber.await()) + const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined