Bladeren bron

Merge invariant readiness fix from Web transcript

Hypatia May 2 maanden geleden
bovenliggende
commit
0da7f17b7a
2 gewijzigde bestanden met toevoegingen van 160 en 5 verwijderingen
  1. 136 1
      scripts/test-invariants.spec.ts
  2. 24 4
      scripts/test-invariants.ts

+ 136 - 1
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'
@@ -32,6 +33,40 @@ function deferred(): { readonly promise: Promise<void>; readonly resolve: () =>
   return { promise, resolve }
 }
 
+function requiredConfig() {
+  return z.object({
+    requiredValue: z.string().required(),
+  })
+}
+
+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')
+}
+
+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(
   create: (path: string, index: number) => () => Promise<TestInvariantCompanion>,
   run: () => Promise<void>,
@@ -136,6 +171,106 @@ 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(invalidConfigApply)
+    const plugin = {
+      apply,
+      Config: requiredConfig(),
+    }
+
+    const fiber = ctx.plugin(plugin, {})
+    const firstError = await rejectionOf(fiber)
+    expectRequiredConfigValidation(firstError)
+    await ctx.plugin(TestInvariantProbe)
+    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([
+            queuedReadinessConfig(ctx, (dispose) => {
+              disposeQueuedReadiness = dispose
+            }),
+            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('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()

+ 24 - 4
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,
@@ -83,8 +85,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
     config,
     getOuterStack,
   )
+  const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING
   host.barrierOwners.add(fiber.ctx.fiber)
-  return joinInvariantStartup(fiber, host.ready)
+  return joinInvariantStartup(fiber, host.ready, initiallyPending)
 }
 
 /**
@@ -204,8 +207,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi
   }
 }
 
-function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise<void>): PluginFiber {
-  const readiness = invariantReady.then(() => fiber.await())
+function joinInvariantStartup(
+  fiber: PluginFiber,
+  invariantReady: Promise<void>,
+  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
+    ? 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 rawFiber.dispose()
+      throw error
+    })
+    : Promise.resolve()
+  const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await())
   const joined = Object.create(fiber) as PluginFiber
   joined.then = readiness.then.bind(readiness)
   return joined