Close invariant readiness validation race

This commit is contained in:
Hypatia May
2026-07-31 01:18:10 +08:00
parent ed5a82f930
commit 9853e74d0e
2 changed files with 73 additions and 22 deletions
+66 -16
View File
@@ -33,6 +33,28 @@ function deferred(): { readonly promise: Promise<void>; readonly resolve: () =>
return { promise, 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<Context['plugin']>): Promise<unknown> {
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( async function withFakeCompanions(
create: (path: string, index: number) => () => Promise<TestInvariantCompanion>, create: (path: string, index: number) => () => Promise<TestInvariantCompanion>,
run: () => Promise<void>, run: () => Promise<void>,
@@ -139,33 +161,61 @@ describe('global test invariant host', () => {
it('preserves config validation failures without starting the rejected plugin', async () => { it('preserves config validation failures without starting the rejected plugin', async () => {
const ctx = new Context() const ctx = new Context()
const apply = vi.fn(function invalidConfigApply() { const apply = vi.fn(invalidConfigApply)
throw new Error('invalid plugin apply executed')
})
const plugin = { const plugin = {
apply, apply,
Config: z.object({ Config: requiredConfig(),
requiredValue: z.string().required(),
}),
} }
const fiber = ctx.plugin(plugin, {}) const fiber = ctx.plugin(plugin, {})
const firstError: unknown = await fiber.then( const firstError = await rejectionOf(fiber)
() => undefined, expectRequiredConfigValidation(firstError)
(error: unknown) => error,
)
expect(firstError).toBeInstanceOf(ValidationError)
expect(firstError).toHaveProperty('message', expect.stringMatching(/requiredValue/))
await ctx.plugin(TestInvariantProbe) await ctx.plugin(TestInvariantProbe)
const secondError: unknown = await fiber.then( const secondError = await rejectionOf(fiber)
() => undefined,
(error: unknown) => error,
)
expect(secondError).toBe(firstError) expect(secondError).toBe(firstError)
expect(fiber.state).toBe(FiberState.DISPOSED) expect(fiber.state).toBe(FiberState.DISPOSED)
expect(apply).not.toHaveBeenCalled() 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 () => { it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => {
const delayedStarted = deferred() const delayedStarted = deferred()
const releaseDelayed = deferred() const releaseDelayed = deferred()
+7 -6
View File
@@ -85,8 +85,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
config, config,
getOuterStack, getOuterStack,
) )
const initiallyPending = fiber.state === FiberState.PENDING
host.barrierOwners.add(fiber.ctx.fiber) 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( function joinInvariantStartup(
fiber: PluginFiber, fiber: PluginFiber,
invariantReady: Promise<void>, invariantReady: Promise<void>,
disposePendingFailure = false, disposeInitialFailure = false,
): PluginFiber { ): PluginFiber {
const initialized = disposePendingFailure const initialized = disposeInitialFailure
? fiber.await().catch(async (error: unknown) => { ? fiber.await().catch(async (error: unknown) => {
// Config validation is the only failure recorded while a gated fiber // Config validation is the only failure recorded while a gated fiber
// is still PENDING. Dispose it before readiness publication can // is initially PENDING. Dispose it even if queued readiness publication
// refresh the rejected fiber with its uninitialized config. // changes its state before this rejection handler runs.
if (fiber.state === FiberState.PENDING) await fiber.dispose() await fiber.dispose()
throw error throw error
}) })
: Promise.resolve() : Promise.resolve()