From 3f58ccb2a0ced7cff68b917448de492f12949381 Mon Sep 17 00:00:00 2001 From: Outcomer <773021792e@gmail.com> Date: Mon, 3 Aug 2026 21:02:17 +0200 Subject: [PATCH 1/4] [Swoole] Make StreamedResponse chunk size configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcoded 4096-byte ob_start() threshold starves SSE — small, time-spaced writes never reach the client until the buffer fills or the response ends. Adds a `streamed_response_chunk_size` ServerFactory option (default 4096, unchanged behavior), grouped into a BridgeOptions value object. --- src/swoole/src/BridgeOptions.php | 49 +++++++++++++++++++++ src/swoole/src/LaravelRunner.php | 5 ++- src/swoole/src/ServerFactory.php | 2 + src/swoole/src/SymfonyHttpBridge.php | 6 ++- src/swoole/src/SymfonyRunner.php | 5 ++- src/swoole/tests/Unit/BridgeOptionsTest.php | 39 ++++++++++++++++ src/swoole/tests/Unit/LaravelRunnerTest.php | 33 ++++++++++++++ src/swoole/tests/Unit/SymfonyRunnerTest.php | 33 ++++++++++++++ 8 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/swoole/src/BridgeOptions.php create mode 100644 src/swoole/tests/Unit/BridgeOptionsTest.php diff --git a/src/swoole/src/BridgeOptions.php b/src/swoole/src/BridgeOptions.php new file mode 100644 index 00000000..9505ad6a --- /dev/null +++ b/src/swoole/src/BridgeOptions.php @@ -0,0 +1,49 @@ + + */ +class BridgeOptions +{ + private const DEFAULT_STREAMED_RESPONSE_CHUNK_SIZE = 4096; + + /** @var int */ + private $streamedResponseChunkSize; + + public function __construct(int $streamedResponseChunkSize = self::DEFAULT_STREAMED_RESPONSE_CHUNK_SIZE) + { + $this->streamedResponseChunkSize = $streamedResponseChunkSize; + } + + /** + * Builds options from a ServerFactory-style options array — the same shape ServerFactory + * itself accepts, so a single $options array can configure both. + * + * @param array $options + */ + public static function fromArray(array $options): self + { + return new self( + (int) ($options['streamed_response_chunk_size'] ?? self::DEFAULT_STREAMED_RESPONSE_CHUNK_SIZE) + ); + } + + /** + * Size, in bytes, at which a StreamedResponse's output buffer is flushed to the Swoole + * response. The default trades latency for fewer write() syscalls, which is fine for + * responses that fill it quickly (e.g. files) but starves a Server-Sent Events response, + * whose chunks are small and spaced out in time: nothing reaches the client until this many + * bytes accumulate or the response ends, which for a long-lived SSE connection reads as the + * connection hanging and can trip a reverse proxy's origin timeout. Use 1 for SSE (or any + * response where each write must reach the client immediately). + */ + public function getStreamedResponseChunkSize(): int + { + return $this->streamedResponseChunkSize; + } +} diff --git a/src/swoole/src/LaravelRunner.php b/src/swoole/src/LaravelRunner.php index 12a0a3f5..3eaae388 100644 --- a/src/swoole/src/LaravelRunner.php +++ b/src/swoole/src/LaravelRunner.php @@ -19,11 +19,14 @@ class LaravelRunner implements RunnerInterface private $serverFactory; /** @var Kernel */ private $application; + /** @var BridgeOptions */ + private $bridgeOptions; public function __construct(ServerFactory $serverFactory, Kernel $application) { $this->serverFactory = $serverFactory; $this->application = $application; + $this->bridgeOptions = BridgeOptions::fromArray($serverFactory->getOptions()); } public function run(): int @@ -38,7 +41,7 @@ public function handle(Request $request, Response $response): void $sfRequest = LaravelRequest::createFromBase(SymfonyHttpBridge::convertSwooleRequest($request)); $sfResponse = $this->application->handle($sfRequest); - SymfonyHttpBridge::reflectSymfonyResponse($sfResponse, $response); + SymfonyHttpBridge::reflectSymfonyResponse($sfResponse, $response, $this->bridgeOptions); $this->application->terminate($sfRequest, $sfResponse); } diff --git a/src/swoole/src/ServerFactory.php b/src/swoole/src/ServerFactory.php index 18c08edb..5f2b8739 100644 --- a/src/swoole/src/ServerFactory.php +++ b/src/swoole/src/ServerFactory.php @@ -17,6 +17,7 @@ class ServerFactory 'mode' => 2, // SWOOLE_PROCESS 'sock_type' => 1, // SWOOLE_SOCK_TCP 'settings' => [], + 'streamed_response_chunk_size' => 4096, ]; /** @var array */ @@ -33,6 +34,7 @@ public function __construct(array $options = []) $options['port'] = $options['port'] ?? $_SERVER['SWOOLE_PORT'] ?? $_ENV['SWOOLE_PORT'] ?? self::DEFAULT_OPTIONS['port']; $options['mode'] = $options['mode'] ?? $_SERVER['SWOOLE_MODE'] ?? $_ENV['SWOOLE_MODE'] ?? self::DEFAULT_OPTIONS['mode']; $options['sock_type'] = $options['sock_type'] ?? $_SERVER['SWOOLE_SOCK_TYPE'] ?? $_ENV['SWOOLE_SOCK_TYPE'] ?? self::DEFAULT_OPTIONS['sock_type']; + $options['streamed_response_chunk_size'] = $options['streamed_response_chunk_size'] ?? $_SERVER['SWOOLE_STREAMED_RESPONSE_CHUNK_SIZE'] ?? $_ENV['SWOOLE_STREAMED_RESPONSE_CHUNK_SIZE'] ?? self::DEFAULT_OPTIONS['streamed_response_chunk_size']; $this->options = array_replace_recursive(self::DEFAULT_OPTIONS, $options); } diff --git a/src/swoole/src/SymfonyHttpBridge.php b/src/swoole/src/SymfonyHttpBridge.php index 27303f64..97920ab3 100644 --- a/src/swoole/src/SymfonyHttpBridge.php +++ b/src/swoole/src/SymfonyHttpBridge.php @@ -35,8 +35,10 @@ public static function convertSwooleRequest(Request $request): SymfonyRequest return $sfRequest; } - public static function reflectSymfonyResponse(SymfonyResponse $sfResponse, Response $response): void + public static function reflectSymfonyResponse(SymfonyResponse $sfResponse, Response $response, ?BridgeOptions $options = null): void { + $options = $options ?? new BridgeOptions(); + foreach ($sfResponse->headers->all() as $name => $values) { foreach ((array) $values as $value) { $response->header((string) $name, $value); @@ -52,7 +54,7 @@ public static function reflectSymfonyResponse(SymfonyResponse $sfResponse, Respo $response->write($buffer); return ''; - }, 4096); + }, $options->getStreamedResponseChunkSize()); $sfResponse->sendContent(); ob_end_clean(); $response->end(); diff --git a/src/swoole/src/SymfonyRunner.php b/src/swoole/src/SymfonyRunner.php index 352ff75f..9c3ec85a 100644 --- a/src/swoole/src/SymfonyRunner.php +++ b/src/swoole/src/SymfonyRunner.php @@ -19,11 +19,14 @@ class SymfonyRunner implements RunnerInterface private $serverFactory; /** @var HttpKernelInterface */ private $application; + /** @var BridgeOptions */ + private $bridgeOptions; public function __construct(ServerFactory $serverFactory, HttpKernelInterface $application) { $this->serverFactory = $serverFactory; $this->application = $application; + $this->bridgeOptions = BridgeOptions::fromArray($serverFactory->getOptions()); } public function run(): int @@ -38,7 +41,7 @@ public function handle(Request $request, Response $response): void $sfRequest = SymfonyHttpBridge::convertSwooleRequest($request); $sfResponse = $this->application->handle($sfRequest); - SymfonyHttpBridge::reflectSymfonyResponse($sfResponse, $response); + SymfonyHttpBridge::reflectSymfonyResponse($sfResponse, $response, $this->bridgeOptions); if ($this->application instanceof TerminableInterface) { $this->application->terminate($sfRequest, $sfResponse); diff --git a/src/swoole/tests/Unit/BridgeOptionsTest.php b/src/swoole/tests/Unit/BridgeOptionsTest.php new file mode 100644 index 00000000..faa4d16c --- /dev/null +++ b/src/swoole/tests/Unit/BridgeOptionsTest.php @@ -0,0 +1,39 @@ +getStreamedResponseChunkSize()); + } + + public function testExplicitStreamedResponseChunkSize(): void + { + $options = new BridgeOptions(1); + + self::assertSame(1, $options->getStreamedResponseChunkSize()); + } + + public function testFromArrayWithGivenChunkSize(): void + { + $options = BridgeOptions::fromArray(['streamed_response_chunk_size' => 1]); + + self::assertSame(1, $options->getStreamedResponseChunkSize()); + } + + public function testFromArrayFallsBackToDefaultChunkSize(): void + { + $options = BridgeOptions::fromArray([]); + + self::assertSame(4096, $options->getStreamedResponseChunkSize()); + } +} diff --git a/src/swoole/tests/Unit/LaravelRunnerTest.php b/src/swoole/tests/Unit/LaravelRunnerTest.php index c08abad5..843ee299 100644 --- a/src/swoole/tests/Unit/LaravelRunnerTest.php +++ b/src/swoole/tests/Unit/LaravelRunnerTest.php @@ -12,6 +12,7 @@ use Swoole\Http\Response; use Swoole\Http\Server; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\StreamedResponse; class LaravelRunnerTest extends TestCase { @@ -46,4 +47,36 @@ public function testHandle(): void $runner = new LaravelRunner($factory, $application); $runner->handle($request, $response); } + + public function testHandleHonoursTheServerFactorysStreamedResponseChunkSize(): void + { + // A chunk size of 1 forces a write() on every echo, regardless of its length — the setting + // this test exists to prove reaches SymfonyHttpBridge from ServerFactory's options. + $sfResponse = new StreamedResponse(static function () { + echo 'Foo'; + echo 'Bar'; + }); + + $application = $this->createMock(Kernel::class); + $application->expects(self::once())->method('handle')->willReturn($sfResponse); + + $response = $this->createMock(Response::class); + $expectedWrites = ['Foo', 'Bar', '']; + $callCount = 0; + $response->expects(self::exactly(3))->method('write') + ->willReturnCallback(function ($string) use ($expectedWrites, &$callCount) { + $this->assertEquals($expectedWrites[$callCount], $string); + ++$callCount; + + return true; + }); + $response->expects(self::once())->method('end'); + + $request = $this->createMock(Request::class); + $factory = $this->createMock(ServerFactory::class); + $factory->method('getOptions')->willReturn(['streamed_response_chunk_size' => 1]); + + $runner = new LaravelRunner($factory, $application); + $runner->handle($request, $response); + } } diff --git a/src/swoole/tests/Unit/SymfonyRunnerTest.php b/src/swoole/tests/Unit/SymfonyRunnerTest.php index 8deee0af..f2b97e3d 100644 --- a/src/swoole/tests/Unit/SymfonyRunnerTest.php +++ b/src/swoole/tests/Unit/SymfonyRunnerTest.php @@ -11,6 +11,7 @@ use Swoole\Http\Response; use Swoole\Http\Server; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\HttpKernelInterface; class SymfonyRunnerTest extends TestCase @@ -46,4 +47,36 @@ public function testHandle(): void $runner = new SymfonyRunner($factory, $application); $runner->handle($request, $response); } + + public function testHandleHonoursTheServerFactorysStreamedResponseChunkSize(): void + { + // A chunk size of 1 forces a write() on every echo, regardless of its length — the setting + // this test exists to prove reaches SymfonyHttpBridge from ServerFactory's options. + $sfResponse = new StreamedResponse(static function () { + echo 'Foo'; + echo 'Bar'; + }); + + $application = $this->createMock(HttpKernelInterface::class); + $application->expects(self::once())->method('handle')->willReturn($sfResponse); + + $response = $this->createMock(Response::class); + $expectedWrites = ['Foo', 'Bar', '']; + $callCount = 0; + $response->expects(self::exactly(3))->method('write') + ->willReturnCallback(function ($string) use ($expectedWrites, &$callCount) { + $this->assertEquals($expectedWrites[$callCount], $string); + ++$callCount; + + return true; + }); + $response->expects(self::once())->method('end'); + + $request = $this->createMock(Request::class); + $factory = $this->createMock(ServerFactory::class); + $factory->method('getOptions')->willReturn(['streamed_response_chunk_size' => 1]); + + $runner = new SymfonyRunner($factory, $application); + $runner->handle($request, $response); + } } From ec5f0052da2c3ced434b57a22a80bdf0562f541a Mon Sep 17 00:00:00 2001 From: Outcomer <773021792e@gmail.com> Date: Mon, 3 Aug 2026 23:25:29 +0200 Subject: [PATCH 2/4] CS fix --- src/swoole/src/BridgeOptions.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/swoole/src/BridgeOptions.php b/src/swoole/src/BridgeOptions.php index 9505ad6a..91ef6dea 100644 --- a/src/swoole/src/BridgeOptions.php +++ b/src/swoole/src/BridgeOptions.php @@ -23,8 +23,6 @@ public function __construct(int $streamedResponseChunkSize = self::DEFAULT_STREA /** * Builds options from a ServerFactory-style options array — the same shape ServerFactory * itself accepts, so a single $options array can configure both. - * - * @param array $options */ public static function fromArray(array $options): self { From f9a2e18918c50a44f2625eed1fbea03389860c0a Mon Sep 17 00:00:00 2001 From: Outcomer <773021792e@gmail.com> Date: Tue, 4 Aug 2026 20:03:46 +0200 Subject: [PATCH 3/4] [Swoole] Skip empty writes to the Swoole response --- src/swoole/src/SymfonyHttpBridge.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/swoole/src/SymfonyHttpBridge.php b/src/swoole/src/SymfonyHttpBridge.php index 97920ab3..d3f35293 100644 --- a/src/swoole/src/SymfonyHttpBridge.php +++ b/src/swoole/src/SymfonyHttpBridge.php @@ -51,7 +51,14 @@ public static function reflectSymfonyResponse(SymfonyResponse $sfResponse, Respo case $sfResponse instanceof BinaryFileResponse && $sfResponse->headers->has('Content-Range'): case $sfResponse instanceof StreamedResponse: ob_start(function ($buffer) use ($response) { - $response->write($buffer); + // At a small chunk size (e.g. 1, for SSE) every echo already flushes on its + // own, so the buffer is typically empty by the time ob_end_clean() makes its + // own final call to this handler. Swoole\Http\Response::write() itself checks + // length === 0 and rejects it with a warning ("the data sent must not be + // empty"), so this mirrors that same check to skip the call instead. + if (strlen($buffer) > 0) { + $response->write($buffer); + } return ''; }, $options->getStreamedResponseChunkSize()); From 0e52bccad163ddb51cffcb710d32da1cce5e6e80 Mon Sep 17 00:00:00 2001 From: Outcomer <773021792e@gmail.com> Date: Tue, 4 Aug 2026 20:11:25 +0200 Subject: [PATCH 4/4] Fix tests for the empty-write skip --- src/swoole/tests/Unit/LaravelRunnerTest.php | 4 ++-- src/swoole/tests/Unit/SymfonyHttpBridgeTest.php | 3 +-- src/swoole/tests/Unit/SymfonyRunnerTest.php | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/swoole/tests/Unit/LaravelRunnerTest.php b/src/swoole/tests/Unit/LaravelRunnerTest.php index 843ee299..a8a35277 100644 --- a/src/swoole/tests/Unit/LaravelRunnerTest.php +++ b/src/swoole/tests/Unit/LaravelRunnerTest.php @@ -61,9 +61,9 @@ public function testHandleHonoursTheServerFactorysStreamedResponseChunkSize(): v $application->expects(self::once())->method('handle')->willReturn($sfResponse); $response = $this->createMock(Response::class); - $expectedWrites = ['Foo', 'Bar', '']; + $expectedWrites = ['Foo', 'Bar']; $callCount = 0; - $response->expects(self::exactly(3))->method('write') + $response->expects(self::exactly(2))->method('write') ->willReturnCallback(function ($string) use ($expectedWrites, &$callCount) { $this->assertEquals($expectedWrites[$callCount], $string); ++$callCount; diff --git a/src/swoole/tests/Unit/SymfonyHttpBridgeTest.php b/src/swoole/tests/Unit/SymfonyHttpBridgeTest.php index f025dc58..01bb1d53 100644 --- a/src/swoole/tests/Unit/SymfonyHttpBridgeTest.php +++ b/src/swoole/tests/Unit/SymfonyHttpBridgeTest.php @@ -107,10 +107,9 @@ public function testThatSymfonyStreamedResponseIsReflected(): void $expectedWrites = [ "Foo\n", "Bar\n", - '', ]; $callCount = 0; - $response->expects(self::exactly(3))->method('write') + $response->expects(self::exactly(2))->method('write') ->willReturnCallback(function ($string) use ($expectedWrites, &$callCount) { $this->assertEquals($expectedWrites[$callCount], $string); ++$callCount; diff --git a/src/swoole/tests/Unit/SymfonyRunnerTest.php b/src/swoole/tests/Unit/SymfonyRunnerTest.php index f2b97e3d..cdcf39e5 100644 --- a/src/swoole/tests/Unit/SymfonyRunnerTest.php +++ b/src/swoole/tests/Unit/SymfonyRunnerTest.php @@ -61,9 +61,9 @@ public function testHandleHonoursTheServerFactorysStreamedResponseChunkSize(): v $application->expects(self::once())->method('handle')->willReturn($sfResponse); $response = $this->createMock(Response::class); - $expectedWrites = ['Foo', 'Bar', '']; + $expectedWrites = ['Foo', 'Bar']; $callCount = 0; - $response->expects(self::exactly(3))->method('write') + $response->expects(self::exactly(2))->method('write') ->willReturnCallback(function ($string) use ($expectedWrites, &$callCount) { $this->assertEquals($expectedWrites[$callCount], $string); ++$callCount;