From 3603151e4d2d121773a8df7b3b9093646afcc783 Mon Sep 17 00:00:00 2001 From: PurHur Date: Fri, 26 Jun 2026 09:57:20 +0000 Subject: [PATCH] Fix ServeCompileCache corrupting header literals on reuse (#12040) Cached Block constants were shared by reference into frame scope; builtins coerced them in place so the second phpc serve request queued an empty header and truncated HTTP bodies. Clone constants per run and ignore blank header lines. Co-authored-by: Cursor --- lib/Block.php | 22 +++++- lib/Web/DevServer.php | 3 + lib/Web/ResponseContext.php | 3 + .../Web/ServeCompileCacheConstantsTest.php | 74 +++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 test/unit/Web/ServeCompileCacheConstantsTest.php diff --git a/lib/Block.php b/lib/Block.php index 334cbd30380..d2723894f0a 100755 --- a/lib/Block.php +++ b/lib/Block.php @@ -466,6 +466,20 @@ public function registerConstant(Operand $operand, Variable $const): int { return $slot; } + /** + * Per-run copy of a compile-time constant for frame scope (#12040, ServeCompileCache). + * + * Cached {@see Block} instances must not share {@see Variable} cells with execution + * scope — builtins may coerce/mutate argument operands in place. + */ + private function scopeConstantVariable(int $slot): Variable + { + $copy = new Variable(); + $copy->duplicateFrom($this->constants[$slot]); + + return $copy; + } + /** * Copy cfg Var root slot mappings from a sibling branch (?: / if merge, #3790). */ @@ -875,7 +889,7 @@ public function getFrame(Context $context, ?Frame $frame = null): Frame { // Variable reads in args must still resolve (#3787 merge + literal arm). if (isset($scope[$pos]) && !$this->args->contains($op)) { if (isset($this->constants[$pos])) { - $scope[$pos] = $this->constants[$pos]; + $scope[$pos] = $this->scopeConstantVariable($pos); } continue; @@ -908,7 +922,7 @@ public function getFrame(Context $context, ?Frame $frame = null): Frame { } if (isset($this->constants[$pos]) && !$this->args->contains($op)) { - $scope[$pos] = $this->constants[$pos]; + $scope[$pos] = $this->scopeConstantVariable($pos); } elseif (isset($this->closureCaptureSlots[$pos])) { $scope[$pos] = self::initialVariableForOperand($op, $context, $pos, $this); } elseif ($this->isArgRecvParameterSlot($pos)) { @@ -1055,13 +1069,13 @@ private function ensureOpcodeReferencedSlots(array &$scope): void for ($i = 0; $i <= $max; ++$i) { if (isset($scope[$i])) { if (isset($this->constants[$i])) { - $scope[$i] = $this->constants[$i]; + $scope[$i] = $this->scopeConstantVariable($i); } continue; } if (isset($this->constants[$i])) { - $scope[$i] = $this->constants[$i]; + $scope[$i] = $this->scopeConstantVariable($i); continue; } diff --git a/lib/Web/DevServer.php b/lib/Web/DevServer.php index 4e4220804e3..698273a6671 100644 --- a/lib/Web/DevServer.php +++ b/lib/Web/DevServer.php @@ -680,6 +680,9 @@ public static function respond($conn, int $status, string $contentType, string $ $out .= "Content-Type: {$contentType}\r\n"; $out .= 'Content-Length: '.strlen($body)."\r\n"; foreach ($extraHeaders as $line) { + if ('' === $line) { + continue; + } if (stripos($line, 'Content-Type:') === 0) { continue; } diff --git a/lib/Web/ResponseContext.php b/lib/Web/ResponseContext.php index 80346fdcca4..5440353da6a 100644 --- a/lib/Web/ResponseContext.php +++ b/lib/Web/ResponseContext.php @@ -122,6 +122,9 @@ public static function assertSafeHeaderLine(string $line): void public static function addHeader(string $line, bool $replace = true): void { + if ('' === $line) { + return; + } self::assertSafeHeaderLine($line); if (preg_match('#^HTTP/\d(?:\.\d)?\s+(\d{3})#', $line, $m)) { self::setStatus((int) $m[1]); diff --git a/test/unit/Web/ServeCompileCacheConstantsTest.php b/test/unit/Web/ServeCompileCacheConstantsTest.php new file mode 100644 index 00000000000..bd4b5b95eb8 --- /dev/null +++ b/test/unit/Web/ServeCompileCacheConstantsTest.php @@ -0,0 +1,74 @@ +assertNotFalse($script); + $docroot = dirname($script); + + ServeCompileCache::enable(); + $runtime = new Runtime(); + [$projectDir, $manifest] = ProjectBootstrap::resolveFromScript($script); + ProjectBootstrap::prepare($runtime, $projectDir, $manifest); + $block = ServeCompileCache::getFile($runtime, $script); + $this->assertNotNull($block); + + foreach (['/example.php', '/example.php'] as $requestUri) { + ResponseContext::reset(); + ResponseContext::enableHeaderQueue(); + VmSession::reset(); + VmStatCache::reset(); + OutputBuffer::reset(); + ShutdownQueue::reset(); + DevServer::clearHttpServerKeys(); + + putenv('REQUEST_METHOD=GET'); + putenv('QUERY_STRING='); + putenv('REQUEST_BODY='); + putenv('SCRIPT_NAME=/example.php'); + putenv('SCRIPT_FILENAME='.$script); + putenv('REQUEST_URI='.$requestUri); + putenv('DOCUMENT_ROOT='.$docroot); + putenv('SERVER_PROTOCOL=HTTP/1.1'); + putenv('PATH_INFO'); + Superglobals::applyHttpHeaders(['host' => 'localhost', 'connection' => 'close']); + + $run = new Runtime(); + Superglobals::populateFromEnvironment($run->vmContext, '', '', $script); + ProjectBootstrap::prepare($run, $projectDir, $manifest); + $cached = ServeCompileCache::getFile($run, $script); + $this->assertSame($block, $cached); + $run->run($cached); + + $this->assertSame( + ['Content-Type: text/html; charset=UTF-8'], + ResponseContext::listHeaders(), + 'request '.$requestUri + ); + } + } +}