From 2e9483826ae7411288d8ce41507b94614a4b6fae Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Mon, 15 Jun 2026 02:40:45 -0400 Subject: [PATCH] fix: reuse a reentrantly-registered type in TypeGenerator::mapAnnotatedObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In long-lived workers (Swoole/RoadRunner/FrankenPHP) the first request after a fresh worker could fail with "Cached type in registry is not the type returned by type mapper."; subsequent requests succeeded. Root cause: mapAnnotatedObject() checks the registry BEFORE instantiating the annotated object via the container, but never after. Instantiating it can reentrantly resolve and register this same type (a dependency in the container graph references it). The outer call then builds a duplicate instance and returns it, which trips RecursiveTypeMapper's registry identity check. (mapFactoryMethod() already guards its own cache after its container->get(); mapAnnotatedObject() did not.) Re-check the registry after container->get() and reuse the registered instance. RecursiveTypeMapper's identity check is intentionally left intact — it now verifies the fix rather than crashing on the benign duplicate. Refs #531 --- src/TypeGenerator.php | 10 ++++++++++ tests/TypeGeneratorTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php index 1220fa6797..a330a1ae84 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -125,6 +125,16 @@ public function mapAnnotatedObject(string $annotatedObjectClassName): MutableInt $type->description = $resolvedDescription; } + // Instantiating the annotated object via the container (above) can reentrantly resolve and + // register this very type — e.g. a dependency in the container graph references it. If so, + // reuse that instance rather than return the duplicate we just built, which would later trip + // the registry identity check in RecursiveTypeMapper (the first-request crash in long-lived + // workers). mapFactoryMethod() guards its own cache the same way after its container->get. + // See https://github.com/thecodingmachine/graphqlite/issues/531 + if ($this->typeRegistry->hasType($type->name)) { + return $this->typeRegistry->getMutableInterface($type->name); + } + return $type; } diff --git a/tests/TypeGeneratorTest.php b/tests/TypeGeneratorTest.php index 131e4ec228..3a15de5060 100644 --- a/tests/TypeGeneratorTest.php +++ b/tests/TypeGeneratorTest.php @@ -51,4 +51,36 @@ public function testextendAnnotatedObjectException(): void $this->expectException(MissingAnnotationException::class); $typeGenerator->extendAnnotatedObject(new stdClass(), $type); } + + public function testMapAnnotatedObjectReusesTypeRegisteredReentrantlyDuringContainerGet(): void + { + // Reproduces the cold-registry race behind #531: instantiating the annotated object via the + // container reentrantly resolves (and registers) this same type. mapAnnotatedObject must + // reuse that instance rather than build a duplicate — otherwise RecursiveTypeMapper's + // identity check throws "Cached type in registry is not the type returned by type mapper." + // on the first request in long-lived workers (Swoole/RoadRunner/FrankenPHP). + $typeRegistry = $this->getTypeRegistry(); + $reentrantlyRegistered = new MutableObjectType(['name' => 'TestObject', 'fields' => []], TypeFoo::class); + + $container = new LazyContainer([ + TypeFoo::class => static function () use ($typeRegistry, $reentrantlyRegistered) { + if (! $typeRegistry->hasType('TestObject')) { + $typeRegistry->registerType($reentrantlyRegistered); + } + + return new TypeFoo(); + }, + ]); + + $typeGenerator = new TypeGenerator( + $this->getAnnotationReader(), + new NamingStrategy(), + $typeRegistry, + $container, + $this->getTypeMapper(), + $this->getFieldsBuilder(), + ); + + $this->assertSame($reentrantlyRegistered, $typeGenerator->mapAnnotatedObject(TypeFoo::class)); + } }