From eed339937b1001c7da9ccd563f9530a6848ee2b0 Mon Sep 17 00:00:00 2001 From: camilleislasse Date: Sat, 29 Nov 2025 11:40:04 +0100 Subject: [PATCH] Fix FrankenPHP worker mode compatibility The Protocol keeps a reference to the transport but never resets it after a request. In worker mode (FrankenPHP, RoadRunner), the same Protocol instance is reused across requests, causing "Protocol already connected to a transport" error on subsequent requests. Add Protocol::disconnect() to reset the transport reference, called in Server::run()'s finally block after transport->close(). Fixes symfony/ai#999 --- src/Server.php | 1 + src/Server/Protocol.php | 12 ++++++ tests/Unit/ServerTest.php | 77 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/Server.php b/src/Server.php index 8657610a..14c231b3 100644 --- a/src/Server.php +++ b/src/Server.php @@ -53,6 +53,7 @@ public function run(TransportInterface $transport): mixed return $transport->listen(); } finally { $transport->close(); + $this->protocol->disconnect(); } } } diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index c3b42f58..b7094eae 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -110,6 +110,18 @@ public function connect(TransportInterface $transport): void $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); } + /** + * Disconnect the protocol from the current transport. + * + * This resets the transport reference, allowing the protocol to be reused + * with a new transport (e.g., in FrankenPHP worker mode where the same + * Protocol instance handles multiple requests). + */ + public function disconnect(): void + { + $this->transport = null; + } + /** * Handle an incoming message from the transport. * diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index f7a8a370..102460f5 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -73,6 +73,12 @@ public function testRunOrchestatesTransportLifecycle(): void $callOrder[] = 'close'; }); + $this->protocol->expects($this->once()) + ->method('disconnect') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'disconnect'; + }); + $server = new Server($this->protocol); $result = $server->run($this->transport); @@ -81,6 +87,7 @@ public function testRunOrchestatesTransportLifecycle(): void 'connect', 'listen', 'close', + 'disconnect', ], $callOrder); $this->assertEquals(0, $result); @@ -154,4 +161,74 @@ public function testRunConnectsProtocolToTransport(): void $server = new Server($this->protocol); $server->run($this->transport); } + + #[TestDox('run() disconnects protocol after completion (worker mode support)')] + public function testRunDisconnectsProtocolAfterCompletion(): void + { + $this->transport->method('initialize'); + $this->transport->method('listen')->willReturn(0); + $this->transport->method('close'); + + $this->protocol->expects($this->once())->method('connect'); + $this->protocol->expects($this->once())->method('disconnect'); + + $server = new Server($this->protocol); + $server->run($this->transport); + } + + #[TestDox('run() disconnects protocol even if listen() throws (worker mode support)')] + public function testRunDisconnectsProtocolEvenOnException(): void + { + $this->transport->method('initialize'); + $this->protocol->method('connect'); + + $this->transport->expects($this->once()) + ->method('listen') + ->willThrowException(new \RuntimeException('Transport error')); + + $this->transport->expects($this->once())->method('close'); + $this->protocol->expects($this->once())->method('disconnect'); + + $server = new Server($this->protocol); + + $this->expectException(\RuntimeException::class); + $server->run($this->transport); + } + + #[TestDox('run() can be called multiple times with different transports (worker mode)')] + public function testRunCanBeCalledMultipleTimes(): void + { + $callOrder = []; + + $transport1 = $this->createMock(TransportInterface::class); + $transport2 = $this->createMock(TransportInterface::class); + + $transport1->method('initialize'); + $transport1->method('listen')->willReturn(1); + $transport1->method('close'); + + $transport2->method('initialize'); + $transport2->method('listen')->willReturn(2); + $transport2->method('close'); + + // Use a real-ish protocol behavior simulation + $this->protocol->expects($this->exactly(2)) + ->method('connect') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'connect'; + }); + + $this->protocol->expects($this->exactly(2)) + ->method('disconnect') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'disconnect'; + }); + + $server = new Server($this->protocol); + + $this->assertEquals(1, $server->run($transport1)); + $this->assertEquals(2, $server->run($transport2)); + + $this->assertEquals(['connect', 'disconnect', 'connect', 'disconnect'], $callOrder); + } }