From bf751d9da1ea2146886119ab4686fef8a6915166 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Fri, 16 Oct 2026 00:33:29 +0530 Subject: [PATCH] Send repeated response headers as one line so Swoole keeps every value Swoole keeps one value per header name: a second header() call for the same name replaces the first. sendResponseHeaders() called header() once per value, so a response with several Link, Vary or Cache-Control values reached the client with only the last one. In production a page carrying ten Link values (llms.txt, API catalog, MCP server card, agent skills, Vite preloads...) arrived with one, and Cloudflare Early Hints never had a preload link to cache. Repeated values now go out as one comma-separated field line (RFC 9110, section 5.3). Set-Cookie keeps its own path, one cookie() call per cookie. Verified on a real Swoole 6.3.0 server (PHP 8.3): before, Link carried only the last of three values; after, all three, with cookies and single-value headers unchanged. --- src/Swoole/SwooleClient.php | 9 +- .../Unit/SwooleClientResponseHeadersTest.php | 101 ++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/SwooleClientResponseHeadersTest.php diff --git a/src/Swoole/SwooleClient.php b/src/Swoole/SwooleClient.php index c0e7f8f..5b230f1 100644 --- a/src/Swoole/SwooleClient.php +++ b/src/Swoole/SwooleClient.php @@ -172,10 +172,13 @@ public function sendResponseHeaders(Response $response, SwooleResponse $swooleRe $formatHeaders = config('octane.swoole.format_headers', true); + // Swoole keeps one value per header name: a second header() call for the + // same name replaces the first, so only the last of several Link, Vary or + // Cache-Control values used to reach the client. Send repeated values as + // one comma-separated field line instead (RFC 9110, section 5.3). + // Set-Cookie is the exception to that rule; it is sent below, one per cookie. foreach ($headers as $name => $values) { - foreach ($values as $value) { - $swooleResponse->header($name, $value, $formatHeaders); - } + $swooleResponse->header($name, implode(', ', $values), $formatHeaders); } if (! is_null($reason = $this->getReasonFromStatusCode($response->getStatusCode()))) { diff --git a/tests/Unit/SwooleClientResponseHeadersTest.php b/tests/Unit/SwooleClientResponseHeadersTest.php new file mode 100644 index 0000000..68465d0 --- /dev/null +++ b/tests/Unit/SwooleClientResponseHeadersTest.php @@ -0,0 +1,101 @@ + + */ + private array $sent = []; + + protected function tearDown(): void + { + Mockery::close(); + + parent::tearDown(); + } + + private function swooleResponse(): SwooleResponse + { + $this->sent = []; + + $swoole = Mockery::mock(SwooleResponse::class); + $swoole->shouldReceive('header')->andReturnUsing(function (string $name, string $value) { + $this->sent[] = [$name, $value]; + + return true; + }); + $swoole->shouldReceive('status')->andReturnTrue(); + $swoole->shouldReceive('cookie')->andReturnTrue(); + $swoole->shouldReceive('rawcookie')->andReturnTrue(); + + return $swoole; + } + + /** + * @return list + */ + private function sentValues(string $name): array + { + return array_values(array_map( + fn (array $header) => $header[1], + array_filter($this->sent, fn (array $header) => strcasecmp($header[0], $name) === 0), + )); + } + + public function test_repeated_header_values_go_out_as_one_comma_separated_line(): void + { + $response = new Response('ok'); + $response->headers->set('Link', '; rel=preload; as=style'); + $response->headers->set('Link', '; rel="llms-txt"', false); + $response->headers->set('Link', '; rel="privacy-policy"', false); + + (new SwooleClient)->sendResponseHeaders($response, $this->swooleResponse()); + + // One header() call, so Swoole has nothing to overwrite. + $this->assertSame( + ['; rel=preload; as=style, ; rel="llms-txt", ; rel="privacy-policy"'], + $this->sentValues('Link'), + ); + } + + public function test_single_value_headers_are_sent_unchanged(): void + { + $response = new Response('ok'); + $response->headers->set('X-Single', 'one'); + $response->headers->set('Cache-Control', 'no-cache, private'); + + (new SwooleClient)->sendResponseHeaders($response, $this->swooleResponse()); + + $this->assertSame(['one'], $this->sentValues('X-Single')); + $this->assertSame(['no-cache, private'], $this->sentValues('Cache-Control')); + } + + public function test_cookies_are_never_joined_into_a_header_line(): void + { + $response = new Response('ok'); + $response->headers->setCookie(Cookie::create('a', '1')); + $response->headers->setCookie(Cookie::create('b', '2')); + + $swoole = $this->swooleResponse(); + (new SwooleClient)->sendResponseHeaders($response, $swoole); + + $this->assertSame([], $this->sentValues('Set-Cookie')); + $swoole->shouldHaveReceived('cookie')->twice(); + } +}